mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +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.
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
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,
|
|
summarizeChanges,
|
|
} from './contract-document-diff.util';
|
|
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
|
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
|
|
|
export interface RecordRevisionInput {
|
|
contractId: string;
|
|
before: ContractDocumentSnapshot | null;
|
|
after: ContractDocumentSnapshot | null;
|
|
actorId?: string | null;
|
|
actorRole?: string | null;
|
|
actorName?: string | null;
|
|
stepId?: string | null;
|
|
}
|
|
|
|
/** Pre-computed changes (contract fields), rather than a document diff. */
|
|
export interface RecordChangesInput {
|
|
contractId: string;
|
|
changes: ContractDocumentChange[];
|
|
actorId?: string | null;
|
|
actorRole?: string | null;
|
|
actorName?: string | null;
|
|
stepId?: string | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ContractDocumentHistoryService {
|
|
private readonly logger = new Logger(ContractDocumentHistoryService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(ContractDocumentRevision)
|
|
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/**
|
|
* Append a revision describing what an edit changed. Best-effort: recording
|
|
* history must never break the edit that triggered it, so failures are logged
|
|
* and swallowed. A no-op edit records nothing.
|
|
*/
|
|
async record(input: RecordRevisionInput): Promise<void> {
|
|
return this.recordChanges({
|
|
...input,
|
|
changes: diffSnapshots(input.before, input.after),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append a revision from an already-computed change set — the contract-field
|
|
* path, where there is no document snapshot to diff. Same best-effort
|
|
* contract as {@link record}: a no-op change set records nothing, and a
|
|
* failure here never breaks the edit that triggered it.
|
|
*/
|
|
async recordChanges(input: RecordChangesInput): Promise<void> {
|
|
try {
|
|
if (input.changes.length === 0) return;
|
|
|
|
await this.revisionRepo.save(
|
|
this.revisionRepo.create({
|
|
contractId: input.contractId,
|
|
actorId: input.actorId ?? null,
|
|
actorRole: input.actorRole ?? null,
|
|
actorName:
|
|
input.actorName ?? (await this.resolveActorName(input.actorId)),
|
|
stepId: input.stepId ?? null,
|
|
summary: summarizeChanges(input.changes),
|
|
changes: input.changes,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Name for the acting user. `iam.users` is owned by the auth system and has
|
|
* no entity here, so it is read directly; a miss is not an error — the trail
|
|
* still carries the id, role and timestamp.
|
|
*/
|
|
private async resolveActorName(
|
|
actorId?: string | null,
|
|
): Promise<string | null> {
|
|
if (!actorId) return null;
|
|
const names = await this.resolveActorNames([actorId]);
|
|
return names.get(actorId) ?? null;
|
|
}
|
|
|
|
/** Batched {@link resolveActorName} — one query for a whole revision list. */
|
|
private async resolveActorNames(
|
|
actorIds: string[],
|
|
): 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();
|
|
}
|
|
}
|
|
|
|
/** Revision history for a contract, newest first. */
|
|
async list(contractId: string): Promise<ContractDocumentRevision[]> {
|
|
const revisions = await this.revisionRepo.find({
|
|
where: { contractId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
|
|
// Rows written before actor_name existed still carry an actor_id — resolve
|
|
// those for display (one query for the whole list) rather than backfilling.
|
|
const missing = revisions
|
|
.filter((r) => !r.actorName && r.actorId)
|
|
.map((r) => r.actorId as string);
|
|
if (missing.length === 0) return revisions;
|
|
|
|
const names = await this.resolveActorNames(missing);
|
|
for (const revision of revisions) {
|
|
if (!revision.actorName && revision.actorId) {
|
|
revision.actorName = names.get(revision.actorId) ?? null;
|
|
}
|
|
}
|
|
return revisions;
|
|
}
|
|
}
|