feat(companies): require and deliver a staff message on suspend/reactivate

Staff could suspend or reactivate a customer role with one silent click:
no reason captured, nothing stored, and the customer was never told.
The API now rejects a suspend or reactivate without a non-empty note,
keeps the note in reviewNote while suspended, and sends the customer an
SMS/email/in-app notification quoting the staff message. In the
backoffice the reject-note modal is generalised into a decision modal
shared by reject, suspend and reactivate, so all three force a message.

EDRFREIGHT-188
This commit is contained in:
Nathnael
2026-07-21 09:09:15 +00:00
parent 4f6a559ae3
commit c8f932f5d5
3 changed files with 159 additions and 32 deletions

View File

@@ -1090,6 +1090,23 @@ export class CompaniesService {
if (!existing) if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`); 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 // A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are // onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an // 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 // Track the review outcome. Rejection and suspension keep the note so the
// why; approval clears it. Any decision stamps the reviewer + time. // customer knows why; approval/reactivation clears it. Any decision stamps
if (status === ProfileStatus.Rejected) { // the reviewer + time.
if (
status === ProfileStatus.Rejected ||
status === ProfileStatus.Suspended
) {
patch.reviewNote = note ?? null; patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) { } else if (status === ProfileStatus.Active) {
patch.reviewNote = null; patch.reviewNote = null;
@@ -1192,6 +1213,23 @@ export class CompaniesService {
if (!updated) if (!updated)
throw new NotFoundException(`Company profile ${existing.id} not found`); 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 // Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared. // customer can start working as soon as their first profile is cleared.
if (status === ProfileStatus.Active) { if (status === ProfileStatus.Active) {

View File

@@ -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 ──────────── // ── Backoffice-facing: work has arrived back in the review queue ────────────
/** /**

View File

@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation( const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(), api.customers.setProfileStatus.mutationOptions(),
); );
const [rejectOpen, setRejectOpen] = useState(false); const [decision, setDecision] = useState<
"reject" | "suspend" | "reactivate" | null
>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next }); 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( mutate(
{ profileId, status: "rejected", note: note.trim() }, { profileId, status: active.status, note: note.trim() },
{ onSuccess: () => setRejectOpen(false) }, { onSuccess: () => setDecision(null) },
); );
}; };
const rejectModal = ( const decisionModal = active && (
<Modal <Modal
opened={rejectOpen} opened
onClose={() => setRejectOpen(false)} onClose={() => setDecision(null)}
title="Reject profile" title={active.title}
centered centered
radius="lg" radius="lg"
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can {active.intro}
amend and resubmit the role for approval.
</Text> </Text>
<Textarea <Textarea
label="Reason for rejection" label={active.label}
placeholder="e.g. The uploaded business license is expired." placeholder={active.placeholder}
autosize autosize
minRows={3} minRows={3}
value={note} value={note}
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button
variant="default" variant="default"
onClick={() => setRejectOpen(false)} onClick={() => setDecision(null)}
disabled={isPending} disabled={isPending}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
color="red" color={active.color}
loading={isPending} loading={isPending}
disabled={note.trim().length === 0} disabled={note.trim().length === 0}
onClick={confirmReject} onClick={confirmDecision}
> >
Reject profile {active.confirmLabel}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
if (status === "pending") { if (status === "pending") {
return ( return (
<> <>
{rejectModal} {decisionModal}
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button <Button
size="xs" size="xs"
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
variant="light" variant="light"
color="red" color="red"
radius="md" radius="md"
onClick={() => setRejectOpen(true)} onClick={() => openDecision("reject")}
> >
Reject Reject
</Button> </Button>
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
if (status === "active") { if (status === "active") {
return ( return (
<Button <>
size="xs" {decisionModal}
variant="light" <Button
color="orange" size="xs"
radius="md" variant="light"
loading={isPending} color="orange"
onClick={() => act("suspended")} radius="md"
> loading={isPending}
Suspend onClick={() => openDecision("suspend")}
</Button> >
Suspend
</Button>
</>
); );
} }
if (status === "suspended") { if (status === "suspended") {
return ( return (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{decisionModal}
<Button <Button
size="xs" size="xs"
variant="light" variant="light"
color="edr-green" color="edr-green"
radius="md" radius="md"
loading={isPending} loading={isPending}
onClick={() => act("active")} onClick={() => openDecision("reactivate")}
> >
Reactivate Reactivate
</Button> </Button>