diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e31851aef..ab2711dfb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -54,6 +54,23 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; +/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_ATTRIBUTES = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; +/** Mandatory once the company operates as a freight forwarder. */ +const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ + { key: "poaName", label: "PoA name" }, + { key: "poaEmail", label: "PoA email" }, + { key: "poaPhone", label: "PoA phone" }, +]; + export interface UserIdentity { userId: string; firstName: string; @@ -1240,6 +1257,29 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // 4. Power of Attorney. Optional in general, but a freight forwarder acts on + // other companies' behalf so its PoA is mandatory. Either way, a PoA that + // has been entered must be evidenced by the delegation letter. + const poaRequired = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); + const poaProvided = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + const missingPoaFields = poaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + // Only gate on the letter once the document set actually carries the field. + const delegationField = (setting?.fields ?? []).find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const missingDelegation = + Boolean(delegationField) && + (poaRequired || poaProvided) && + !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -1247,18 +1287,31 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), + ...(missingDelegation + ? ["Upload the delegation letter for your Power of Attorney"] + : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents and one license per operational profile. + // fields, required documents, one license per operational profile, and the + // PoA details/letter whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; + const poaItemCount = + (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + + (delegationField && (poaRequired || poaProvided) ? 1 : 0); const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + - licenseProfiles.length; + licenseProfiles.length + + poaItemCount; const completed = total - - (missingInfo.length + missingDocs.length + missingLicenses.length); + (missingInfo.length + + missingDocs.length + + missingLicenses.length + + missingPoaFields.length + + (missingDelegation ? 1 : 0)); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1266,6 +1319,13 @@ export class CompaniesService { companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, + poa: { + required: poaRequired, + provided: poaProvided, + delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + missingFields: missingPoaFields, + complete: missingPoaFields.length === 0 && !missingDelegation, + }, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 92f9fa513..da908a177 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +export interface OnboardingPoaState { + /** True when the company operates as a freight forwarder — PoA is mandatory. */ + required: boolean; + /** True once any PoA detail has been entered. */ + provided: boolean; + /** True when the delegation letter is stored for the company. */ + delegationLetterUploaded: boolean; + /** PoA details still missing (only populated when `required`). */ + missingFields: OnboardingInfoField[]; + /** False while the PoA step still owes details or a delegation letter. */ + complete: boolean; +} + export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; @@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto { /** Per-operational-profile business-license requirements. */ licenseProfiles: OnboardingLicenseProfile[]; + /** Power of Attorney state, so the wizard needn't re-derive the rule. */ + poa: OnboardingPoaState; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto { this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; + this.poa = init.poa; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 99dae5974..13759ddb3 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -18,6 +18,28 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; +/** fileKey of the delegation letter attached to the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** + * Seeded as optional: the delegation letter is only mandatory once a PoA has + * been entered, or when the company operates as a freight forwarder. That rule + * spans form fields as well as files, so it lives in the onboarding gate + * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + */ +const poaDelegationField = (displayOrder: number): OnboardingField => ({ + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: "PoA Delegation Letter", + helpText: + "Signed letter in which the General Manager delegates the representative named above.", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, +}); + /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ { @@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, + poaDelegationField(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, + poaDelegationField(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 849ba50c5..7dbc6d7c4 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -392,11 +392,17 @@ export default function OnboardingWizardDialog({ const requiredDocsMissing = requirementDocuments.some( (d) => d.isRequired && !d.uploaded, ); + // The PoA gets the same treatment: a resumed draft that predates the + // delegation-letter requirement (or a forwarder whose PoA is blank) must land + // back on the PoA step, where both the details and the letter are entered. + const poaIncomplete = requirementsQuery.data?.poa?.complete === false; + // Each unmet requirement lowers the ceiling; resume never moves forward. + let ceiling = FORM_STEPS.length - 1; + if (requiredDocsMissing) + ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); + if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa")); const effectiveResumeStep: FormStep = - requiredDocsMissing && - FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") - ? "documents" - : resumeFormStep; + FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)]; const formProps = { documentSettingCode: resolvedDocumentSettingCode, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index abfb61551..cadb102b4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -12,7 +12,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import type { AuthUser } from "@/types/auth"; @@ -28,9 +28,11 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { + buildOnboardingSchema, type CompanyStep, type FormData, - onboardingSchema, + hasPoaDetails, + POA_DELEGATION_FILE_KEY, stepFields, } from "./companyProfileForm/schema"; import { @@ -155,6 +157,12 @@ export default function CompanyProfileForm({ }), ); + // A freight forwarder signs on other companies' behalf, so its Power of + // Attorney (details + delegation letter) is mandatory rather than optional. + const requirePoa = (roleProfiles ?? []).some( + (p) => p.type === "freight_forwarder", + ); + const { register, control, @@ -164,7 +172,7 @@ export default function CompanyProfileForm({ setValue, formState: { errors }, } = useForm({ - resolver: zodResolver(onboardingSchema), + resolver: zodResolver(buildOnboardingSchema(requirePoa)), defaultValues: { companyName: "", companyEmail: "", @@ -354,7 +362,34 @@ export default function CompanyProfileForm({ } }; - const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The delegation letter is seeded into the same nationality document set as + // the rest, but belongs on the PoA step next to the details it evidences — + // so it's split out here and the Documents step renders the remainder. Both + // halves share `documentFiles`, so the existing bulk upload still carries it. + const poaDocumentField = uploadSetting?.fields?.find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const documentsSetting = useMemo( + () => + uploadSetting + ? { + ...uploadSetting, + fields: uploadSetting.fields.filter( + (f) => f.fileKey !== POA_DELEGATION_FILE_KEY, + ), + } + : undefined, + [uploadSetting], + ); + const poaDocumentSetting = useMemo( + () => + uploadSetting && poaDocumentField + ? { ...uploadSetting, fields: [poaDocumentField] } + : undefined, + [uploadSetting, poaDocumentField], + ); + + const hasDocuments = Boolean(documentsSetting?.fields?.length); // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be @@ -368,7 +403,7 @@ export default function CompanyProfileForm({ const validateRequiredDocuments = (): Record => { const errs: Record = {}; - for (const field of uploadSetting?.fields ?? []) { + for (const field of documentsSetting?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; @@ -447,6 +482,20 @@ export default function CompanyProfileForm({ ]; const currentIdx = stepOrder.indexOf(step); + // The delegation letter is what proves the representative was actually + // delegated, so it's required the moment a PoA exists — and unconditionally + // for a freight forwarder, whose PoA itself is mandatory. Skipped entirely + // when the document set predates the field (seeder not yet re-run). + const poaProvided = hasPoaDetails(watch()); + const delegationRequired = + Boolean(poaDocumentField) && (requirePoa || poaProvided); + const delegationPresent = + (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || + (() => { + const v = documentFiles[POA_DELEGATION_FILE_KEY]; + return Array.isArray(v) ? v.length > 0 : v != null; + })(); + /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); @@ -498,6 +547,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // The PoA step also gates on a file, which lives outside the form state. + if (step === "poa" && delegationRequired && !delegationPresent) { + setDocumentFieldErrors({ + [POA_DELEGATION_FILE_KEY]: "Delegation letter is required", + }); + setSaveError( + requirePoa + ? "Freight forwarders must provide Power of Attorney details and a delegation letter." + : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.", + ); + // Fall through to validate the text fields too, so every problem shows at once. + await trigger(stepFields.poa); + return; + } // Field steps validate + save before advancing. const ok = await saveCurrentStep(); if (!ok) return; @@ -743,8 +806,9 @@ export default function CompanyProfileForm({ {step === "poa" && ( <> - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. + {requirePoa + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."} {watch("contactPersonName") && ( + + {poaDocumentSetting && ( + <> + + + + )} )} @@ -797,13 +874,13 @@ export default function CompanyProfileForm({ - ) : !uploadSetting ? ( + ) : !documentsSetting ? ( No document requirements found for your account type. ) : ( !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), - poaEmail: z.string().optional(), + poaEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid email address", + ), poaLocation: z.string().optional(), }); export type FormData = z.infer; +/** fileKey of the delegation letter uploaded on the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +export const POA_FIELDS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const satisfies readonly (keyof FormData)[]; + +/** True once the customer has entered any Power of Attorney detail. */ +export const hasPoaDetails = (d: Partial) => + POA_FIELDS.some((f) => d[f]?.trim()); + +/** + * A freight forwarder acts on other companies' behalf, so its PoA is mandatory + * rather than optional. Everyone else keeps the optional PoA — but once they + * start filling it in, the identifying fields have to be complete (the + * delegation-letter upload is enforced alongside this, in CompanyProfileForm, + * since files live outside the form state). + */ +export function buildOnboardingSchema(requirePoa: boolean) { + if (!requirePoa) return onboardingSchema; + return onboardingSchema.superRefine((d, ctx) => { + const required: [keyof FormData, string][] = [ + ["poaName", "PoA name is required for freight forwarders"], + ["poaEmail", "PoA email is required for freight forwarders"], + ["poaPhone", "PoA phone is required for freight forwarders"], + ]; + for (const [path, message] of required) { + if (!d[path]?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); + } + } + }); +} + export const stepFields: Record = { company: [ "companyName", @@ -102,7 +146,7 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: [], + poa: [...POA_FIELDS], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index ad93e03a8..b5c1c3cdf 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -144,6 +144,15 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */ +export interface OnboardingPoaState { + required: boolean; + provided: boolean; + delegationLetterUploaded: boolean; + missingFields: { key: string; label: string }[]; + complete: boolean; +} + /** * Server-driven onboarding requirements. The portal renders this verbatim: the * backend decides which documents apply (by nationality) and what is still @@ -158,6 +167,7 @@ export interface OnboardingRequirements { }; documents: OnboardingDocumentField[]; licenseProfiles: OnboardingLicenseProfile[]; + poa: OnboardingPoaState; progress: { completed: number; total: number }; isComplete: boolean; onboardingCompleted: boolean;