feat(companies): per-document change requests and resubmission review queue

Two review-workflow gaps for freight customer onboarding:

Request for change per document. Backoffice can now flag a single uploaded
document (company document, profile licence, or POA delegation letter) with a
note the customer sees, instead of rejecting the whole role over it. Adds
review_status/review_note/reviewed_by/reviewed_at to freight.files (migration
AddFileReviewStatus, partial index for the gate), a POST
documents/:fileId/request-change endpoint, the backoffice action + modal, and a
portal banner/badge so the customer knows what to re-upload. Re-uploading clears
the flag. Approving a role is blocked while any of its documents has an open
correction; the gate check and the status write share a pessimistic write lock
on the company row (as does the change-request write) so a correction can never
slip in between the check and the profile going Active.

Resubmission is visible to reviewers. When a customer resubmits a rejected role
or amends a change request, backoffice staff are notified (allBackoffice inbox
item, deep-linked to the customer) and the resubmission surfaces in a new
"Pending changes" list view + KPI, since such companies are status = active and
never matched the pending-approval filter.
This commit is contained in:
Nathnael
2026-07-21 07:34:40 +00:00
parent 1cfc0f0fa8
commit fd5aedcec7
22 changed files with 948 additions and 60 deletions

View File

@@ -8,7 +8,7 @@ import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord } from "./entities/file.entity";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
export interface CreateFileInput {
resourceId: string;
@@ -169,6 +169,53 @@ export class FilesService {
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
* reason can never outlive the request it explained.
*/
async setReviewStatus(
id: string,
status: FileReviewStatus,
note: string | null,
reviewerId?: string,
): Promise<FileRecord> {
const record = await this.findById(id);
const updated = await this.filesRepository.update(record.id, {
reviewStatus: status,
reviewNote: status === "change_requested" ? (note ?? null) : null,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
});
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Drop any reviewer verdict from a document, returning it to "not reviewed".
* Called when a customer re-uploads: the new bytes have not been looked at, so
* carrying the old `change_requested` forward would keep them blocked forever.
*/
async clearReview(id: string): Promise<void> {
await this.filesRepository.update(id, {
reviewStatus: null,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
}
/** Documents across these resources still awaiting a customer correction. */
findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findWithOpenChangeRequest(
resourceIds,
resource,
);
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);