mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Three gaps on the backoffice customer detail page: - Rejecting a change request or sending it back for correction notified nobody. Adds CompanyNotifierService.changeRequestReturned, which pings the customer desk with the reviewer, the outcome and the note. Marketing joins that desk via customers:view + customers:get_notification in the role preset — grants still come from the IAM UI, the preset only sets the default for new environments. - submitted_by / reviewed_by / actor_id were stored but never resolved, so the History tab could say what changed but never who asked or who sent it back. Resolves them through a shared iam-user-name util (deduped from the private copy in contract-document-history.service) and renders "Requested by" / "Sent back to marketing by" lines. The changes_requested badge is relabelled to match the workflow. - "View" opened an in-page modal one document at a time. Adds openFileInNewTab, which opens the tab inside the click gesture and fills it once the authenticated fetch resolves, and an "Open all" button that loops over the documents table so every file lands in its own tab.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource } from "typeorm";
|
|
import {
|
|
NotificationAudience,
|
|
NotificationPriority,
|
|
NotificationType,
|
|
} from "@edr/types";
|
|
|
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
|
import { NotificationsService } from "../notifications/notifications.service";
|
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
|
import { resolveCompanyNotifyContact } from "../notifications/resolve-company-phone.util";
|
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|
|
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
|
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
|
CompanyStatus.Suspended,
|
|
CompanyStatus.Blacklisted,
|
|
];
|
|
|
|
/**
|
|
* Customer notifications for company account-status changes. Mirrors
|
|
* {@link ContractNotifierService}: SMS + email direct to the company contact,
|
|
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
|
|
* a notification failure must not roll back the status change itself.
|
|
*/
|
|
@Injectable()
|
|
export class CompanyNotifierService {
|
|
private readonly logger = new Logger(CompanyNotifierService.name);
|
|
|
|
constructor(
|
|
private readonly notifications: NotificationsService,
|
|
private readonly inbox: NotificationInboxService,
|
|
@InjectDataSource()
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/** Send SMS + email to the company contact; log-only on failure. */
|
|
private async notifyContact(company: Company, message: string): Promise<void> {
|
|
// One resolver for both channels — the company row's own email column is
|
|
// only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
|
|
// left an approval/suspension notice unsent to everyone else.
|
|
const { phone, email } = await resolveCompanyNotifyContact(
|
|
this.dataSource,
|
|
company.id,
|
|
);
|
|
|
|
if (phone) {
|
|
try {
|
|
await this.notifications.directSend("sms", phone, message);
|
|
} catch (err) {
|
|
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (email) {
|
|
try {
|
|
await this.notifications.directSend("email", email, message);
|
|
} catch (err) {
|
|
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (!phone && !email) {
|
|
this.logger.warn(`No contact on file for ${company.id} — not notified`);
|
|
}
|
|
}
|
|
|
|
/** SMS + email + in-app account-status item to the company contact. */
|
|
private notifyAccount(
|
|
company: Company,
|
|
title: string,
|
|
body: string,
|
|
link = "/settings",
|
|
): void {
|
|
void this.notifyContact(company, `${title}. ${body}`);
|
|
void this.inbox.notify({
|
|
recipients: { companyId: company.id },
|
|
audience: NotificationAudience.PORTAL,
|
|
type: NotificationType.ACCOUNT_STATUS,
|
|
title,
|
|
body,
|
|
link,
|
|
data: { companyId: company.id, status: company.status },
|
|
priority: NotificationPriority.HIGH,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tell the customer their account changed status. Fires on the transitions
|
|
* that change what they can do: suspended/blacklisted (locked out) and
|
|
* reactivated (back to Active from a lockout). Silent otherwise.
|
|
*/
|
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
|
const status = company.status;
|
|
if (status === previous) return;
|
|
|
|
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
|
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
|
this.notifyAccount(
|
|
company,
|
|
"Account reactivated",
|
|
"Your company account has been reactivated. " +
|
|
"You can submit new contracts and bookings again.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!PUNITIVE_STATUSES.includes(status)) return;
|
|
|
|
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
|
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
|
this.notifyAccount(
|
|
company,
|
|
`Account ${label}`,
|
|
`Your company account has been ${label}. ` +
|
|
`You will not be able to submit new contracts or bookings. ` +
|
|
`Please contact EDR support for assistance.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tell the customer their company account was approved and is now live — the
|
|
* first operational role clearing review promotes a pending company to Active.
|
|
*/
|
|
companyApproved(company: Company): void {
|
|
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
|
this.notifyAccount(
|
|
company,
|
|
"Account approved",
|
|
"Your company account has been approved and is now active. " +
|
|
"You can start submitting bookings and contracts.",
|
|
"/dashboard",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tell the customer one of their operational roles changed review status —
|
|
* approved, rejected, suspended, or reactivated — quoting the staff message
|
|
* when one was given (rejection/suspension/reactivation require one; approval
|
|
* carries none).
|
|
*/
|
|
profileStatusChanged(
|
|
company: Company,
|
|
profileType: string,
|
|
change: "approved" | "rejected" | "suspended" | "reactivated",
|
|
staffMessage: string,
|
|
): void {
|
|
const title = `${profileType} role ${change}`;
|
|
const consequence: Record<typeof change, string> = {
|
|
approved: "You can now operate under this role.",
|
|
rejected:
|
|
"You will not be able to operate under this role. Amend the required " +
|
|
"documents and resubmit it for approval from your settings page.",
|
|
suspended:
|
|
"You will not be able to operate under this role until it is " +
|
|
"reactivated; your other roles are unaffected.",
|
|
reactivated: "You can operate under this role again.",
|
|
};
|
|
const message = staffMessage.trim();
|
|
const body =
|
|
`Your company's ${profileType} role has been ${change}. ` +
|
|
`${consequence[change]}` +
|
|
(message ? ` Message from EDR staff: ${message}` : "");
|
|
|
|
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: message },
|
|
priority: NotificationPriority.HIGH,
|
|
});
|
|
}
|
|
|
|
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
|
|
|
/**
|
|
* Persist + push an in-app item to the customer desk — staff holding
|
|
* `customers:get_notification` — deep-linked to the customer's detail page,
|
|
* which is itself gated on `customers:view`.
|
|
*/
|
|
private notifyStaff(
|
|
company: Company,
|
|
title: string,
|
|
body: string,
|
|
data: Record<string, unknown> = {},
|
|
): void {
|
|
void this.inbox.notify({
|
|
recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.REQUEST_SUBMITTED,
|
|
title,
|
|
body,
|
|
link: `/dashboard/customers/${company.id}`,
|
|
data: { companyId: company.id, companyName: company.name, ...data },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A customer resubmitted an operational role after it was rejected for
|
|
* adjustment. Without this the role silently flips back to Pending and nobody
|
|
* is told there is anything to look at again.
|
|
*/
|
|
roleReapplied(company: Company, profileId: string, profileType: string): void {
|
|
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
|
|
this.notifyStaff(
|
|
company,
|
|
"Customer resubmitted a role for approval",
|
|
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
|
|
`It is back in the pending approval queue for review.`,
|
|
{ profileId, profileType },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A customer submitted (or amended and resubmitted) a profile change request.
|
|
* `resubmitted` distinguishes the two so the reviewer knows this is a second
|
|
* look at something they already sent back.
|
|
*/
|
|
changeRequestSubmitted(
|
|
company: Company,
|
|
changeRequestId: string,
|
|
resubmitted: boolean,
|
|
): void {
|
|
this.logger.log(
|
|
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`,
|
|
);
|
|
this.notifyStaff(
|
|
company,
|
|
resubmitted
|
|
? "Customer resubmitted profile changes"
|
|
: "Customer submitted profile changes",
|
|
resubmitted
|
|
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
|
|
`them. They are pending your review.`
|
|
: `${company.name} has submitted profile changes that are pending review.`,
|
|
{ changeRequestId },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A reviewer did NOT approve a customer's profile changes — they rejected it
|
|
* or sent it back for correction. The customer desk (Marketing included, via
|
|
* the `customers:get_notification` key) owns the follow-up with the customer,
|
|
* so the decision has to reach their inbox; without this it was silent, and
|
|
* only visible to whoever happened to reopen the customer's History tab.
|
|
*/
|
|
changeRequestReturned(
|
|
company: Company,
|
|
changeRequestId: string,
|
|
outcome: "rejected" | "changes_requested",
|
|
note: string,
|
|
reviewerName?: string | null,
|
|
): void {
|
|
const rejected = outcome === "rejected";
|
|
const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : "";
|
|
this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`);
|
|
this.notifyStaff(
|
|
company,
|
|
rejected
|
|
? "Customer profile changes rejected"
|
|
: "Customer profile changes sent back for correction",
|
|
`${company.name}'s profile changes were ` +
|
|
`${rejected ? "rejected" : "sent back for correction"}${by}. ` +
|
|
`Reason: ${note}`,
|
|
{ changeRequestId, outcome, note, reviewerName: reviewerName ?? null },
|
|
);
|
|
}
|
|
|
|
// ── Customer-facing: a specific document needs correcting ──────────────────
|
|
|
|
/**
|
|
* Tell the customer a reviewer wants one specific document corrected. Mirrors
|
|
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
|
|
* deep-linked to the documents tab where they can re-upload.
|
|
*/
|
|
documentChangeRequested(
|
|
company: Company,
|
|
documentName: string,
|
|
note: string,
|
|
fileId: string,
|
|
): void {
|
|
const title = "Document change requested";
|
|
const body =
|
|
`A reviewer has asked you to correct "${documentName}". ` +
|
|
`Reason: ${note} ` +
|
|
`Please upload a corrected version from your settings page.`;
|
|
|
|
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
|
|
void this.notifyContact(company, `${title}. ${body}`);
|
|
void this.inbox.notify({
|
|
recipients: { companyId: company.id },
|
|
audience: NotificationAudience.PORTAL,
|
|
type: NotificationType.DOCUMENT_ACTION,
|
|
title,
|
|
body,
|
|
link: "/settings",
|
|
data: { companyId: company.id, fileId, documentName },
|
|
priority: NotificationPriority.HIGH,
|
|
});
|
|
}
|
|
}
|