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)
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) {

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 ────────────
/**