feat(customers): notify marketing on returned changes, name actors in history

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.
This commit is contained in:
Nathnael
2026-08-17 12:38:06 +00:00
parent 339996d27f
commit 7143ba1040
14 changed files with 335 additions and 89 deletions

View File

@@ -7,6 +7,7 @@ import {
ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -1141,16 +1142,55 @@ export class CompaniesService {
return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
/**
* List a company's change requests, newest first (backoffice review). Actor
* ids are resolved to display names here — the history screen has to say who
* asked for a change and who sent it back, not print two uuids.
*/
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
const requests = await this.changeRequestRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(
requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
);
for (const request of requests) {
request.submittedByName = request.submittedBy
? (names.get(request.submittedBy) ?? null)
: null;
request.reviewedByName = request.reviewedBy
? (names.get(request.reviewedBy) ?? null)
: null;
}
return requests;
}
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
await this.findCompanyById(companyId);
return this.revisionRepo.findByCompanyId(companyId);
const revisions = await this.revisionRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
for (const revision of revisions) {
revision.actorName = revision.actorId
? (names.get(revision.actorId) ?? null)
: null;
}
return revisions;
}
/**
* Display names for actor ids, one query for the whole list. A lookup failure
* degrades the history to ids rather than failing the request — the entry is
* still worth showing without the name.
*/
private async resolveActorNames(
actorIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
try {
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
}
/**
@@ -1519,6 +1559,7 @@ export class CompaniesService {
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
@@ -1556,6 +1597,12 @@ export class CompaniesService {
`Change request ${id} is already ${request.status}`,
);
}
await this.notifyChangeRequestReturned(
request,
"changes_requested",
note,
reviewerId,
);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
@@ -1566,6 +1613,35 @@ export class CompaniesService {
);
}
/**
* Tell the customer desk a change request came back unapproved. Best-effort:
* a missing company or an unresolvable reviewer name must not fail the
* reviewer's decision, which is already the point of the try/catch.
*/
private async notifyChangeRequestReturned(
request: CompanyChangeRequest,
outcome: "rejected" | "changes_requested",
note: string,
reviewerId?: string,
): Promise<void> {
try {
const company = await this.companiesRepo.findById(request.companyId);
if (!company) return;
const names = await this.resolveActorNames([reviewerId]);
this.companyNotifier.changeRequestReturned(
company,
request.id,
outcome,
note,
reviewerId ? (names.get(reviewerId) ?? null) : null,
);
} catch (err) {
this.logger.warn(
`Could not notify the customer desk about ${request.id}: ${String(err)}`,
);
}
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);

View File

@@ -244,6 +244,35 @@ export class CompanyNotifierService {
);
}
/**
* 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 ──────────────────
/**

View File

@@ -23,8 +23,12 @@ export class ChangeRequestResponseDto {
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
/** Who filed the request, for the history screen (null when unresolvable). */
submittedByName: string | null;
submittedAt: Date | null;
reviewedBy: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
@@ -39,8 +43,10 @@ export class ChangeRequestResponseDto {
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedByName = req.submittedByName ?? null;
this.submittedAt = req.submittedAt ?? null;
this.reviewedBy = req.reviewedBy ?? null;
this.reviewedByName = req.reviewedByName ?? null;
this.reviewedAt = req.reviewedAt ?? null;
this.createdAt = req.createdAt;
this.updatedAt = req.updatedAt;

View File

@@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit, for the history screen (null when unresolvable). */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: Date;
@@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto {
this.id = revision.id;
this.companyId = revision.companyId;
this.actorId = revision.actorId ?? null;
this.actorName = revision.actorName ?? null;
this.summary = revision.summary;
this.changes = revision.changes ?? [];
this.createdAt = revision.createdAt;

View File

@@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity {
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt?: Date | null;
/**
* Display names for {@link submittedBy} / {@link reviewedBy}, resolved from
* `iam.users` on read. Not columns — the history screen has to name the
* person who asked for the change, and an opaque uuid does not.
*/
submittedByName?: string | null;
reviewedByName?: string | null;
}

View File

@@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity {
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
changes!: CompanyRevisionChange[];
/**
* Display name for {@link actorId}, resolved from `iam.users` on read. Not a
* column — history has to name who made the edit, and a uuid does not.
*/
actorName?: string | null;
}

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
ContractDocumentChange,
diffSnapshots,
@@ -20,28 +21,6 @@ export interface RecordRevisionInput {
stepId?: string | null;
}
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in the audit trail.
*/
interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === 'string' && name.trim()) return name.trim();
if (name && typeof name === 'object') {
const localized =
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/** Pre-computed changes (contract fields), rather than a document diff. */
export interface RecordChangesInput {
contractId: string;
@@ -120,23 +99,12 @@ export class ContractDocumentHistoryService {
private async resolveActorNames(
actorIds: string[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(actorIds.filter(Boolean))];
if (ids.length === 0) return resolved;
try {
const rows = (await this.dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
return resolved;
}
/** Revision history for a contract, newest first. */