mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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 ────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user