diff --git a/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts new file mode 100644 index 000000000..833d49fea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-document review state, so a backoffice reviewer can request a correction + * on one specific onboarding document instead of rejecting the whole role. + * + * Until now `freight.files` carried no status at all: the `pending_add` / + * `pending_remove` badges the portal shows are derived by diffing live rows + * against an open company change request, which says nothing about whether a + * reviewer is happy with a given document. `review_status` is that missing + * verdict — NULL means never reviewed, which is the state every existing row + * correctly starts in, so no backfill is needed. + * + * The partial index serves the approval gate, which asks "does this company (or + * profile) still have any document with an open change request?" on every + * role-status write. + */ +export class AddFileReviewStatus2430000000000 implements MigrationInterface { + name = 'AddFileReviewStatus2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL, + ADD COLUMN IF NOT EXISTS review_note text NULL, + ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL, + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request" + ON freight.files (resource, resource_id) + WHERE review_status = 'change_requested' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`, + ); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS review_status, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS reviewed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8db9eba66..6111bd32a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -494,6 +495,9 @@ export class CompaniesController { mimeType: f.mimeType, size: f.size, uploadedAt: f.createdAt, + reviewStatus: f.reviewStatus, + reviewNote: f.reviewNote, + reviewedAt: f.reviewedAt, // Raw `f.url` is an un-signed MinIO path the browser can't open — sign // it so the file previews/downloads in the client. url: f.url ? await this.filesService.signUrl(f.url) : f.url, @@ -501,6 +505,35 @@ export class CompaniesController { ); } + @Post("documents/:fileId/request-change") + @FreightAdmin() + @ApiOperation({ + summary: "Ask the customer to correct one uploaded document", + description: + "Flags a single document with a reason the customer sees, notifies them, " + + "and blocks role approval until they re-upload. Narrower than rejecting " + + "the whole role.", + }) + async requestDocumentChange( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + @Body() dto: RequestDocumentChangeDto, + ) { + const file = await this.companiesService.requestDocumentChange( + fileId, + dto.note, + user.id, + ); + return { + id: file.id, + name: file.name, + code: file.code, + reviewStatus: file.reviewStatus, + reviewNote: file.reviewNote, + reviewedAt: file.reviewedAt, + }; + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index b02f38380..3ac12b11a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository { ) )`; + /** + * A company waiting on a reviewer to decide an edit it submitted after being + * approved. These rows are `status = active`, so the pending-application filter + * can never surface them — the review queue needs its own predicate. + */ + private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = company.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -67,6 +79,7 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + hasPendingChangeRequest, sortBy = 'name', sortOrder = 'ASC', } = query; @@ -99,6 +112,14 @@ export class CompaniesRepository extends BaseRepository { ); } + if (hasPendingChangeRequest !== undefined) { + qb.andWhere( + hasPendingChangeRequest + ? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL + : `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -143,6 +164,12 @@ export class CompaniesRepository extends BaseRepository { .addGroupBy(CompaniesRepository.DRAFT_SQL) .getRawMany(); + const pendingChanges = await this.repository + .createQueryBuilder('company') + .where('company.deleted_at IS NULL') + .andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL) + .getCount(); + const map = new Map(); let onboarding = 0; let total = 0; @@ -160,6 +187,7 @@ export class CompaniesRepository extends BaseRepository { onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, + pendingChanges, }; } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 04b8790cd..df0e14998 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; +import { DataSource } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -98,6 +99,7 @@ export class CompaniesService { private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, + private readonly dataSource: DataSource, ) { } /** @@ -748,7 +750,15 @@ export class CompaniesService { submittedAt: now, note: null, })) ?? existing; + this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { + // Rejecting a request leaves it Rejected rather than reopening it, so a + // customer amending after a rejection lands here with a fresh Pending row. + // That is the resubmission case the reviewer needs flagged. + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); request = await this.changeRequestRepo.create({ companyId: company.id, snapshot: fields, @@ -756,6 +766,11 @@ export class CompaniesService { submittedBy: userId, submittedAt: now, }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); } // Live company is unchanged; surface the pending state for the settings page. @@ -827,6 +842,12 @@ export class CompaniesService { "companies", files, ); + await this.resolveDocumentChangeRequests( + companyId, + "companies", + uploaded.map((f) => f.code), + uploaded.map((f) => f.id), + ); if (company.status === CompanyStatus.Active) { await this.stageDocumentChange( company.id, @@ -837,6 +858,95 @@ export class CompaniesService { return uploaded; } + /** + * Clear the `change_requested` flag from the documents a fresh upload replaces. + * + * Uploading does not overwrite the old row — it adds a new one under the same + * `code` — so the flagged original would otherwise linger and keep the approval + * gate closed even after the customer did exactly what was asked. Only rows of + * the same code are touched, and never the newly uploaded ones. + */ + private async resolveDocumentChangeRequests( + resourceId: string, + resource: string, + codes: string[], + uploadedIds: string[], + ): Promise { + if (codes.length === 0) return; + const replaced = new Set(codes); + const fresh = new Set(uploadedIds); + const open = await this.filesService.findWithOpenChangeRequest( + [resourceId], + resource, + ); + await Promise.all( + open + .filter((f) => replaced.has(f.code) && !fresh.has(f.id)) + .map((f) => this.filesService.clearReview(f.id)), + ); + } + + /** + * Backoffice: ask the customer to correct one specific document, instead of + * rejecting their whole role over it. Mirrors the contract change-request + * flow — a note the customer sees verbatim, plus a block on approval until + * they re-upload. + */ + async requestDocumentChange( + fileId: string, + note: string, + reviewerId?: string, + ): Promise { + const file = await this.filesService.findById(fileId); + const companyId = await this.resolveDocumentCompanyId(file); + const company = await this.findCompanyById(companyId); + + // Flag the document while holding a write lock on its company row. The + // approval gate takes the same lock before it reads the flags, so the two + // serialize: a change request can never land in the window between the gate + // checking "any open corrections?" and writing the profile Active. + const updated = await this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: companyId }, + lock: { mode: "pessimistic_write" }, + }); + return this.filesService.setReviewStatus( + file.id, + "change_requested", + note, + reviewerId, + ); + }); + this.companyNotifier.documentChangeRequested( + company, + file.name, + note, + file.id, + ); + return updated; + } + + /** + * Which company a stored document belongs to. Company documents are keyed by + * the company id directly; profile licences and POA letters hang off a company + * profile, so those resolve through it. + */ + private async resolveDocumentCompanyId(file: FileRecord): Promise { + if (file.resource === "companies") return file.resourceId; + if (file.resource === "company_profiles") { + const profile = await this.companyProfilesRepo.findById(file.resourceId); + if (!profile) { + throw new NotFoundException( + `Company profile ${file.resourceId} not found`, + ); + } + return profile.companyId; + } + throw new BadRequestException( + `Documents on "${file.resource}" do not support change requests`, + ); + } + /** Open or append a pending change request recording staged document uploads. */ private async stageDocumentChange( companyId: string, @@ -847,6 +957,7 @@ export class CompaniesService { const now = new Date(); const existing = await this.changeRequestRepo.findPendingByCompanyId(companyId); + const company = await this.companiesRepo.findById(companyId); if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { @@ -860,8 +971,15 @@ export class CompaniesService { submittedAt: now, note: null, }); + if (company) { + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + } } else { - await this.changeRequestRepo.create({ + const history = await this.changeRequestRepo.findByCompanyId(companyId); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const created = await this.changeRequestRepo.create({ companyId, snapshot: {}, documents: { documentFileIds: fileIds }, @@ -869,6 +987,13 @@ export class CompaniesService { submittedBy: submittedBy ?? null, submittedAt: now, }); + if (company) { + this.companyNotifier.changeRequestSubmitted( + company, + created.id, + resubmitted, + ); + } } } @@ -987,6 +1112,61 @@ export class CompaniesService { } } + // Anything other than approval has no document gate and no concurrency + // hazard — apply it directly. + if (status !== ProfileStatus.Active) { + return this.applyProfileStatus(existing, status, note, reviewerId); + } + + // Approving over an outstanding document correction would silently accept the + // very document a reviewer just rejected, and would strand the customer's + // "please fix this" banner with nothing left to fix. The gate check and the + // status write share a write lock on the company row — `requestDocumentChange` + // takes the same lock, so a fresh correction can never land in the window + // between "any open corrections?" and the profile going Active. Suspend and + // blacklist skip all this — staff must always be able to act against a bad + // account. + return this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: existing.companyId }, + lock: { mode: "pessimistic_write" }, + }); + + const [companyDocs, profileDocs] = await Promise.all([ + this.filesService.findWithOpenChangeRequest( + [existing.companyId], + "companies", + ), + this.filesService.findWithOpenChangeRequest( + [existing.id], + "company_profiles", + ), + ]); + const pending = [...companyDocs, ...profileDocs]; + if (pending.length > 0) { + const names = pending.map((f) => f.name).join(", "); + throw new BadRequestException( + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + + `Approve it once the customer has re-uploaded them, or withdraw the change request first.`, + ); + } + + return this.applyProfileStatus(existing, status, note, reviewerId); + }); + } + + /** + * Write a reviewed profile status (reference minting, note handling, reviewer + * stamp) and promote the company if this is its first approved role. Split out + * of `setCompanyProfileStatus` so the approval path can run it inside the gate + * transaction while every other status skips that overhead. + */ + private async applyProfileStatus( + existing: CompanyProfile, + status: ProfileStatus, + note?: string, + reviewerId?: string, + ): Promise { // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -1008,9 +1188,9 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(profileId, patch); + const updated = await this.companyProfilesRepo.update(existing.id, patch); if (!updated) - throw new NotFoundException(`Company profile ${profileId} not found`); + throw new NotFoundException(`Company profile ${existing.id} not found`); // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. @@ -1057,6 +1237,13 @@ export class CompaniesService { }); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // The role is back in the pending queue — tell the reviewers, otherwise the + // resubmission is invisible until someone happens to reopen the customer. + const company = await this.companiesRepo.findById(companyId); + if (company) { + this.companyNotifier.roleReapplied(company, updated.id, updated.type); + } return updated; } @@ -1512,6 +1699,15 @@ export class CompaniesService { ); } + // A fresh licence upload answers any correction the reviewer asked for on the + // previous one, so the old row must stop blocking approval. + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + uploaded.map((r) => r.id), + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1593,6 +1789,13 @@ export class CompaniesService { await this.filesService.remove(fileId); } + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + [created.id], + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1689,6 +1892,8 @@ export class CompaniesService { : pendingRemoveIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -1855,6 +2060,13 @@ export class CompaniesService { for (const r of live) await this.filesService.remove(r.id); } + await this.resolveDocumentChangeRequests( + company.id, + COMPANY_RESOURCE, + [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE], + [created.id], + ); + return this.getPoaDelegationView(company.id); } @@ -1932,6 +2144,8 @@ export class CompaniesService { : removeIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 43d4e9905..f71a67976 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -88,4 +88,106 @@ export class CompanyNotifierService { priority: NotificationPriority.HIGH, }); } + + // ── Backoffice-facing: work has arrived back in the review queue ──────────── + + /** + * Persist + push an in-app item to every backoffice staff user, deep-linked to + * the customer's detail page. + * + * The recipient resolver has no role/permission targeting (see + * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest + * selector available, so marketing is reached by notifying all staff. + */ + private notifyStaff( + company: Company, + title: string, + body: string, + data: Record = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/customers/${company.id}`, + data: { companyId: company.id, companyName: company.name, ...data }, + }); + } + + /** + * A customer resubmitted an operational role after it was rejected for + * adjustment. Without this the role silently flips back to Pending and nobody + * is told there is anything to look at again. + */ + roleReapplied(company: Company, profileId: string, profileType: string): void { + this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`); + this.notifyStaff( + company, + "Customer resubmitted a role for approval", + `${company.name} has adjusted and resubmitted its ${profileType} role. ` + + `It is back in the pending approval queue for review.`, + { profileId, profileType }, + ); + } + + /** + * A customer submitted (or amended and resubmitted) a profile change request. + * `resubmitted` distinguishes the two so the reviewer knows this is a second + * look at something they already sent back. + */ + changeRequestSubmitted( + company: Company, + changeRequestId: string, + resubmitted: boolean, + ): void { + this.logger.log( + `CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`, + ); + this.notifyStaff( + company, + resubmitted + ? "Customer resubmitted profile changes" + : "Customer submitted profile changes", + resubmitted + ? `${company.name} has adjusted the changes you sent back and resubmitted ` + + `them. They are pending your review.` + : `${company.name} has submitted profile changes that are pending review.`, + { changeRequestId }, + ); + } + + // ── Customer-facing: a specific document needs correcting ────────────────── + + /** + * Tell the customer a reviewer wants one specific document corrected. Mirrors + * the contract `changesRequested` flow: SMS + email out, plus an in-app item + * deep-linked to the documents tab where they can re-upload. + */ + documentChangeRequested( + company: Company, + documentName: string, + note: string, + fileId: string, + ): void { + const title = "Document change requested"; + const body = + `A reviewer has asked you to correct "${documentName}". ` + + `Reason: ${note} ` + + `Please upload a corrected version from your settings page.`; + + this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title, + body, + link: "/settings", + data: { companyId: company.id, fileId, documentName }, + priority: NotificationPriority.HIGH, + }); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index c054b3531..e97a21266 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -7,4 +7,10 @@ export class CompanyStatsResponseDto { onboarding!: number; suspended!: number; blacklisted!: number; + /** + * Approved customers with an open profile change request. Counted separately + * because they are `active` and so are invisible to the `pending` KPI, even + * though they are just as much waiting on a reviewer. + */ + pendingChanges!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8b5083e5f..8d4910ded 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -48,6 +48,17 @@ export class ListCompaniesQueryDto { @IsBoolean() onboardingCompleted?: boolean; + @ApiPropertyOptional({ + description: + "`true` = only companies with an open (pending) profile change request. " + + "These are already-approved customers, so they never appear under " + + "`status=pending` and would otherwise be invisible in the review queue.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + hasPendingChangeRequest?: boolean; + @ApiPropertyOptional({ enum: ["name", "createdAt", "updatedAt"], default: "name", diff --git a/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts new file mode 100644 index 000000000..8119e041d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RequestDocumentChangeDto { + /** What is wrong with this document — shown verbatim to the customer. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index ebda7a0b9..9bba9396e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -37,8 +37,20 @@ export interface BusinessLicenseFile { */ export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; +/** + * Reviewer verdict on a document, as surfaced to clients. Distinct from + * {@link StagedFileStatus}: that describes where the file sits in the staged + * add/remove workflow, this describes whether a reviewer wants it corrected. + */ +export interface FileReviewView { + /** `change_requested` while the customer still owes a corrected upload. */ + reviewStatus?: "change_requested" | "approved" | null; + /** The reviewer's reason, shown verbatim to the customer. */ + reviewNote?: string | null; +} + /** A business-license file plus its change-review state, surfaced to clients. */ -export interface ProfileLicenseFileView { +export interface ProfileLicenseFileView extends FileReviewView { id: string; name: string; size: number; @@ -47,7 +59,7 @@ export interface ProfileLicenseFileView { } /** A company-level document (e.g. the PoA letter) with its change-review state. */ -export interface CompanyDocumentFileView { +export interface CompanyDocumentFileView extends FileReviewView { id: string; name: string; size: number; diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 221b7c29b..1fbd1459f 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index a9e8bf761..583e221ed 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository { ): Promise { 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 { + if (resourceIds.length === 0) return []; + return this.repository.find({ + where: { + resourceId: In(resourceIds), + resource, + reviewStatus: "change_requested", + }, + order: { createdAt: "ASC" }, + }); + } } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index c799d896d..e959fa556 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 { + 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 { + 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 { await this.filesRepository.softDelete(id); diff --git a/apps/edr-freight-web/backoffice/src/components/customers/RequestDocumentChangeModal.tsx b/apps/edr-freight-web/backoffice/src/components/customers/RequestDocumentChangeModal.tsx new file mode 100644 index 000000000..6322638bb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/RequestDocumentChangeModal.tsx @@ -0,0 +1,101 @@ +import { + Alert, + Button, + Group, + Modal, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { FilePen } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { api } from "@/services/api"; +import type { CustomerDocument } from "@/types/customer"; + +export interface RequestDocumentChangeModalProps { + /** The document under review; `null` closes the modal. */ + document: CustomerDocument | null; + companyId: string; + onClose: () => void; +} + +/** + * Ask the customer to correct one uploaded document. + * + * Deliberately narrower than rejecting a whole role: the customer keeps every + * other document and only re-uploads this one. The role cannot be approved + * while the request is open, so the note has to say what is actually wrong — + * it is shown to the customer verbatim. + */ +export function RequestDocumentChangeModal({ + document, + companyId, + onClose, +}: RequestDocumentChangeModalProps) { + const requestChange = useMutation( + api.customers.requestDocumentChange.mutationOptions(), + ); + const [note, setNote] = useState(""); + + // Re-opening on a document that already has an open request should show what + // was asked for, so the reviewer edits the reason rather than retyping it. + useEffect(() => { + setNote(document?.reviewNote ?? ""); + }, [document?.id, document?.reviewNote]); + + const submit = () => { + if (!document) return; + requestChange.mutate( + { companyId, fileId: document.id, note: note.trim() }, + { onSuccess: onClose }, + ); + }; + + return ( + + + }> + The customer is notified and sees this note verbatim. This role cannot + be approved until they upload a corrected document. + + + Document: {document?.name} + +