mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
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:
@@ -1,6 +1,16 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reviewer verdict on a single stored document.
|
||||
*
|
||||
* `null` (the default) means "not reviewed" — the state every file starts in and
|
||||
* the only state the customer is not blocked by. `change_requested` is raised by
|
||||
* a backoffice reviewer against one specific document and is what the customer
|
||||
* must clear by re-uploading; `approved` records an explicit sign-off.
|
||||
*/
|
||||
export type FileReviewStatus = "change_requested" | "approved";
|
||||
|
||||
@Entity({ schema: "freight", name: "files" })
|
||||
export class FileRecord extends BaseEntity {
|
||||
@Column({ name: "resource_id", type: "uuid" })
|
||||
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
|
||||
|
||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||
mimeType!: string;
|
||||
|
||||
/** Reviewer verdict, or `null` while the document has never been reviewed. */
|
||||
@Column({
|
||||
name: "review_status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
reviewStatus!: FileReviewStatus | null;
|
||||
|
||||
/** Why a change was requested — shown verbatim to the customer. */
|
||||
@Column({ name: "review_note", type: "text", nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents belonging to any of the given resources that a reviewer has asked
|
||||
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||
* resource ids (a company plus each of its company profiles) in one query.
|
||||
*/
|
||||
async findWithOpenChangeRequest(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
return this.repository.find({
|
||||
where: {
|
||||
resourceId: In(resourceIds),
|
||||
resource,
|
||||
reviewStatus: "change_requested",
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user