fix(companies): align optional numeric fields with API validation
continuous-integration/drone/push Build is passing

Make employee_count, annual_revenue, and founded_year truly optional in
the create company form and payload. Add field validation docs and
Cypress e2e coverage for dashboard and notifications.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
AmirReza Jamali
2026-06-16 11:47:45 +03:30
parent 2fec8bb84c
commit 169708e49d
8 changed files with 678 additions and 18 deletions
+228
View File
@@ -0,0 +1,228 @@
/// <reference types="cypress" />
const apiResponse = (data: Record<string, unknown>) => ({
success: true,
message: "ok",
data,
});
const visitDashboard = () => {
cy.visit("/", {
onBeforeLoad(win) {
cy.stub(win, "matchMedia").callsFake((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}));
},
});
};
const stubDashboardRequests = () => {
const now = Math.floor(Date.now() / 1000);
cy.intercept("GET", "**/api/proxy/dashboard/summary**", (req) => {
expect(req.query.closing_window).to.be.undefined;
req.reply({
statusCode: 200,
body: apiResponse({
total_tenders: 120,
active_tenders: 72,
awarded_tenders: 18,
expired_tenders: 20,
cancelled_tenders: 10,
closing_soon: 12,
total_estimated_value: 2500000,
value_currency: "EUR",
generated_at: now,
}),
});
}).as("dashboardSummary");
cy.intercept("GET", "**/api/proxy/dashboard/trend**", (req) => {
expect(req.query).to.include({ days: "14", metric: "created" });
req.reply({
statusCode: 200,
body: apiResponse({
metric: "created",
days: 14,
series: [
{ date: "2026-06-07", count: 4 },
{ date: "2026-06-08", count: 7 },
{ date: "2026-06-09", count: 9 },
],
}),
});
}).as("dashboardTrend");
cy.intercept("GET", "**/api/proxy/dashboard/countries**", (req) => {
expect(req.query.limit).to.eq("6");
req.reply({
statusCode: 200,
body: apiResponse({
total: 120,
items: [
{ country_code: "DE", count: 60 },
{ country_code: "FR", count: 30 },
{ country_code: "NL", count: 10 },
],
other_count: 20,
}),
});
}).as("dashboardCountries");
cy.intercept("GET", "**/api/proxy/dashboard/notice-types**", {
statusCode: 200,
body: apiResponse({
total: 100,
items: [
{ type: "CONTRACT_NOTICE", count: 70 },
{ type: "AWARD_NOTICE", count: 30 },
],
}),
}).as("dashboardNoticeTypes");
cy.intercept("GET", "**/api/proxy/dashboard/closing-soon**", (req) => {
expect(req.query.limit).to.eq("5");
req.reply({
statusCode: 200,
body: apiResponse({
items: [
{
id: "closing-tender-1",
title: "Railway signaling upgrade",
country_code: "DE",
buyer_name: "Federal Rail Agency",
submission_deadline: now + 36 * 60 * 60,
status: "active",
},
],
}),
});
}).as("dashboardClosingSoon");
cy.intercept("GET", "**/api/proxy/tenders**", (req) => {
expect(req.query).to.include({
sort_by: "created_at",
sort_order: "desc",
offset: "0",
limit: "5",
});
req.reply({
statusCode: 200,
body: apiResponse({
tenders: [
{
id: "recent-tender-1",
title: "Hospital equipment framework",
country_code: "FR",
status: "active",
created_at: now - 60 * 60,
buyer_organization: { name: "Central Health Buyer" },
},
],
}),
});
}).as("recentTenders");
cy.intercept("GET", "**/api/proxy/dashboard/statistics**", (req) => {
const days = Number(req.query.days);
req.alias = days === 30 ? "statistics30Days" : "statistics14Days";
req.reply({
statusCode: 200,
body: apiResponse({
days,
generated_at: now,
daily: {
scraped_ted: [
{ date: "2026-06-08", count: days === 30 ? 12 : 5 },
{ date: "2026-06-09", count: days === 30 ? 18 : 8 },
],
scraped_documents: [
{ date: "2026-06-08", count: days === 30 ? 40 : 20 },
{ date: "2026-06-09", count: days === 30 ? 60 : 30 },
],
translated_notices: [
{ date: "2026-06-08", count: days === 30 ? 7 : 3 },
{ date: "2026-06-09", count: days === 30 ? 11 : 6 },
],
},
totals: {
scraped_documents: days === 30 ? 9876 : 4321,
translated_tenders: days === 30 ? 654 : 321,
},
}),
});
});
};
describe("tender dashboard", () => {
beforeEach(() => {
cy.setAuthCookies();
stubDashboardRequests();
});
it("renders the new dashboard sections with API data and detail links", () => {
visitDashboard();
cy.wait([
"@dashboardSummary",
"@dashboardTrend",
"@dashboardCountries",
"@dashboardNoticeTypes",
"@dashboardClosingSoon",
"@recentTenders",
"@statistics14Days",
]);
cy.get('h1[aria-label*="welcome to OppLens"]')
.should("be.visible")
.and("have.attr", "aria-label")
.and("include", "welcome to OppLens");
cy.contains("Total Tenders").parent().should("contain.text", "120");
cy.contains("Active Now").parent().should("contain.text", "72");
cy.contains("Closing Soon").parent().should("contain.text", "12");
cy.contains("Estimated Value").parent().should("contain.text", "€2.5M");
cy.contains("Tender Flow — Last 14 days").should("be.visible");
cy.contains("By Country").should("be.visible");
cy.contains(/^DE$/).should("be.visible");
cy.contains("Contract Notice").should("be.visible");
cy.contains("+70 · 70%").should("be.visible");
cy.contains("Railway signaling upgrade")
.closest("a")
.should("have.attr", "href", "/tenders/closing-tender-1");
cy.contains("Hospital equipment framework")
.closest("a")
.should("have.attr", "href", "/tenders/recent-tender-1");
cy.contains("Scraping & Translation").should("be.visible");
cy.contains("Total Scraped Documents").parent().should("contain.text", "4,321");
cy.contains("Total Translated Tenders").parent().should("contain.text", "321");
cy.contains("Scraped TED per day").should("be.visible");
cy.contains("Scraped documents per day").should("be.visible");
cy.contains("Translated notices per day").should("be.visible");
});
it("reloads scraping and translation statistics for a selected range", () => {
visitDashboard();
cy.wait("@statistics14Days");
cy.contains("button", "14d").should("have.class", "bg-primary");
cy.contains("button", "30d").click();
cy.wait("@statistics30Days")
.its("request.query.days")
.should("eq", "30");
cy.contains("button", "30d").should("have.class", "bg-primary");
cy.contains("Total Scraped Documents").parent().should("contain.text", "9,876");
cy.contains("Total Translated Tenders").parent().should("contain.text", "654");
});
});
@@ -0,0 +1,166 @@
/// <reference types="cypress" />
const emptyHistoryResponse = {
success: true,
message: "ok",
data: [],
meta: {
offset: 0,
limit: 10,
total: 0,
page: 1,
pages: 1,
has_more: false,
},
};
const stubNotificationReads = () => {
cy.intercept("GET", "**/api/proxy/notifications/my**", {
statusCode: 200,
body: emptyHistoryResponse,
});
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, {
statusCode: 200,
body: emptyHistoryResponse,
}).as("notificationHistory");
};
const chooseMultiSelectOption = (label: string, option: string) => {
cy.contains("label", new RegExp(`^${label}`, "i"))
.parent()
.find('div[tabindex="0"]')
.click();
cy.contains(
"div[class*='cursor-pointer']",
new RegExp(`^${option}$`),
).click();
};
describe("create notification", () => {
beforeEach(() => {
cy.setAuthCookies();
stubNotificationReads();
});
it("renders breadcrumbs and validates required fields and an invalid link", () => {
cy.visit("/notification-history/create");
cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
cy.getByCy("breadcrumb-link-notifications").should(
"have.attr",
"href",
"/notification-history",
);
cy.getByCy("breadcrumb-current-create-notification")
.should("be.visible")
.and("contain.text", "Create Notification");
cy.get('textarea[name="description"]').type(
"Description entered so custom validation can run.",
);
cy.getByCy("admin-form-submit-button").click();
cy.get("p")
.filter(
(_index, element) =>
element.textContent?.trim() === "This field is required",
)
.should("have.length", 4);
cy.get('input[name="link"]').type("not-a-valid-url");
cy.get('input[name="title"]').type("Validation test");
cy.get('select[name="target"]').select("all_users", { force: true });
cy.get('select[name="priority"]').select("medium", { force: true });
cy.get('select[name="type"]').select("info", { force: true });
cy.get('textarea[name="description"]')
.clear()
.type("A valid notification message.");
cy.getByCy("admin-form-submit-button").click();
cy.contains("This field does not match the required pattern").should(
"be.visible",
);
});
it("loads role recipients and submits the expected notification payload", () => {
cy.intercept("POST", "**/api/proxy/notifications", (req) => {
expect(req.body).to.deep.equal({
title: "Planned maintenance",
target: "specific_role",
recipient: ["admin"],
channels: ["email", "push"],
link: "https://example.com/maintenance",
priority: "important",
type: "warning",
description: "The platform will be unavailable during maintenance.",
schedule_at: 0,
});
req.reply({
statusCode: 201,
body: {
success: true,
message: "Notification created successfully",
data: {},
},
});
}).as("createNotification");
cy.visit("/notification-history");
cy.wait("@notificationHistory");
cy.getByCy("list-header-create-button").click();
cy.location("pathname").should("eq", "/notification-history/create");
cy.get('input[name="title"]').type("Planned maintenance");
cy.get('select[name="target"]').select("specific_role", { force: true });
chooseMultiSelectOption("recipient", "Admin");
chooseMultiSelectOption("channels", "Email");
chooseMultiSelectOption("channels", "Push");
cy.get('input[name="link"]').type("https://example.com/maintenance");
cy.get('select[name="priority"]').select("important", { force: true });
cy.get('select[name="type"]').select("warning", { force: true });
cy.get('textarea[name="description"]').type(
"The platform will be unavailable during maintenance.",
);
cy.getByCy("admin-form-submit-button").click();
cy.wait("@createNotification");
cy.location("pathname").should("eq", "/notification-history");
cy.contains("Notification created successfully").should("be.visible");
});
it("loads specific users from the API and clears recipients when target changes", () => {
cy.intercept("GET", "**/api/proxy/users**", (req) => {
expect(req.query.offset).to.eq("0");
req.reply({
statusCode: 200,
body: {
success: true,
message: "ok",
data: {
users: [
{
id: "admin-recipient-1",
full_name: "Notification Admin",
username: "notification-admin",
},
],
},
meta: { offset: 0, limit: 10, total: 1 },
},
});
}).as("notificationRecipients");
cy.visit("/notification-history/create");
cy.get('select[name="target"]').select("specific_users", { force: true });
cy.wait("@notificationRecipients");
chooseMultiSelectOption("recipient", "Notification Admin");
cy.contains("span", "Notification Admin").should("be.visible");
cy.get('select[name="target"]').select("all_users", { force: true });
cy.contains("label", /^recipient/i)
.parent()
.find('[tabindex="-1"]')
.should("exist");
});
});
@@ -0,0 +1,190 @@
/// <reference types="cypress" />
type NotificationRecord = {
id: string;
title: string;
event_type: string;
created_at: number;
message: string;
priority: string;
type: string;
seen: boolean;
status: string;
recipient?: { full_name: string };
};
const makeNotification = (
overrides: Partial<NotificationRecord> = {},
): NotificationRecord => ({
id: "notification-1",
title: "Tender deadline reminder",
event_type: "push",
created_at: 1_717_934_400,
message: "A watched tender is closing soon and needs your attention.",
priority: "important",
type: "warning",
seen: false,
status: "sent",
recipient: { full_name: "E2E Admin One" },
...overrides,
});
const historyResponse = (
notifications: NotificationRecord[],
options: { offset?: number; total?: number; pages?: number } = {},
) => ({
success: true,
message: "ok",
data: notifications,
meta: {
offset: options.offset ?? 0,
limit: 10,
total: options.total ?? notifications.length,
page: Math.floor((options.offset ?? 0) / 10) + 1,
pages: options.pages ?? 1,
has_more: false,
},
});
const stubHeaderNotifications = () => {
cy.intercept("GET", "**/api/proxy/notifications/my**", {
statusCode: 200,
body: historyResponse([]),
}).as("myNotifications");
};
describe("notification history", () => {
beforeEach(() => {
cy.setAuthCookies();
stubHeaderNotifications();
});
it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => {
const notification = makeNotification();
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
expect(req.query).to.include({
sort_by: "created_at",
sort_order: "desc",
offset: "0",
limit: "10",
});
req.reply({
statusCode: 200,
body: historyResponse([notification]),
});
}).as("notificationHistory");
cy.visit("/notification-history");
cy.wait("@notificationHistory");
cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
cy.getByCy("breadcrumb-current-notification-history")
.should("be.visible")
.and("contain.text", "Notification history");
cy.getByCy("list-header-create-button").should(
"contain.text",
"Create notification",
);
[
"row",
"title",
"channel",
"created at",
"receiver",
"message",
"priority",
"type",
"seen",
"status",
"actions",
].forEach((column) => {
cy.contains("th", new RegExp(`^${column}$`, "i")).should("be.visible");
});
cy.contains("tr", notification.title)
.should("contain.text", notification.event_type)
.and("contain.text", notification.recipient?.full_name)
.and("contain.text", notification.priority)
.and("contain.text", notification.type)
.and("contain.text", notification.status)
.within(() => {
cy.get('button[data-tooltip-id="details"]').click();
});
cy.location("pathname").should(
"eq",
`/notification-history/${notification.id}`,
);
});
it("filters by seen state and search, then resets to the default list", () => {
const defaultNotification = makeNotification({
id: "notification-default",
title: "Default notification",
});
const filteredNotification = makeNotification({
id: "notification-filtered",
title: "Filtered urgent notice",
seen: true,
});
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
const isFiltered =
req.query.seen === "true" && req.query.search === "urgent";
req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications";
req.reply({
statusCode: 200,
body: historyResponse([
isFiltered ? filteredNotification : defaultNotification,
]),
});
});
cy.visit("/notification-history");
cy.wait("@defaultNotifications");
cy.contains(defaultNotification.title).should("be.visible");
cy.getByCy("notification-history-filter-panel-toggle").click();
cy.get('select[name="seen"]').select("true", { force: true });
cy.getByCy("notification-history-filter-search-input").type("urgent");
cy.getByCy("notification-history-filter-submit-button").click();
cy.wait("@filteredNotifications");
cy.location("search")
.should("include", "seen=true")
.and("include", "search=urgent");
cy.contains(filteredNotification.title).should("be.visible");
cy.contains(defaultNotification.title).should("not.exist");
cy.contains("2 active").should("be.visible");
cy.getByCy("notification-history-filter-reset-button").click();
cy.wait("@defaultNotifications");
cy.location("search").should("eq", "");
cy.contains(defaultNotification.title).should("be.visible");
});
it("shows loading and empty states, and opens the create page", () => {
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
req.reply({
delay: 900,
statusCode: 200,
body: historyResponse([]),
});
}).as("emptyNotificationHistory");
cy.visit("/notification-history");
cy.getByCy("table-skeleton").should("be.visible");
cy.wait("@emptyNotificationHistory");
cy.getByCy("table-skeleton").should("not.exist");
cy.getByCy("empty-list-message").should(
"contain.text",
"No notifications were found",
);
cy.getByCy("list-header-create-button").click();
cy.location("pathname").should("eq", "/notification-history/create");
cy.getByCy("breadcrumb-current-create-notification").should("be.visible");
});
});
@@ -0,0 +1,32 @@
# Create Company Field Validations
This document describes validation rules for selected fields in the `Create Company` API request body (`POST /admin/v1/companies`).
Source of truth: `internal/company/form.go` (`CompanyForm`).
## employee_count
- JSON field: `employee_count`
- Type: `integer` (`*int`, optional)
- Validation tag: `optional,range(1|100000)`
- Rules:
- Field is optional.
- If provided, value must be between `1` and `100000` (inclusive).
## annual_revenue
- JSON field: `annual_revenue`
- Type: `number` (`*float64`, optional)
- Validation tag: `optional,range(0|999999999999)`
- Rules:
- Field is optional.
- If provided, value must be between `0` and `999999999999` (inclusive).
## founded_year
- JSON field: `founded_year`
- Type: `integer` (`*int`, optional)
- Validation tag: `optional,range(1800|2100)`
- Rules:
- Field is optional.
- If provided, value must be between `1800` and `2100` (inclusive).
@@ -25,6 +25,35 @@ const FieldError = ({ message }: { message?: string }) =>
<p className="mt-1.5 text-sm font-medium text-error">{message}</p>
) : null;
const optionalNumber = (value: unknown) => {
if (value === "" || value === null || value === undefined) return undefined;
return Number(value);
};
const optionalNumberRange = ({
min,
max,
integer = false,
}: {
min: number;
max: number;
integer?: boolean;
}) => {
return (value: unknown) => {
const numberValue = optionalNumber(value);
if (numberValue === undefined) return true;
if (!Number.isFinite(numberValue)) return "Enter a valid number";
if (integer && !Number.isInteger(numberValue)) {
return "This field must be a whole number";
}
if (numberValue < min) return FormErrorMessages.min(min);
if (numberValue > max) return FormErrorMessages.max(max);
return true;
};
};
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const {
errors,
@@ -223,8 +252,12 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
>
<InputGroup
{...register("employee_count", {
min: { value: 1, message: FormErrorMessages.min(1) },
max: { value: 100000, message: FormErrorMessages.max(100000) },
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1,
max: 100000,
integer: true,
}),
})}
errors={errors}
label="Employee count"
@@ -233,11 +266,11 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
/>
<InputGroup
{...register("annual_revenue", {
min: { value: 0, message: FormErrorMessages.min(0) },
max: {
value: 999999999999,
message: FormErrorMessages.max(999999999999),
},
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 0,
max: 999999999999,
}),
})}
errors={errors}
label="Annual revenue"
@@ -246,8 +279,12 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
/>
<InputGroup
{...register("founded_year", {
min: { value: 1800, message: FormErrorMessages.min(1800) },
max: { value: 2100, message: FormErrorMessages.max(2100) },
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1800,
max: 2100,
integer: true,
}),
})}
errors={errors}
label="Founded year"
@@ -46,12 +46,19 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
id as string,
);
const optionalNumber = (value: unknown) => {
if (value === "" || value === null || value === undefined) return undefined;
return Number(value);
};
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => {
const formattedData = {
...data,
founded_year: data.founded_year ? +data.founded_year : 0,
employee_count: data.employee_count ? +data.employee_count : 0,
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
founded_year: optionalNumber(data.founded_year),
employee_count: optionalNumber(data.employee_count),
annual_revenue: optionalNumber(data.annual_revenue),
document_file_ids: data.document_file_ids ?? [],
tags: {
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
+3 -3
View File
@@ -35,9 +35,9 @@ export interface ICreateCompanyCredentials {
website: string;
type: string;
description: string;
founded_year: number;
employee_count: number;
annual_revenue: number;
founded_year?: number | string;
employee_count?: number | string;
annual_revenue?: number | string;
currency: string;
industry: string;
language: string;
+3 -3
View File
@@ -35,9 +35,9 @@ export interface ICreateCompanyCredentials {
website: string;
type: string;
description: string;
founded_year: number;
employee_count: number;
annual_revenue: number;
founded_year?: number | string;
employee_count?: number | string;
annual_revenue?: number | string;
currency: string;
industry: string;
language: string;