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.
This commit is contained in:
parent
b2a9836436
commit
dd6d33366e
@ -1 +1,2 @@
|
||||
VITE_API_SERVER_URL=http://172.31.142.243:3002/api/v1
|
||||
VITE_FORCE_INITIAL_ONBOARDING=true
|
||||
51
apps/web/src/onboarding/api/initial-onboarding.api.test.ts
Normal file
51
apps/web/src/onboarding/api/initial-onboarding.api.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
110
apps/web/src/onboarding/api/initial-onboarding.api.ts
Normal file
110
apps/web/src/onboarding/api/initial-onboarding.api.ts
Normal file
@ -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<Record<string, never>, StatusDTO>({
|
||||
path: "onboarding/initial",
|
||||
method: "get",
|
||||
signal,
|
||||
data: {},
|
||||
})
|
||||
.then(mapStatus);
|
||||
export const provisionInitialOnboarding = (ds: IDataSource, signal?: AbortSignal) =>
|
||||
ds
|
||||
.custom<Record<string, never>, StatusDTO>({
|
||||
path: "onboarding/initial",
|
||||
method: "post",
|
||||
signal,
|
||||
data: {},
|
||||
})
|
||||
.then(mapStatus);
|
||||
export const completeInitialOnboarding = (
|
||||
ds: IDataSource,
|
||||
input: InitialCompanyInput,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
ds
|
||||
.custom<CompanyDTO, CompleteResponseDTO>({
|
||||
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;
|
||||
};
|
||||
38
apps/web/src/onboarding/api/initial-onboarding.form.test.ts
Normal file
38
apps/web/src/onboarding/api/initial-onboarding.form.test.ts
Normal file
@ -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);
|
||||
});
|
||||
33
apps/web/src/onboarding/hooks/use-initial-onboarding.ts
Normal file
33
apps/web/src/onboarding/hooks/use-initial-onboarding.ts
Normal file
@ -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),
|
||||
});
|
||||
};
|
||||
@ -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" });
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
export interface RestoredCompany {
|
||||
id: string;
|
||||
}
|
||||
export interface OnboardingSessionActions {
|
||||
restoreSession: () => Promise<unknown>;
|
||||
getAvailableCompanies: () => RestoredCompany[];
|
||||
changeActiveCompany: (companyId: string) => Promise<RestoredCompany | null>;
|
||||
}
|
||||
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<CompletionTransition> {
|
||||
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 };
|
||||
}
|
||||
115
apps/web/src/onboarding/ui/initial-onboarding-page.tsx
Normal file
115
apps/web/src/onboarding/ui/initial-onboarding-page.tsx
Normal file
@ -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<string | null>(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 <LoadingOverlay />;
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-3xl flex-col justify-center gap-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Configura tu empresa</h1>
|
||||
<p className="text-muted-foreground">Completa los datos fiscales para comenzar.</p>
|
||||
</div>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={async (e) => {
|
||||
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]) => (
|
||||
<label className="grid gap-1" key={k as string}>
|
||||
<span>
|
||||
{l as string}
|
||||
{r ? " *" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="rounded border p-2"
|
||||
onChange={(e) => set(k as string, e.target.value)}
|
||||
required={Boolean(r)}
|
||||
value={String(v[k as keyof InitialCompanyInput] ?? "")}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<h2 className="mt-4 text-xl font-medium">Dirección fiscal</h2>
|
||||
{[
|
||||
["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]) => (
|
||||
<label className="grid gap-1" key={k as string}>
|
||||
<span>
|
||||
{l as string}
|
||||
{r ? " *" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="rounded border p-2"
|
||||
onChange={(e) => setA(k as string, e.target.value)}
|
||||
required={Boolean(r)}
|
||||
value={String(v.address[k as keyof typeof v.address] ?? "")}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{recoverableError && (
|
||||
<p className="text-red-600">
|
||||
No se pudo restaurar la empresa ({recoverableError}). Puedes reintentar la restauración
|
||||
o cerrar sesión.
|
||||
</p>
|
||||
)}
|
||||
{m.isError && (
|
||||
<p className="text-red-600">
|
||||
No se pudo completar el alta. Revisa los datos e inténtalo de nuevo.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="rounded bg-primary px-4 py-2 text-primary-foreground"
|
||||
disabled={m.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{m.isPending ? "Guardando…" : "Crear empresa"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./app-routes";
|
||||
export * from "./resolve-initial-onboarding-navigation";
|
||||
|
||||
@ -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 <LoadingOverlay />;
|
||||
}
|
||||
|
||||
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 <div className="p-8">No se pudo cargar el estado de onboarding.</div>;
|
||||
if (statusQuery.data?.status === "pending" || provision.isSuccess)
|
||||
return <InitialOnboardingPage />;
|
||||
if (statusQuery.data?.status === "completed")
|
||||
return (
|
||||
<div className="p-8">
|
||||
La cuenta tiene un onboarding completado, pero no hay una empresa disponible.
|
||||
</div>
|
||||
);
|
||||
const returnTo = `${location.pathname}${location.search}`;
|
||||
|
||||
if (availableCompanies.length === 0) {
|
||||
return <Navigate replace to="/no-companies" />;
|
||||
}
|
||||
|
||||
return <Navigate replace to={`/company-selection?returnTo=${encodeURIComponent(returnTo)}`} />;
|
||||
};
|
||||
|
||||
31
apps/web/src/routes/resolve-company-guard-decision.test.ts
Normal file
31
apps/web/src/routes/resolve-company-guard-decision.test.ts
Normal file
@ -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"
|
||||
));
|
||||
15
apps/web/src/routes/resolve-company-guard-decision.ts
Normal file
15
apps/web/src/routes/resolve-company-guard-decision.ts
Normal file
@ -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,
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
23
apps/web/src/routes/resolve-initial-onboarding-navigation.ts
Normal file
23
apps/web/src/routes/resolve-initial-onboarding-navigation.ts
Normal file
@ -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";
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -1 +1,5 @@
|
||||
export type IOnboardingPublicServices = Record<never, never>;
|
||||
import type { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner";
|
||||
|
||||
export interface IOnboardingPublicServices {
|
||||
provisioner: InitialOnboardingProvisioner;
|
||||
}
|
||||
|
||||
@ -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"]) {
|
||||
|
||||
@ -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."));
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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: <T>(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: <T>(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<void>((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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user