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 df0e14998..73e2fdc00 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1090,6 +1090,23 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1176,9 +1193,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -1192,6 +1213,23 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); + // Suspension and reactivation lock/unlock a role the customer relies on — + // tell them, and carry the staff message so they know why. + const reactivated = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if (status === ProfileStatus.Suspended || reactivated) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + status === ProfileStatus.Suspended ? "suspended" : "reactivated", + note ?? "", + ); + } + } + // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. if (status === ProfileStatus.Active) { diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..d9aac8e45 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -89,6 +89,43 @@ export class CompanyNotifierService { }); } + /** + * Tell the customer one of their operational roles was suspended or + * reactivated, quoting the staff message — the service layer requires one for + * both transitions, so the customer always learns why, not just what. + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence = + change === "suspended" + ? `You will not be able to operate under this role until it is reactivated; ` + + `your other roles are unaffected.` + : `You can operate under this role again.`; + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence} Message from EDR staff: ${staffMessage}`; + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link: "/settings", + data: { companyId: company.id, profileType, change, staffMessage }, + priority: NotificationPriority.HIGH, + }); + } + // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 6cb6759e7..04267cc7b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -298,34 +298,82 @@ export function ProfileApprovalActions({ const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); - const [rejectOpen, setRejectOpen] = useState(false); + const [decision, setDecision] = useState< + "reject" | "suspend" | "reactivate" | null + >(null); const [note, setNote] = useState(""); const act = (next: ProfileStatus) => mutate({ profileId, status: next }); - const confirmReject = () => { + // Decisions the customer must be given a reason for. Reject/suspend/reactivate + // all capture a required message through the same modal; the API refuses + // suspend/reactivate without one. + const DECISIONS = { + reject: { + title: "Reject profile", + intro: + "Tell the customer what needs fixing. They'll see this note and can " + + "amend and resubmit the role for approval.", + label: "Reason for rejection", + placeholder: "e.g. The uploaded business license is expired.", + confirmLabel: "Reject profile", + color: "red", + status: "rejected" as ProfileStatus, + }, + suspend: { + title: "Suspend role", + intro: + "Explain why this role is being suspended. The customer will see this " + + "message and cannot operate under the role until it is reactivated.", + label: "Reason for suspension", + placeholder: "e.g. Outstanding invoices unpaid for over 90 days.", + confirmLabel: "Suspend role", + color: "orange", + status: "suspended" as ProfileStatus, + }, + reactivate: { + title: "Reactivate role", + intro: + "Explain why this role is being reactivated. The customer will see " + + "this message and can operate under the role again.", + label: "Reactivation message", + placeholder: "e.g. Outstanding payments have been settled.", + confirmLabel: "Reactivate role", + color: "edr-green", + status: "active" as ProfileStatus, + }, + } as const; + + const openDecision = (kind: keyof typeof DECISIONS) => { + setNote(""); + setDecision(kind); + }; + + const active = decision ? DECISIONS[decision] : null; + + const confirmDecision = () => { + if (!active) return; mutate( - { profileId, status: "rejected", note: note.trim() }, - { onSuccess: () => setRejectOpen(false) }, + { profileId, status: active.status, note: note.trim() }, + { onSuccess: () => setDecision(null) }, ); }; - const rejectModal = ( + const decisionModal = active && ( setRejectOpen(false)} - title="Reject profile" + opened + onClose={() => setDecision(null)} + title={active.title} centered radius="lg" > - Tell the customer what needs fixing. They'll see this note and can - amend and resubmit the role for approval. + {active.intro}