feat(freight): non-terminal change-request review + unified customer timeline

Backoffice can now "Request changes" on a pending settings change
request without rejecting it outright: a new ChangesRequested status
keeps the row open so the customer's next edit appends into the same
request instead of starting a fresh cycle, and the reviewer's note
persists across that round instead of being cleared on resubmit.

Version History and Review History (previously two separate,
differently-shaped lists) are merged into one chronological timeline
under a new History tab, including document changes shown as a real
previous-vs-current diff (both files openable).

Bug fixes surfaced while wiring this up:
- Replacing a single-file document slot left the old file live
  alongside the new one instead of retiring it (customer settings +
  onboarding uploads).
- The "previous" file in a document diff 404'd once superseded —
  the preview route now also matches soft-deleted records.
- A document replace was recorded twice in the timeline (once at
  upload, once again at change-request approval).
This commit is contained in:
Nathnael
2026-07-31 14:12:30 +00:00
parent 3952e8bbdf
commit 4f81a0bbb8
23 changed files with 795 additions and 166 deletions

View File

@@ -731,6 +731,25 @@ export class CompaniesController {
return new ChangeRequestResponseDto(request);
}
@Post("change-requests/:id/request-changes")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary:
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
})
async requestChangeRequestChanges(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectChangeRequestDto,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.requestChangeRequestChanges(
id,
dto.note,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -70,7 +70,10 @@ import {
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import {
CompanyRevision,
CompanyRevisionChange,
} from "./entities/company-revision.entity";
/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
@@ -869,9 +872,10 @@ export class CompaniesService {
before: Company,
patch: Record<string, any>,
actorId?: string | null,
extraChanges: CompanyRevisionChange[] = [],
): Promise<void> {
try {
const changes = diffCompanyUpdate(before, patch);
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
if (changes.length === 0) return;
await this.revisionRepo.create({
companyId: before.id,
@@ -992,10 +996,14 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
// Note is left untouched: if this request was ChangesRequested, the
// reviewer's ask stays visible on the resubmitted (Pending) row —
// clearing it here would hide what was asked for right when the
// reviewer comes back to check whether it was actually addressed.
snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
note: null,
status: ChangeRequestStatus.Pending,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
@@ -1039,6 +1047,47 @@ export class CompaniesService {
return this.revisionRepo.findByCompanyId(companyId);
}
/**
* Pair adjacent remove-then-add intents into one before/after revision
* change — that's exactly how a "replace" is staged (see
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
* pushed together, and later merges only ever append after that pair, so
* adjacency is preserved). A remove or add with no adjacent partner (a pure
* add, or a pure removal) stands alone.
*/
private pairReplaceIntents<
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
const changes: CompanyRevisionChange[] = [];
let i = 0;
while (i < intents.length) {
const current = intents[i];
const next = intents[i + 1];
if (current.op === "remove" && next?.op === "add") {
changes.push({
field: `document:${current.fileId}`,
label: labelFor(next),
from: current.fileName ?? null,
to: next.fileName ?? null,
fromFileId: current.fileId,
toFileId: next.fileId,
});
i += 2;
continue;
}
changes.push({
field: `document:${current.fileId}`,
label: labelFor(current),
from: current.op === "remove" ? (current.fileName ?? null) : null,
to: current.op === "add" ? (current.fileName ?? null) : null,
fromFileId: current.op === "remove" ? current.fileId : null,
toFileId: current.op === "add" ? current.fileId : null,
});
i += 1;
}
return changes;
}
/**
* Approve a pending change request: apply its snapshot to the live Company and
* mark the request approved. Any staged documents are already attached to the
@@ -1068,6 +1117,30 @@ export class CompaniesService {
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
// This is the ONLY place post-approval FIELD/license/PoA-document changes
// land on the live row — without this call, everything the #419
// change-request flow does to those is invisible in Version History.
// `documentFileIds` (the general bulk company-documents upload) is
// deliberately NOT re-recorded here — those documents go live immediately
// at upload time and are already recorded there (see
// `uploadCompanyDocuments`); redoing it here would double the entry.
const documentChanges: CompanyRevisionChange[] = [
...this.pairReplaceIntents(
request.documents?.licenseChanges ?? [],
() => "Business license",
),
...this.pairReplaceIntents(
request.documents?.documentChanges ?? [],
(intent) => intent.code,
),
];
await this.recordCompanyRevision(
company,
companyUpdates,
reviewerId,
documentChanges,
);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Approved,
@@ -1078,12 +1151,62 @@ export class CompaniesService {
);
}
/**
* A fresh upload under a single-file document slot (`isMultiple: false`)
* replaces whatever was there, not adds to it — soft-delete the prior live
* file(s) for that code, and describe each replacement (plus each genuinely
* new upload) as a revision change carrying both file ids, so the reviewer
* can open the previous and current file. Multi-file slots are left alone
* (genuinely additive, no single "the" document to diff against). Unrecognised
* codes (no matching field in the nationality's document setting) are also
* left alone — safer to under-clean than to guess wrong. Independent of the
* change-request review outcome: nothing else in this flow ever retires a
* superseded document, on approve OR reject — these documents go live the
* moment they're uploaded.
*/
private async replaceSingleFileCompanyDocuments(
company: Company,
before: FileRecord[],
uploaded: FileRecord[],
): Promise<CompanyRevisionChange[]> {
const setting = await this.fileUploadSettingsService
.getByCode(this.documentSettingCodeFor(company.nationality))
.catch(() => null);
const fields = setting?.fields ?? [];
const singleFileCodes = new Set(
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
);
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
const uploadedIds = new Set(uploaded.map((f) => f.id));
const changes: CompanyRevisionChange[] = [];
const toRemove: FileRecord[] = [];
for (const file of uploaded) {
if (!singleFileCodes.has(file.code)) continue;
const prior = before.find(
(f) => f.code === file.code && !uploadedIds.has(f.id),
);
changes.push({
field: `document:${file.code}`,
label: labelByCode.get(file.code) ?? file.code,
from: prior?.name ?? null,
to: file.name,
fromFileId: prior?.id ?? null,
toFileId: file.id,
});
if (prior) toRemove.push(prior);
}
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
return changes;
}
/**
* Upload company documents. For an approved company this also opens/updates a
* pending change request (recording the uploaded file ids) so the upload is
* reviewed and the customer is locked until it clears — consistent with the
* field-edit review. During onboarding (company not yet active) it's a plain
* upload with no review.
* upload with no review. Either way the documents go live immediately, so
* the revision history is recorded right away too, not gated on a decision.
*/
async uploadCompanyDocuments(
companyId: string,
@@ -1091,11 +1214,20 @@ export class CompaniesService {
submittedBy?: string,
): Promise<FileRecord[]> {
const company = await this.findCompanyById(companyId);
const before = await this.filesService.findByResource(
companyId,
"companies",
);
const uploaded = await this.filesService.uploadMany(
companyId,
"companies",
files,
);
const documentChanges = await this.replaceSingleFileCompanyDocuments(
company,
before,
uploaded,
);
await this.resolveDocumentChangeRequests(
companyId,
"companies",
@@ -1108,14 +1240,9 @@ export class CompaniesService {
uploaded.map((f) => f.id),
submittedBy,
);
} else if (uploaded.length > 0) {
await this.recordCompanyRevision(
company,
{
documents: uploaded.map((f) => f.name).join(", "),
},
submittedBy,
);
}
if (documentChanges.length > 0) {
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
}
return uploaded;
}
@@ -1231,7 +1358,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
@@ -1292,6 +1420,37 @@ export class CompaniesService {
);
}
/**
* Ask for specific fixes without rejecting outright: unlike
* {@link rejectChangeRequest}, staged license/document intents are kept (the
* row stays open), so the customer's next edit is appended to this SAME
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
* of starting a fresh cycle.
*/
async requestChangeRequestChanges(
id: string,
note: string,
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
);
}
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
})) ?? request
);
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
@@ -2326,7 +2485,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({
@@ -2652,7 +2812,8 @@ export class CompaniesService {
snapshot,
submittedBy: userId,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
return;
@@ -2932,7 +3093,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
// Note left untouched — see the comment in updateProfile's merge branch.
status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({

View File

@@ -1,4 +1,4 @@
import { Repository } from "typeorm";
import { FindOperator, Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
const COMPANY_ID = "company-1";
/** Matches a row's status against either a plain value or an `In([...])` operator. */
function statusMatches(
rowStatus: ChangeRequestStatus,
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
): boolean {
if (where === undefined) return true;
if (where instanceof FindOperator) return where.value.includes(rowStatus);
return rowStatus === where;
}
/**
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
({ where }: { where: Partial<Row> & { companyId: string } }) =>
({
where,
}: {
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
}) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
(where.status === undefined || row.status === where.status),
statusMatches(row.status, where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
expect(result?.id).toBe("pending");
});
it("treats a changes-requested request as open, same as pending", async () => {
const changesRequested: Row = {
id: "changes-requested",
status: ChangeRequestStatus.ChangesRequested,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([
rejected,
changesRequested,
]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result?.id).toBe("changes-requested");
});
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,

View File

@@ -1,12 +1,18 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
const OPEN_FOR_EDIT_STATUSES = [
ChangeRequestStatus.Pending,
ChangeRequestStatus.ChangesRequested,
];
@Injectable()
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
constructor(
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
super(repo);
}
/** The company's current pending request, if any. */
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
async findPendingByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
return this.repository.findOne({
where: { companyId, status: ChangeRequestStatus.Pending },
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
order: { createdAt: "DESC" },
});
}

View File

@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
/**
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
* settings + new-contract/booking creation disabled) and the reapply banner.
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
* call to action, but the customer's edit appends to this SAME request
* instead of starting a fresh one.
*/
review: {
status: 'pending' | 'rejected';
status: 'pending' | 'rejected' | 'changes_requested';
note: string | null;
} | null;
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
const open =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.review = open
? {
status: open.status as 'pending' | 'rejected',
status: open.status as 'pending' | 'rejected' | 'changes_requested',
note: open.note ?? null,
}
: null;

View File

@@ -68,10 +68,12 @@ export class ProfileResponseDto {
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
* proposed values from `pendingChanges` so the customer can amend & resubmit.
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
* prefill the proposed values from `pendingChanges` so the customer can amend
* & resubmit — `"changes_requested"` just appends the edit to this same
* request instead of starting a fresh one.
*/
reviewStatus: "pending" | "rejected" | null;
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
reviewNote: string | null;
pendingChanges: Record<string, any> | null;
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
const openReview =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.reviewStatus =
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
? "pending"
: openReview?.status === ChangeRequestStatus.Rejected
? "rejected"
: null;
: openReview?.status === ChangeRequestStatus.ChangesRequested
? "changes_requested"
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);

View File

@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
/**
* Lifecycle of a customer's proposed profile change. Edits made on the portal
* settings page by an already-approved company are staged here (not written to
* the live Company row) until a backoffice reviewer approves — at which point
* the snapshot is applied — or rejects with a note, after which the customer can
* amend and resubmit.
* the live Company row) until a backoffice reviewer resolves it:
* - Approved — the snapshot is applied to the live Company row.
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
* so the customer's next edit is appended (merged) into this SAME row instead
* of starting a new cycle.
*/
export enum ChangeRequestStatus {
Pending = "pending",
Approved = "approved",
Rejected = "rejected",
ChangesRequested = "changes_requested",
}
/**

View File

@@ -2,12 +2,19 @@ import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
/** One recorded field/document change, as shown on the customer's version history. */
/**
* One recorded field/document change, as shown on the customer's version
* history. A document change carries `fromFileId`/`toFileId` alongside the
* display names, so the reviewer can open the previous and current file —
* not just read that "a document changed."
*/
export interface CompanyRevisionChange {
field: string;
label: string;
from: string | null;
to: string | null;
fromFileId?: string | null;
toFileId?: string | null;
}
/**

View File

@@ -51,7 +51,11 @@ export class FilesController {
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const record = await this.filesService.findById(fileId);
// Includes soft-deleted records: a superseded document (replaced via a
// single-file document slot, or resolved as part of a license/PoA swap)
// is only reachable by UUID through the change-request/version-history
// diff, where reviewers need to open the "previous" file to compare it.
const record = await this.filesService.findByIdIncludingDeleted(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
@@ -63,7 +67,9 @@ export class FilesController {
);
}
const { stream } = await this.filesService.streamById(fileId);
const { stream } = await this.filesService.streamById(fileId, {
includeDeleted: true,
});
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";

View File

@@ -245,6 +245,23 @@ export class FilesService {
return record;
}
/**
* Same as {@link findById}, but also matches a soft-deleted record — a
* superseded document (replaced via a single-file slot, or a resolved
* license/PoA swap) is exactly this: gone from every live listing, but its
* id is still handed to reviewers in the change-request/version-history
* diff so they can open the "previous" file for comparison. Only the
* preview/download route should use this; every other caller wants the
* default (soft-deleted = not found).
*/
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id, {
withDeleted: true,
});
if (!record) throw new NotFoundException(`File ${id} not found`);
return record;
}
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
@@ -360,8 +377,11 @@ export class FilesService {
async streamById(
id: string,
opts: { includeDeleted?: boolean } = {},
): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const record = opts.includeDeleted
? await this.findByIdIncludingDeleted(id)
: await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };