diff --git a/cypress/e2e/dashboard/dashboard.cy.ts b/cypress/e2e/dashboard/dashboard.cy.ts new file mode 100644 index 0000000..8a63567 --- /dev/null +++ b/cypress/e2e/dashboard/dashboard.cy.ts @@ -0,0 +1,228 @@ +/// + +const apiResponse = (data: Record) => ({ + 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"); + }); +}); diff --git a/cypress/e2e/notifications/create-notification.cy.ts b/cypress/e2e/notifications/create-notification.cy.ts new file mode 100644 index 0000000..7258a3d --- /dev/null +++ b/cypress/e2e/notifications/create-notification.cy.ts @@ -0,0 +1,166 @@ +/// + +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"); + }); +}); diff --git a/cypress/e2e/notifications/notification-history.cy.ts b/cypress/e2e/notifications/notification-history.cy.ts new file mode 100644 index 0000000..c54f681 --- /dev/null +++ b/cypress/e2e/notifications/notification-history.cy.ts @@ -0,0 +1,190 @@ +/// + +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 => ({ + 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"); + }); +}); diff --git a/docs/client/create-company-field-validations.md b/docs/client/create-company-field-validations.md new file mode 100644 index 0000000..4eadc04 --- /dev/null +++ b/docs/client/create-company-field-validations.md @@ -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). diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index fbb3575..e38e238 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -25,6 +25,35 @@ const FieldError = ({ message }: { message?: string }) =>

{message}

) : 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) => { > { /> { /> { 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 = (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 : [], diff --git a/src/lib/api/types/Company.ts b/src/lib/api/types/Company.ts index b16e44f..9861ee8 100644 --- a/src/lib/api/types/Company.ts +++ b/src/lib/api/types/Company.ts @@ -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; diff --git a/src/lib/api/types/TCompany.ts b/src/lib/api/types/TCompany.ts index 370e20c..ad6117d 100644 --- a/src/lib/api/types/TCompany.ts +++ b/src/lib/api/types/TCompany.ts @@ -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;