From dd6d33366eca235537409a4466ac8b58c42bb9d8 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 9 Sep 2026 11:19:41 +0200 Subject: [PATCH] feat(onboarding): implement initial onboarding process and validation - Add tests for initial company input validation. - Create hooks for managing initial onboarding status and provisioning. - Implement orchestration for completing onboarding and restoring session. - Develop UI for the initial onboarding page with form handling. - Enhance routing to include onboarding navigation resolution. - Introduce error handling for unprovisioned onboarding scenarios. - Establish API endpoints for onboarding status and completion. - Validate onboarding request payloads with appropriate schemas. - Update onboarding module composition to include provisioning services. --- apps/web/.env.development | 1 + .../api/initial-onboarding.api.test.ts | 51 ++++ .../onboarding/api/initial-onboarding.api.ts | 110 ++++++++ .../api/initial-onboarding.form.test.ts | 38 +++ .../hooks/use-initial-onboarding.ts | 33 +++ ...ial-onboarding-and-restore-session.test.ts | 39 +++ ...-initial-onboarding-and-restore-session.ts | 27 ++ .../onboarding/ui/initial-onboarding-page.tsx | 115 +++++++++ apps/web/src/routes/index.ts | 1 + apps/web/src/routes/require-company-guard.tsx | 65 +++-- .../resolve-company-guard-decision.test.ts | 31 +++ .../routes/resolve-company-guard-decision.ts | 15 ++ ...olve-initial-onboarding-navigation.test.ts | 42 +++ .../resolve-initial-onboarding-navigation.ts | 23 ++ ...nitial-onboarding-not-provisioned-error.ts | 9 + .../onboarding-public-services.interface.ts | 6 +- ...mplete-initial-onboarding.use-case.test.ts | 6 +- .../complete-initial-onboarding.use-case.ts | 4 +- modules/onboarding/src/api/index.ts | 2 +- .../complete-initial-onboarding.controller.ts | 8 +- ...et-initial-onboarding-status.controller.ts | 3 +- .../express/onboarding-api-error-mapper.ts | 14 + .../express/onboarding.routes.test.ts | 244 ++++++++++++++++++ .../express/onboarding.routes.ts | 35 +-- ...provision-initial-onboarding.controller.ts | 24 ++ ...ate-complete-initial-onboarding-request.ts | 27 ++ .../src/api/module.composition.test.ts | 2 +- modules/onboarding/src/api/module.ts | 2 +- ...complete-initial-onboarding.request.dto.ts | 5 +- 29 files changed, 939 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/onboarding/api/initial-onboarding.api.test.ts create mode 100644 apps/web/src/onboarding/api/initial-onboarding.api.ts create mode 100644 apps/web/src/onboarding/api/initial-onboarding.form.test.ts create mode 100644 apps/web/src/onboarding/hooks/use-initial-onboarding.ts create mode 100644 apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.test.ts create mode 100644 apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.ts create mode 100644 apps/web/src/onboarding/ui/initial-onboarding-page.tsx create mode 100644 apps/web/src/routes/resolve-company-guard-decision.test.ts create mode 100644 apps/web/src/routes/resolve-company-guard-decision.ts create mode 100644 apps/web/src/routes/resolve-initial-onboarding-navigation.test.ts create mode 100644 apps/web/src/routes/resolve-initial-onboarding-navigation.ts create mode 100644 modules/onboarding/src/api/application/errors/initial-onboarding-not-provisioned-error.ts create mode 100644 modules/onboarding/src/api/infrastructure/express/onboarding-api-error-mapper.ts create mode 100644 modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts create mode 100644 modules/onboarding/src/api/infrastructure/express/provision-initial-onboarding.controller.ts create mode 100644 modules/onboarding/src/api/infrastructure/express/validate-complete-initial-onboarding-request.ts diff --git a/apps/web/.env.development b/apps/web/.env.development index e8b47516..d8259eb8 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -1 +1,2 @@ VITE_API_SERVER_URL=http://172.31.142.243:3002/api/v1 +VITE_FORCE_INITIAL_ONBOARDING=true \ No newline at end of file diff --git a/apps/web/src/onboarding/api/initial-onboarding.api.test.ts b/apps/web/src/onboarding/api/initial-onboarding.api.test.ts new file mode 100644 index 00000000..32d4dd38 --- /dev/null +++ b/apps/web/src/onboarding/api/initial-onboarding.api.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + completeInitialOnboarding, + getInitialOnboardingStatus, + provisionInitialOnboarding, + toCompleteInitialOnboardingDTO, +} from "./initial-onboarding.api"; + +const ds = { + custom: async (p: any) => { + if (p.method === "get") return { status: "pending", company_id: null }; + if (p.path.endsWith("/complete")) { + assert.equal(p.data.address.street_2, "2B"); + return { company_id: "c1" }; + } + return { status: "pending", company_id: null }; + }, +} as any; +test("maps onboarding status and provision responses", async () => { + assert.deepEqual(await getInitialOnboardingStatus(ds), { status: "pending", companyId: null }); + assert.deepEqual(await provisionInitialOnboarding(ds), { status: "pending", companyId: null }); +}); +test("maps complete payload and response", async () => { + const input: any = { + legalName: "Acme", + tradeName: "", + tin: "B1", + email: "", + phone: "", + website: "", + address: { + street: "Main", + street2: "2B", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + countryCode: "ES", + }, + }; + assert.equal(await completeInitialOnboarding(ds, input), "c1"); + assert.deepEqual(toCompleteInitialOnboardingDTO(input).address, { + street: "Main", + street_2: "2B", + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }); +}); diff --git a/apps/web/src/onboarding/api/initial-onboarding.api.ts b/apps/web/src/onboarding/api/initial-onboarding.api.ts new file mode 100644 index 00000000..924981c9 --- /dev/null +++ b/apps/web/src/onboarding/api/initial-onboarding.api.ts @@ -0,0 +1,110 @@ +import type { IDataSource } from "@erp/core/client"; + +export type InitialOnboardingStatus = "not_provisioned" | "pending" | "completed"; +export interface InitialOnboardingStatusModel { + status: InitialOnboardingStatus; + companyId: string | null; +} +export interface InitialCompanyInput { + legalName: string; + tradeName?: string; + tin: string; + email?: string; + phone?: string; + website?: string; + address: { + street: string; + street2?: string; + city: string; + postalCode: string; + province: string; + countryCode: string; + }; +} +interface StatusDTO { + status: InitialOnboardingStatus; + company_id: string | null; +} +interface CompleteResponseDTO { + company_id: string; +} +interface CompanyDTO { + legal_name: string; + trade_name?: string | null; + tin: string; + email?: string | null; + phone?: string | null; + website?: string | null; + address: { + street: string; + street_2?: string | null; + city: string; + postal_code: string; + province: string; + country_code: string; + }; +} +const mapStatus = (v: StatusDTO): InitialOnboardingStatusModel => ({ + status: v.status, + companyId: v.company_id, +}); +export const toCompleteInitialOnboardingDTO = (v: InitialCompanyInput): CompanyDTO => ({ + legal_name: v.legalName, + trade_name: v.tradeName || null, + tin: v.tin, + email: v.email || null, + phone: v.phone || null, + website: v.website || null, + address: { + street: v.address.street, + street_2: v.address.street2 || null, + city: v.address.city, + postal_code: v.address.postalCode, + province: v.address.province, + country_code: v.address.countryCode, + }, +}); +export const getInitialOnboardingStatus = (ds: IDataSource, signal?: AbortSignal) => + ds + .custom, StatusDTO>({ + path: "onboarding/initial", + method: "get", + signal, + data: {}, + }) + .then(mapStatus); +export const provisionInitialOnboarding = (ds: IDataSource, signal?: AbortSignal) => + ds + .custom, StatusDTO>({ + path: "onboarding/initial", + method: "post", + signal, + data: {}, + }) + .then(mapStatus); +export const completeInitialOnboarding = ( + ds: IDataSource, + input: InitialCompanyInput, + signal?: AbortSignal +) => + ds + .custom({ + path: "onboarding/initial/complete", + method: "post", + signal, + data: toCompleteInitialOnboardingDTO(input), + }) + .then((v) => v.company_id); + +export const validateInitialCompanyInput = (value: InitialCompanyInput): string[] => { + const errors: string[] = []; + if (!value.legalName.trim()) errors.push("legalName"); + if (!value.tin.trim()) errors.push("tin"); + if (!value.address?.street.trim()) errors.push("address.street"); + if (!value.address?.city.trim()) errors.push("address.city"); + if (!value.address?.province.trim()) errors.push("address.province"); + if (!/^\d{5}$/.test(value.address?.postalCode ?? "")) errors.push("address.postalCode"); + if (!/^[A-Z]{2}$/.test(value.address?.countryCode ?? "")) errors.push("address.countryCode"); + if (value.email && !/^\S+@\S+\.\S+$/.test(value.email)) errors.push("email"); + return errors; +}; diff --git a/apps/web/src/onboarding/api/initial-onboarding.form.test.ts b/apps/web/src/onboarding/api/initial-onboarding.form.test.ts new file mode 100644 index 00000000..64f806b7 --- /dev/null +++ b/apps/web/src/onboarding/api/initial-onboarding.form.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { type InitialCompanyInput, validateInitialCompanyInput } from "./initial-onboarding.api"; + +const valid: InitialCompanyInput = { + legalName: "Acme", + tradeName: "", + tin: "B1", + email: "user@example.com", + phone: "", + website: "", + address: { + street: "Main", + street2: "", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + countryCode: "ES", + }, +}; +for (const [name, mutate] of Object.entries({ + valid: () => valid, + legalName: () => ({ ...valid, legalName: "" }), + tin: () => ({ ...valid, tin: "" }), + address: () => ({ ...valid, address: undefined as never }), + street: () => ({ ...valid, address: { ...valid.address, street: "" } }), + city: () => ({ ...valid, address: { ...valid.address, city: "" } }), + province: () => ({ ...valid, address: { ...valid.address, province: "" } }), + postalCode: () => ({ ...valid, address: { ...valid.address, postalCode: "bad" } }), + countryCode: () => ({ ...valid, address: { ...valid.address, countryCode: "ESP" } }), + email: () => ({ ...valid, email: "bad" }), +})) + test(name, () => { + const errors = validateInitialCompanyInput(mutate()); + if (name === "valid") assert.deepEqual(errors, []); + else assert.ok(errors.length > 0); + }); diff --git a/apps/web/src/onboarding/hooks/use-initial-onboarding.ts b/apps/web/src/onboarding/hooks/use-initial-onboarding.ts new file mode 100644 index 00000000..6486b1de --- /dev/null +++ b/apps/web/src/onboarding/hooks/use-initial-onboarding.ts @@ -0,0 +1,33 @@ +import { useDataSource } from "@erp/core/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + type InitialCompanyInput, + completeInitialOnboarding, + getInitialOnboardingStatus, + provisionInitialOnboarding, +} from "../api/initial-onboarding.api"; +export const initialOnboardingStatusKey = ["onboarding", "initial"] as const; +export const useInitialOnboardingStatus = () => { + const ds = useDataSource(); + return useQuery({ + queryKey: initialOnboardingStatusKey, + queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal), + }); +}; +export const useProvisionInitialOnboarding = () => { + const ds = useDataSource(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => provisionInitialOnboarding(ds), + onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v), + }); +}; +export const useCompleteInitialOnboarding = () => { + const ds = useDataSource(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (v: InitialCompanyInput) => completeInitialOnboarding(ds, v), + onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v), + }); +}; diff --git a/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.test.ts b/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.test.ts new file mode 100644 index 00000000..dbeec896 --- /dev/null +++ b/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { completeInitialOnboardingAndRestoreSession } from "./complete-initial-onboarding-and-restore-session"; + +test("selects created company among multiple companies", async () => { + let selected = ""; + const r = await completeInitialOnboardingAndRestoreSession("x", { + restoreSession: async () => ({}), + getAvailableCompanies: () => [{ id: "a" }, { id: "x" }], + changeActiveCompany: async (id) => { + selected = id; + return { id }; + }, + }); + assert.deepEqual(r, { kind: "application", companyId: "x" }); + assert.equal(selected, "x"); +}); +test("reports missing company without selecting another", async () => { + let selected = false; + const r = await completeInitialOnboardingAndRestoreSession("x", { + restoreSession: async () => ({}), + getAvailableCompanies: () => [{ id: "a" }, { id: "b" }], + changeActiveCompany: async () => { + selected = true; + return null; + }, + }); + assert.deepEqual(r, { kind: "recoverable", companyId: "x", reason: "company_missing" }); + assert.equal(selected, false); +}); +test("reports restore failure without repeating completion", async () => { + const r = await completeInitialOnboardingAndRestoreSession("x", { + restoreSession: async () => null, + getAvailableCompanies: () => [], + changeActiveCompany: async () => null, + }); + assert.deepEqual(r, { kind: "recoverable", companyId: "x", reason: "restore_failed" }); +}); diff --git a/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.ts b/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.ts new file mode 100644 index 00000000..c541fe64 --- /dev/null +++ b/apps/web/src/onboarding/orchestration/complete-initial-onboarding-and-restore-session.ts @@ -0,0 +1,27 @@ +export interface RestoredCompany { + id: string; +} +export interface OnboardingSessionActions { + restoreSession: () => Promise; + getAvailableCompanies: () => RestoredCompany[]; + changeActiveCompany: (companyId: string) => Promise; +} +export type CompletionTransition = + | { kind: "application"; companyId: string } + | { kind: "recoverable"; companyId: string; reason: "company_missing" | "restore_failed" }; +export async function completeInitialOnboardingAndRestoreSession( + companyId: string, + actions: OnboardingSessionActions +): Promise { + try { + const restored = await actions.restoreSession(); + if (restored === null) return { kind: "recoverable", companyId, reason: "restore_failed" }; + } catch { + return { kind: "recoverable", companyId, reason: "restore_failed" }; + } + const companies = actions.getAvailableCompanies(); + const created = companies.find((company) => company.id === companyId); + if (!created) return { kind: "recoverable", companyId, reason: "company_missing" }; + if (companies.length > 1) await actions.changeActiveCompany(companyId); + return { kind: "application", companyId }; +} diff --git a/apps/web/src/onboarding/ui/initial-onboarding-page.tsx b/apps/web/src/onboarding/ui/initial-onboarding-page.tsx new file mode 100644 index 00000000..23e5683c --- /dev/null +++ b/apps/web/src/onboarding/ui/initial-onboarding-page.tsx @@ -0,0 +1,115 @@ +import { useIdentityAuthSession } from "@erp/identity/client"; +import { LoadingOverlay } from "@repo/rdx-ui/components"; +import { useEffect, useState } from "react"; + +import { + type InitialCompanyInput, + useCompleteInitialOnboarding, +} from "../hooks/use-initial-onboarding"; + +const empty: InitialCompanyInput = { + legalName: "", + tradeName: "", + tin: "", + email: "", + phone: "", + website: "", + address: { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "ES" }, +}; +export const InitialOnboardingPage = () => { + const [v, setV] = useState(empty); + const [recoverableError, setRecoverableError] = useState(null); + const m = useCompleteInitialOnboarding(); + const { restoreSession, availableCompanies, changeActiveCompany } = useIdentityAuthSession(); + useEffect(() => { + if (!m.data) return; + const found = availableCompanies.find((c) => c.id === m.data); + if (!found) { + if (availableCompanies.length > 0) setRecoverableError("company_missing"); + return; + } + if (availableCompanies.length > 1) + changeActiveCompany(m.data).catch(() => setRecoverableError("company_selection_failed")); + }, [m.data, availableCompanies, changeActiveCompany]); + const set = (k: string, x: string) => setV((s) => ({ ...s, [k]: x })); + const setA = (k: string, x: string) => setV((s) => ({ ...s, address: { ...s.address, [k]: x } })); + if (m.isSuccess) return ; + return ( +
+
+

Configura tu empresa

+

Completa los datos fiscales para comenzar.

+
+
{ + e.preventDefault(); + await m.mutateAsync(v); + await restoreSession(); + }} + > + {[ + ["legalName", "Razón social", true], + ["tradeName", "Nombre comercial", false], + ["tin", "NIF/CIF", true], + ["email", "Email", false], + ["phone", "Teléfono", false], + ["website", "Web", false], + ].map(([k, l, r]) => ( + + ))} +

Dirección fiscal

+ {[ + ["street", "Calle", true], + ["street2", "Complemento", false], + ["city", "Ciudad", true], + ["postalCode", "Código postal", true], + ["province", "Provincia", true], + ["countryCode", "País", true], + ].map(([k, l, r]) => ( + + ))} + {recoverableError && ( +

+ No se pudo restaurar la empresa ({recoverableError}). Puedes reintentar la restauración + o cerrar sesión. +

+ )} + {m.isError && ( +

+ No se pudo completar el alta. Revisa los datos e inténtalo de nuevo. +

+ )} + +
+
+ ); +}; diff --git a/apps/web/src/routes/index.ts b/apps/web/src/routes/index.ts index 6bda2cac..e12d9d61 100644 --- a/apps/web/src/routes/index.ts +++ b/apps/web/src/routes/index.ts @@ -1 +1,2 @@ export * from "./app-routes"; +export * from "./resolve-initial-onboarding-navigation"; diff --git a/apps/web/src/routes/require-company-guard.tsx b/apps/web/src/routes/require-company-guard.tsx index 5071c9f5..a049c5e0 100644 --- a/apps/web/src/routes/require-company-guard.tsx +++ b/apps/web/src/routes/require-company-guard.tsx @@ -1,29 +1,64 @@ import { useIdentityAuthSession } from "@erp/identity/client"; import { LoadingOverlay } from "@repo/rdx-ui/components"; -import type { ReactNode } from "react"; +import { type ReactNode, useEffect } from "react"; import { Navigate, useLocation } from "react-router-dom"; +import { + useInitialOnboardingStatus, + useProvisionInitialOnboarding, +} from "@/onboarding/hooks/use-initial-onboarding"; +import { InitialOnboardingPage } from "@/onboarding/ui/initial-onboarding-page"; + +import { resolveCompanyGuardDecision } from "./resolve-company-guard-decision"; + interface RequireCompanyRouteGuardProps { children: ReactNode; } - export const RequireCompanyRouteGuard = ({ children }: RequireCompanyRouteGuardProps) => { const location = useLocation(); const { activeCompany, availableCompanies, isLoading } = useIdentityAuthSession(); - - if (isLoading) { + const statusQuery = useInitialOnboardingStatus(); + const provision = useProvisionInitialOnboarding(); + useEffect(() => { + if ( + availableCompanies.length === 0 && + statusQuery.data?.status === "not_provisioned" && + !provision.isPending && + !provision.isSuccess && + !provision.isError + ) + provision.mutate(); + }, [ + availableCompanies.length, + statusQuery.data?.status, + provision.isPending, + provision.isSuccess, + provision.mutate, + provision.isError, + ]); + if ( + isLoading || + (availableCompanies.length === 0 && (statusQuery.isLoading || provision.isPending)) + ) return ; - } - - if (activeCompany) { - return <>{children}; - } - + const navigation = statusQuery.data + ? resolveCompanyGuardDecision({ + isAuthenticated: true, + availableCompaniesCount: availableCompanies.length, + onboardingStatus: statusQuery.data.status, + }) + : null; + if (activeCompany || navigation === "application") return <>{children}; + if (statusQuery.isError || provision.isError) + return
No se pudo cargar el estado de onboarding.
; + if (statusQuery.data?.status === "pending" || provision.isSuccess) + return ; + if (statusQuery.data?.status === "completed") + return ( +
+ La cuenta tiene un onboarding completado, pero no hay una empresa disponible. +
+ ); const returnTo = `${location.pathname}${location.search}`; - - if (availableCompanies.length === 0) { - return ; - } - return ; }; diff --git a/apps/web/src/routes/resolve-company-guard-decision.test.ts b/apps/web/src/routes/resolve-company-guard-decision.test.ts new file mode 100644 index 00000000..a25592f0 --- /dev/null +++ b/apps/web/src/routes/resolve-company-guard-decision.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCompanyGuardDecision } from "./resolve-company-guard-decision"; + +const d = (n: number, s: any, a = false) => + resolveCompanyGuardDecision({ + isAuthenticated: true, + availableCompaniesCount: n, + hasActiveCompany: a, + onboardingStatus: s, + }); +test("companies have priority", () => { + assert.equal(d(2, "pending"), "application"); + assert.equal(d(2, "pending"), "application"); +}); +test("zero companies route by onboarding state", () => { + assert.equal(d(0, "pending"), "onboarding"); + assert.equal(d(0, "not_provisioned"), "provision_onboarding"); + assert.equal(d(0, "completed"), "onboarding_inconsistent"); +}); +test("unauthenticated goes to login", () => + assert.equal( + resolveCompanyGuardDecision({ + isAuthenticated: false, + availableCompaniesCount: 0, + hasActiveCompany: false, + onboardingStatus: "pending", + }), + "login" + )); diff --git a/apps/web/src/routes/resolve-company-guard-decision.ts b/apps/web/src/routes/resolve-company-guard-decision.ts new file mode 100644 index 00000000..da06aeac --- /dev/null +++ b/apps/web/src/routes/resolve-company-guard-decision.ts @@ -0,0 +1,15 @@ +import { + type InitialOnboardingNavigation, + resolveInitialOnboardingNavigation, +} from "./resolve-initial-onboarding-navigation"; +export const resolveCompanyGuardDecision = (input: { + isAuthenticated: boolean; + availableCompaniesCount: number; + hasActiveCompany: boolean; + onboardingStatus: "not_provisioned" | "pending" | "completed"; +}): InitialOnboardingNavigation => + resolveInitialOnboardingNavigation({ + isAuthenticated: input.isAuthenticated, + availableCompaniesCount: input.availableCompaniesCount, + onboardingStatus: input.onboardingStatus, + }); diff --git a/apps/web/src/routes/resolve-initial-onboarding-navigation.test.ts b/apps/web/src/routes/resolve-initial-onboarding-navigation.test.ts new file mode 100644 index 00000000..6c4f9c1f --- /dev/null +++ b/apps/web/src/routes/resolve-initial-onboarding-navigation.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveInitialOnboardingNavigation } from "./resolve-initial-onboarding-navigation"; + +const cases = [ + [false, 0, "pending", "login"], + [true, 1, "pending", "application"], + [true, 1, "completed", "application"], + [true, 1, "not_provisioned", "application"], + [true, 0, "pending", "onboarding"], + [true, 0, "not_provisioned", "provision_onboarding"], + [true, 0, "completed", "onboarding_inconsistent"], + [true, 2, "pending", "application"], +] as const; + +for (const [isAuthenticated, companies, status, expected] of cases) { + test(`${String(isAuthenticated)} + ${companies} companies + ${status}`, () => { + assert.equal( + resolveInitialOnboardingNavigation({ + isAuthenticated, + availableCompaniesCount: companies, + onboardingStatus: status, + }), + expected + ); + }); +} + +test("pending with available companies has no side effects", () => { + const input = { + isAuthenticated: true, + availableCompaniesCount: 1, + onboardingStatus: "pending" as const, + }; + assert.equal(resolveInitialOnboardingNavigation(input), "application"); + assert.deepEqual(input, { + isAuthenticated: true, + availableCompaniesCount: 1, + onboardingStatus: "pending", + }); +}); diff --git a/apps/web/src/routes/resolve-initial-onboarding-navigation.ts b/apps/web/src/routes/resolve-initial-onboarding-navigation.ts new file mode 100644 index 00000000..dc375b71 --- /dev/null +++ b/apps/web/src/routes/resolve-initial-onboarding-navigation.ts @@ -0,0 +1,23 @@ +export type InitialOnboardingNavigation = + | "login" + | "application" + | "onboarding" + | "provision_onboarding" + | "onboarding_inconsistent"; + +export type InitialOnboardingNavigationInput = { + isAuthenticated: boolean; + availableCompaniesCount: number; + onboardingStatus: "not_provisioned" | "pending" | "completed"; +}; + +/** Decide la navegación sin leer ni modificar persistencia. */ +export function resolveInitialOnboardingNavigation( + input: InitialOnboardingNavigationInput +): InitialOnboardingNavigation { + if (!input.isAuthenticated) return "login"; + if (input.availableCompaniesCount > 0) return "application"; + if (input.onboardingStatus === "pending") return "onboarding"; + if (input.onboardingStatus === "completed") return "onboarding_inconsistent"; + return "provision_onboarding"; +} diff --git a/modules/onboarding/src/api/application/errors/initial-onboarding-not-provisioned-error.ts b/modules/onboarding/src/api/application/errors/initial-onboarding-not-provisioned-error.ts new file mode 100644 index 00000000..972090bf --- /dev/null +++ b/modules/onboarding/src/api/application/errors/initial-onboarding-not-provisioned-error.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from "@repo/rdx-ddd"; + +/** La transición requiere un onboarding provisionado previamente para la cuenta. */ +export class InitialOnboardingNotProvisionedError extends ApplicationError { + constructor() { + super("Initial onboarding has not been provisioned.", "initial_onboarding_not_provisioned"); + this.name = "InitialOnboardingNotProvisionedError"; + } +} diff --git a/modules/onboarding/src/api/application/services/onboarding-public-services.interface.ts b/modules/onboarding/src/api/application/services/onboarding-public-services.interface.ts index 3e0ea9c4..aff05aaa 100644 --- a/modules/onboarding/src/api/application/services/onboarding-public-services.interface.ts +++ b/modules/onboarding/src/api/application/services/onboarding-public-services.interface.ts @@ -1 +1,5 @@ -export type IOnboardingPublicServices = Record; +import type { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner"; + +export interface IOnboardingPublicServices { + provisioner: InitialOnboardingProvisioner; +} diff --git a/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.test.ts b/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.test.ts index 7d013263..d252366f 100644 --- a/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.test.ts +++ b/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.test.ts @@ -6,6 +6,7 @@ import { UniqueID, UtcDateTime } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { InitialOnboarding } from "../../domain"; +import { InitialOnboardingNotProvisionedError } from "../errors/initial-onboarding-not-provisioned-error"; import { CompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper"; import { CompleteInitialOnboardingUseCase } from "./complete-initial-onboarding.use-case"; @@ -137,7 +138,10 @@ test("completed existente ignora payload nuevo", async () => { }); test("ausente falla sin provisionar", async () => { const s = setup(undefined, true); - assert.equal((await s.useCase.execute(input)).isFailure, true); + const result = await s.useCase.execute(input); + assert.equal(result.isFailure, true); + assert.ok(result.error instanceof InitialOnboardingNotProvisionedError); + assert.equal(result.error.code, "initial_onboarding_not_provisioned"); assert.deepEqual(s.events, ["begin", "lock", "rollback"]); }); for (const failure of ["read", "company", "admin", "save", "throw", "commit", "begin"]) { diff --git a/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.ts b/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.ts index dd80f4d3..b98e341f 100644 --- a/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.ts +++ b/modules/onboarding/src/api/application/use-cases/complete-initial-onboarding.use-case.ts @@ -6,6 +6,7 @@ import { Result } from "@repo/rdx-utils"; import type { CompleteInitialOnboardingRequestDTO } from "../../../common"; import type { InitialOnboarding } from "../../domain"; +import { InitialOnboardingNotProvisionedError } from "../errors/initial-onboarding-not-provisioned-error"; import type { ICompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper"; import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface"; @@ -45,8 +46,7 @@ export class CompleteInitialOnboardingUseCase { transaction ); if (found.isFailure) return Result.fail(found.error); - if (found.data.isNone()) - return Result.fail(new Error("Initial onboarding has not been provisioned.")); + if (found.data.isNone()) return Result.fail(new InitialOnboardingNotProvisionedError()); const onboarding = found.data.unwrap(); if (!onboarding.accountId.equals(params.accountId)) return Result.fail(new Error("Initial onboarding account mismatch.")); diff --git a/modules/onboarding/src/api/index.ts b/modules/onboarding/src/api/index.ts index 1ec96ded..91a100da 100644 --- a/modules/onboarding/src/api/index.ts +++ b/modules/onboarding/src/api/index.ts @@ -15,7 +15,7 @@ export const onboardingAPIModule: IModuleServer = { dependencies: ["companies", "identity"], setup(params) { const internal = buildOnboardingDependencies(params); - const general: IOnboardingPublicServices = {}; + const general: IOnboardingPublicServices = internal.applicationServices; return { models, services: { general }, internal }; }, start(params) { diff --git a/modules/onboarding/src/api/infrastructure/express/complete-initial-onboarding.controller.ts b/modules/onboarding/src/api/infrastructure/express/complete-initial-onboarding.controller.ts index 3378f4b7..bf7a4790 100644 --- a/modules/onboarding/src/api/infrastructure/express/complete-initial-onboarding.controller.ts +++ b/modules/onboarding/src/api/infrastructure/express/complete-initial-onboarding.controller.ts @@ -1,18 +1,22 @@ -import { ExpressController } from "@erp/core/api"; +import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api"; import type { CompleteInitialOnboardingRequestDTO } from "../../../common"; import type { CompleteInitialOnboardingUseCase } from "../../application"; +import { onboardingApiErrorMapper } from "./onboarding-api-error-mapper"; + export class CompleteInitialOnboardingController extends ExpressController { constructor(private readonly useCase: CompleteInitialOnboardingUseCase) { super(); + this.errorMapper = onboardingApiErrorMapper; + this.registerGuards(requireAuthenticatedGuard()); } protected async executeImpl() { const user = this.getUser(); if (!user?.userId) return this.unauthorizedError("Unauthorized"); const dto = this.req.body as CompleteInitialOnboardingRequestDTO; - const result = await this.useCase.execute({ dto, accountId: user.userId }); + const result = await this.useCase.execute({ dto, accountId: user.userId.toPrimitive() }); return result.match( (value) => this.ok({ company_id: value.companyId }), (error) => this.handleError(error) diff --git a/modules/onboarding/src/api/infrastructure/express/get-initial-onboarding-status.controller.ts b/modules/onboarding/src/api/infrastructure/express/get-initial-onboarding-status.controller.ts index bdb79c34..072da60b 100644 --- a/modules/onboarding/src/api/infrastructure/express/get-initial-onboarding-status.controller.ts +++ b/modules/onboarding/src/api/infrastructure/express/get-initial-onboarding-status.controller.ts @@ -1,10 +1,11 @@ -import { ExpressController } from "@erp/core/api"; +import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api"; import type { GetInitialOnboardingStatusUseCase } from "../../application"; export class GetInitialOnboardingStatusController extends ExpressController { constructor(private readonly useCase: GetInitialOnboardingStatusUseCase) { super(); + this.registerGuards(requireAuthenticatedGuard()); } protected async executeImpl() { diff --git a/modules/onboarding/src/api/infrastructure/express/onboarding-api-error-mapper.ts b/modules/onboarding/src/api/infrastructure/express/onboarding-api-error-mapper.ts new file mode 100644 index 00000000..097cf290 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/express/onboarding-api-error-mapper.ts @@ -0,0 +1,14 @@ +import { ApiErrorMapper, ConflictApiError } from "@erp/core/api"; + +import { InitialOnboardingNotProvisionedError } from "../../application/errors/initial-onboarding-not-provisioned-error"; + +export const onboardingApiErrorMapper = ApiErrorMapper.default().register({ + priority: 120, + matches: (error) => error instanceof InitialOnboardingNotProvisionedError, + build: (error) => { + const functionalError = error as InitialOnboardingNotProvisionedError; + const conflict = new ConflictApiError(functionalError.message); + conflict.errors = [{ code: functionalError.code, message: functionalError.message }]; + return conflict; + }, +}); diff --git a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts new file mode 100644 index 00000000..99587124 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import type { TestContext } from "node:test"; +import test from "node:test"; + +import type { StartParams } from "@erp/core/api"; +import { ApiErrorMapper, globalErrorHandler, toProblemJson } from "@erp/core/api"; +import { requireAuthenticated } from "@erp/identity/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { RequestHandler } from "express"; +import express from "express"; + +import { InitialOnboardingNotProvisionedError } from "../../application/errors/initial-onboarding-not-provisioned-error"; +import type { CompleteInitialOnboardingUseCaseInput } from "../../application/use-cases/complete-initial-onboarding.use-case"; +import { InitialOnboarding } from "../../domain"; + +import { onboardingRouter } from "./onboarding.routes"; + +const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data; +const companyId = "33333333-3333-4333-8333-333333333333"; +const path = "/api/v1/onboarding/initial/complete"; +const valid = { + legal_name: "Company A", + tin: "B12345678", + address: { + street: "Calle Mayor 1", + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, +}; + +async function runtime(t: TestContext, error?: Error) { + const app = express(); + app.use(express.json()); + const calls: CompleteInitialOnboardingUseCaseInput[] = []; + let status: "not_provisioned" | "pending" | "completed" = "not_provisioned"; + let provisionCalls = 0; + const authenticate: RequestHandler = (req, _res, next) => { + // Solo se sustituye la resolución de credenciales; no se crea contexto de Company. + if (req.get("authorization") === "Test account-a") { + Object.assign(req, { user: { userId: accountId } }); + } + next(); + }; + const unexpected = () => { + throw new Error("Unexpected HTTP side effect"); + }; + const params: StartParams = { + app, + config: { server: { apiBasePath: "/api/v1" } }, + operationContext: {} as never, + transactionContext: { run: unexpected } as never, + audit: {} as never, + getInternal: (name: string) => { + assert.equal(name, "onboarding"); + return { + applicationServices: { + provisioner: { + provision: async (id: typeof accountId) => { + provisionCalls++; + return Result.ok(InitialOnboarding.createPending(id).data); + }, + }, + }, + useCases: { + completeInitialOnboarding: { + execute: async (input: CompleteInitialOnboardingUseCaseInput) => { + calls.push(input); + return error ? Result.fail(error) : Result.ok({ companyId }); + }, + }, + getInitialOnboardingStatus: { + execute: async () => + Result.ok({ status, companyId: status === "completed" ? companyId : null }), + }, + }, + repository: { save: unexpected, findByAccountIdForUpdate: unexpected }, + } as T; + }, + getService: (name: string) => { + if (name === "companies:general") return { finder: {} } as T; + assert.equal(name, "identity:general"); + return { + auth: { + authenticatedOnly: () => [authenticate, requireAuthenticated()], + tenantRequired: () => { + throw new Error("Onboarding must not require Company context"); + }, + }, + } as T; + }, + }; + onboardingRouter(params); + app.use(globalErrorHandler); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after( + () => + new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))) + ); + const address = server.address() as AddressInfo; + return { + calls, + setStatus: (value: typeof status) => { + status = value; + }, + provisionCalls: () => provisionCalls, + async request(body: unknown = valid, authenticated = true, url = path, method = "POST") { + const response = await fetch(`http://127.0.0.1:${address.port}${url}`, { + method, + headers: { + "content-type": "application/json", + ...(authenticated ? { authorization: "Test account-a" } : {}), + }, + ...(method === "POST" ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(5000), + }); + return { status: response.status, body: await response.json() }; + }, + }; +} + +test("POST happy path: snake_case exacto, account auth y sin Company", async (t) => { + const r = await runtime(t); + const response = await r.request(); + assert.equal(response.status, 200); + assert.deepEqual(response.body, { company_id: companyId }); + assert.equal(r.calls.length, 1); + assert.equal(r.calls[0].accountId, accountId.toPrimitive()); + assert.deepEqual(r.calls[0].dto, valid); + assert.equal(r.provisionCalls(), 0); +}); + +test("POST unauthorized no llega a Application", async (t) => { + const r = await runtime(t); + assert.equal((await r.request(valid, false)).status, 401); + assert.equal(r.calls.length, 0); +}); + +const invalid: [string, unknown][] = [ + ["legal_name ausente", { ...valid, legal_name: undefined }], + ["address ausente", { ...valid, address: undefined }], + ["address parcial", { ...valid, address: { ...valid.address, city: undefined } }], + ["country_code inválido", { ...valid, address: { ...valid.address, country_code: "12" } }], + ["postal_code inválido", { ...valid, address: { ...valid.address, postal_code: "!!!!!" } }], + ["unknown field", { ...valid, company_id: companyId }], + ["account_id externo", { ...valid, account_id: companyId }], +]; +for (const [name, body] of invalid) { + test(`POST ${name}: 400 sin ejecutar use case`, async (t) => { + const r = await runtime(t); + assert.equal((await r.request(body)).status, 400); + assert.equal(r.calls.length, 0); + }); +} +test("POST query accountId no sustituye auth", async (t) => { + const r = await runtime(t); + assert.equal((await r.request(valid, true, `${path}?accountId=${companyId}`)).status, 200); + assert.equal(r.calls[0].accountId, accountId.toPrimitive()); +}); +for (const [name, error] of [ + ["Application failure", new Error("Test application failure")], +] as const) { + test(`POST ${name}: mapper estándar sin provisioning`, async (t) => { + const r = await runtime(t, error); + const response = await r.request(); + const expected = ApiErrorMapper.default().map(error); + assert.equal(response.status, expected.status); + assert.deepEqual(response.body, toProblemJson(expected, { instance: path, method: "POST" })); + assert.equal(r.calls.length, 1); + assert.equal(r.provisionCalls(), 0); + }); +} +test("POST repetido y payload distinto mantienen 200 y company_id", async (t) => { + const r = await runtime(t); + for (const body of [valid, valid, { ...valid, legal_name: "Company B" }]) { + const response = await r.request(body); + assert.equal(response.status, 200); + assert.deepEqual(response.body, { company_id: companyId }); + } + assert.equal(r.calls.length, 3); + assert.equal(r.calls[2].dto.legal_name, "Company B"); +}); +for (const status of ["not_provisioned", "pending", "completed"] as const) { + test(`GET ${status}: regresión de router sin escritura`, async (t) => { + const r = await runtime(t); + r.setStatus(status); + const response = await r.request(undefined, true, "/api/v1/onboarding/initial", "GET"); + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + status, + company_id: status === "completed" ? companyId : null, + }); + assert.equal(r.calls.length, 0); + assert.equal(r.provisionCalls(), 0); + }); +} +test("GET unauthorized", async (t) => { + const r = await runtime(t); + assert.equal( + (await r.request(undefined, false, "/api/v1/onboarding/initial", "GET")).status, + 401 + ); +}); + +test("POST not provisioned: 409 con código estable sin provisioning", async (t) => { + const r = await runtime(t, new InitialOnboardingNotProvisionedError()); + const response = await r.request(); + assert.equal(response.status, 409); + assert.deepEqual(response.body, { + type: "https://httpstatuses.com/409", + title: "Conflict", + status: 409, + detail: "Initial onboarding has not been provisioned.", + errors: [ + { + code: "initial_onboarding_not_provisioned", + message: "Initial onboarding has not been provisioned.", + }, + ], + instance: path, + method: "POST", + }); + assert.equal(r.calls.length, 1); + assert.equal(r.provisionCalls(), 0); +}); + +test("POST /initial provisiona explícitamente sin Company context", async (t) => { + const r = await runtime(t); + const response = await r.request({}, true, "/api/v1/onboarding/initial"); + assert.equal(response.status, 200); + assert.deepEqual(response.body, { status: "pending", company_id: null }); + assert.equal(r.provisionCalls(), 1); +}); + +test("POST /initial exige autenticación", async (t) => { + const r = await runtime(t); + assert.equal((await r.request({}, false, "/api/v1/onboarding/initial")).status, 401); + assert.equal(r.provisionCalls(), 0); +}); diff --git a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts index a260eedb..0074842f 100644 --- a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts +++ b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts @@ -1,12 +1,13 @@ -import { type StartParams, requireAuthenticatedGuard, validateRequest } from "@erp/core/api"; -import { requireIdentityTenant } from "@erp/identity/api"; +import type { StartParams } from "@erp/core/api"; +import { requireIdentityAuthenticated } from "@erp/identity/api"; import { type NextFunction, type Request, type Response, Router } from "express"; -import { CompleteInitialOnboardingRequestSchema } from "../../../common"; import type { OnboardingInternalDeps } from "../di"; import { CompleteInitialOnboardingController } from "./complete-initial-onboarding.controller"; import { GetInitialOnboardingStatusController } from "./get-initial-onboarding-status.controller"; +import { ProvisionInitialOnboardingController } from "./provision-initial-onboarding.controller"; +import { validateCompleteInitialOnboardingRequest } from "./validate-complete-initial-onboarding-request"; export const onboardingRouter = (params: StartParams) => { const { app, config, getInternal } = params; @@ -15,23 +16,25 @@ export const onboardingRouter = (params: StartParams) => { // ---------------------------------------------- - router.use(...requireIdentityTenant(params)); + router.use(...requireIdentityAuthenticated(params)); - router.get( - "/initial", - requireAuthenticatedGuard(), - (req: Request, res: Response, next: NextFunction) => { - const controller = new GetInitialOnboardingStatusController( - deps.useCases.getInitialOnboardingStatus - ); - return controller.execute(req, res, next); - } - ); + router.get("/initial", (req: Request, res: Response, next: NextFunction) => { + const controller = new GetInitialOnboardingStatusController( + deps.useCases.getInitialOnboardingStatus + ); + return controller.execute(req, res, next); + }); + + router.post("/initial", (req: Request, res: Response, next: NextFunction) => { + const controller = new ProvisionInitialOnboardingController( + deps.applicationServices.provisioner + ); + return controller.execute(req, res, next); + }); router.post( "/initial/complete", - requireAuthenticatedGuard(), - validateRequest(CompleteInitialOnboardingRequestSchema, "body"), + validateCompleteInitialOnboardingRequest, (req: Request, res: Response, next: NextFunction) => { const controller = new CompleteInitialOnboardingController( deps.useCases.completeInitialOnboarding diff --git a/modules/onboarding/src/api/infrastructure/express/provision-initial-onboarding.controller.ts b/modules/onboarding/src/api/infrastructure/express/provision-initial-onboarding.controller.ts new file mode 100644 index 00000000..d6063ec4 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/express/provision-initial-onboarding.controller.ts @@ -0,0 +1,24 @@ +import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api"; +import type { InitialOnboardingProvisioner } from "../../application/services/initial-onboarding-provisioner"; + +export class ProvisionInitialOnboardingController extends ExpressController { + public constructor(private readonly provisioner: InitialOnboardingProvisioner) { + super(); + this.registerGuards(requireAuthenticatedGuard()); + } + protected async executeImpl() { + const user = this.getUser(); + if (!user?.userId) return this.unauthorizedError("Unauthorized"); + const result = await this.provisioner.provision(user.userId); + return result.match( + (onboarding) => + this.ok({ + status: onboarding.status, + company_id: onboarding.companyId.isSome() + ? onboarding.companyId.unwrap().toPrimitive() + : null, + }), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/onboarding/src/api/infrastructure/express/validate-complete-initial-onboarding-request.ts b/modules/onboarding/src/api/infrastructure/express/validate-complete-initial-onboarding-request.ts new file mode 100644 index 00000000..f37bfe82 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/express/validate-complete-initial-onboarding-request.ts @@ -0,0 +1,27 @@ +import { ApiError, ExpressController } from "@erp/core/api"; +import type { RequestHandler } from "express"; + +import { CompleteInitialOnboardingRequestSchema } from "../../../common"; + +/** Este contrato exige 400 de transporte; ValidationApiError global representa 422. */ +export const validateCompleteInitialOnboardingRequest: RequestHandler = (req, res, next) => { + const parsed = CompleteInitialOnboardingRequestSchema.safeParse(req.body); + if (!parsed.success) { + return ExpressController.errorResponse( + new ApiError({ + status: 400, + title: "Validation Failed", + detail: "Validation failed", + type: "https://httpstatuses.com/400", + errors: parsed.error.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }), + req, + res + ); + } + req.body = parsed.data; + next(); +}; diff --git a/modules/onboarding/src/api/module.composition.test.ts b/modules/onboarding/src/api/module.composition.test.ts index fbcd0457..3d1b97cb 100644 --- a/modules/onboarding/src/api/module.composition.test.ts +++ b/modules/onboarding/src/api/module.composition.test.ts @@ -27,7 +27,7 @@ test("onboarding composition builds public capabilities and registers its model" } as never); assert.ok(result?.services?.general); - assert.deepEqual(result.services.general, {}); + assert.ok(result.services.general.provisioner); assert.ok(result.internal); assert.ok(result.models?.includes(initialOnboardingModelInit)); assert.equal(provisionCalls, 0); diff --git a/modules/onboarding/src/api/module.ts b/modules/onboarding/src/api/module.ts index a2c9339d..fe83f569 100644 --- a/modules/onboarding/src/api/module.ts +++ b/modules/onboarding/src/api/module.ts @@ -12,7 +12,7 @@ export const onboardingModule: IModuleServer = { dependencies: ["companies", "identity"], setup(params) { const internal = buildOnboardingDependencies(params); - const general: IOnboardingPublicServices = {}; + const general: IOnboardingPublicServices = internal.applicationServices; return { models, services: { general }, internal }; }, }; diff --git a/modules/onboarding/src/common/dto/request/complete-initial-onboarding.request.dto.ts b/modules/onboarding/src/common/dto/request/complete-initial-onboarding.request.dto.ts index 6e23d5c3..f0a42d67 100644 --- a/modules/onboarding/src/common/dto/request/complete-initial-onboarding.request.dto.ts +++ b/modules/onboarding/src/common/dto/request/complete-initial-onboarding.request.dto.ts @@ -1,3 +1,4 @@ +import { CountryCodeSchema, PostalCodeSchema } from "@erp/core"; import { z } from "zod/v4"; export const CompleteInitialOnboardingRequestSchema = z @@ -13,9 +14,9 @@ export const CompleteInitialOnboardingRequestSchema = z street: z.string().min(1), street_2: z.string().nullable().optional(), city: z.string().min(1), - postal_code: z.string().min(1), + postal_code: PostalCodeSchema, province: z.string().min(1), - country_code: z.string().length(2), + country_code: CountryCodeSchema, }) .strict(), })