diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts new file mode 100644 index 000000000..976aca798 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Pending→Active is the only company-level approval event; `updatedAt` can't + * stand in for it since any field edit bumps that too. Nullable — existing + * companies (approved before this column existed) have no recorded moment. + */ +export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface { + name = "AddApprovedAtToCompanies3120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS approved_at timestamptz`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + DROP COLUMN IF EXISTS approved_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts new file mode 100644 index 000000000..01c1c6991 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Append-only audit of company edits made before the company reaches Active + * (the onboarding phase) — that write path has no approval gate and, until + * now, left no trace of what changed (e.g. a phone number or a document). + */ +export class CreateCompanyRevisions3130000000000 implements MigrationInterface { + name = 'CreateCompanyRevisions3130000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_revisions', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'actor_id', type: 'uuid', isNullable: true }, + { name: 'summary', type: 'varchar', length: '255' }, + { name: 'changes', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedSchema: 'freight', + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.company_revisions', + new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_revisions', true); + } +} 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 ee75460c9..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat 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 { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -685,6 +686,17 @@ export class CompaniesController { return requests.map((r) => new ChangeRequestResponseDto(r)); } + @Get(":companyId/revisions") + @BookingStaff(FREIGHT_PERMS.customers.view) + @ApiOperation({ summary: "Onboarding-phase edit history (version history)" }) + async listCompanyRevisions( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const revisions = + await this.companiesService.listCompanyRevisions(companyId); + return revisions.map((r) => new CompanyRevisionResponseDto(r)); + } + @Post("change-requests/:id/approve") @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ @@ -719,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 { + 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" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index c13f79f5e..511c3aa25 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -120,6 +120,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, profilesRepo: { findByCompanyId: jest.fn(async () => []), findByUserId: jest.fn(async () => ({ @@ -144,6 +151,7 @@ function makeService(overrides: Partial = {}) { deps.companiesRepo as never, deps.companyProfilesRepo as never, deps.changeRequestRepo as never, + deps.revisionRepo as never, deps.profilesRepo as never, {} as never, deps.filesService as never, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 450222657..f0a443ba8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; import { CompanyChangeRequest } from "./entities/company-change-request.entity"; +import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { CompanyRevisionRepository } from "./company-revision.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { VerifaydaModule } from "../verifayda/verifayda.module"; @@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; ExternalProfile, CompanyProfile, CompanyChangeRequest, + CompanyRevision, Booking, ]), HttpModule, @@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; ExternalProfileRepository, CompanyProfileRepository, CompanyChangeRequestRepository, + CompanyRevisionRepository, CompanyDashboardRepository, ETradeService, CompanyNotifierService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 27c42e581..365bd4639 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -91,6 +91,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, profilesRepo: { findByCompanyId: jest.fn(async () => []), findByUserId: jest.fn(async () => ({ @@ -121,6 +128,7 @@ function makeService(overrides: Partial = {}) { deps.companiesRepo as never, deps.companyProfilesRepo as never, deps.changeRequestRepo as never, + deps.revisionRepo as never, deps.profilesRepo as never, {} as never, deps.filesService as never, diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index f22123f61..5961be90f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) { companiesRepo as never, companyProfilesRepo as never, {} as never, + {} as never, profilesRepo as never, {} as never, {} as never, 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 c198d149f..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,5 +1,6 @@ import { Injectable, + Logger, NotFoundException, ConflictException, BadRequestException, @@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { CompanyRevisionRepository } from "./company-revision.repository"; +import { + diffCompanyUpdate, + summarizeCompanyChanges, +} from "./company-revision-diff.util"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, @@ -64,6 +70,10 @@ import { DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; +import { + CompanyRevision, + CompanyRevisionChange, +} from "./entities/company-revision.entity"; /** FileRecord `resource` + `code` slots for business-license documents. */ const LICENSE_RESOURCE = "company_profiles"; @@ -176,10 +186,13 @@ export interface UserIdentity { @Injectable() export class CompaniesService { + private readonly logger = new Logger(CompaniesService.name); + constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, private readonly changeRequestRepo: CompanyChangeRequestRepository, + private readonly revisionRepo: CompanyRevisionRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly filesService: FilesService, @@ -682,7 +695,16 @@ export class CompaniesService { async updateCompany(id: string, dto: UpdateCompanyDto): Promise { const before = await this.findCompanyById(id); - const updated = await this.companiesRepo.update(id, dto); + const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto }; + // Staff can also promote Pending -> Active directly through this generic + // endpoint (not just via the first-profile-approval path), so stamp it here too. + if ( + dto.status === CompanyStatus.Active && + before.status !== CompanyStatus.Active + ) { + patch.approvedAt = new Date(); + } + const updated = await this.companiesRepo.update(id, patch); if (!updated) throw new NotFoundException(`Company ${id} not found`); // Suspending or blacklisting locks the customer out, so they must be told. @@ -840,6 +862,34 @@ export class CompaniesService { return companyUpdates; } + /** + * Append a version-history entry for an onboarding-phase edit (the company + * is not yet Active, so the change went straight to the live row with no + * approval gate to carry a record of it). Best-effort: a no-op patch or a + * failure to write history must never break the edit that triggered it. + */ + private async recordCompanyRevision( + before: Company, + patch: Record, + actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], + ): Promise { + try { + const changes = [...diffCompanyUpdate(before, patch), ...extraChanges]; + if (changes.length === 0) return; + await this.revisionRepo.create({ + companyId: before.id, + actorId: actorId ?? null, + summary: summarizeCompanyChanges(changes), + changes, + }); + } catch (err) { + this.logger.error( + `Failed to record company revision for ${before.id}: ${String(err)}`, + ); + } + } + /** Reject a TIN already registered to a *different* company. */ private async assertTinAvailable( company: Company, @@ -902,6 +952,7 @@ export class CompaniesService { ); if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + await this.recordCompanyRevision(company, companyUpdates, userId); return new ProfileResponseDto(profile, updated); } @@ -945,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 { @@ -986,6 +1041,53 @@ export class CompaniesService { return this.changeRequestRepo.findByCompanyId(companyId); } + /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ + async listCompanyRevisions(companyId: string): Promise { + await this.findCompanyById(companyId); + 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 @@ -1015,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, @@ -1025,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 { + 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, @@ -1038,11 +1214,20 @@ export class CompaniesService { submittedBy?: string, ): Promise { 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", @@ -1056,6 +1241,9 @@ export class CompaniesService { submittedBy, ); } + if (documentChanges.length > 0) { + await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); + } return uploaded; } @@ -1170,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); @@ -1231,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 { + 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 { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); @@ -1472,6 +1692,7 @@ export class CompaniesService { ) { await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, + approvedAt: new Date(), }); this.companyNotifier.companyApproved(company); } @@ -2264,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({ @@ -2590,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; @@ -2870,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({ diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts index 73271e0af..1dc4b5a84 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -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 & { 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 | 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 & { companyId: string } }) => + ({ + where, + }: { + where: { companyId: string; status?: Row["status"] | FindOperator }; + }) => 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, diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index eb44d56cb..8fb81e6f5 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -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 { constructor( @@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository { return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Pending }, + where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) }, order: { createdAt: "DESC" }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts new file mode 100644 index 000000000..83fa539e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -0,0 +1,90 @@ +import type { Company } from "./entities/company.entity"; +import type { CompanyRevisionChange } from "./entities/company-revision.entity"; + +/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */ +export const COMPANY_FIELD_LABELS: Record = { + name: "Company name", + phone: "Phone", + email: "Email", + address: "Address", + country: "Country", + tin: "TIN", + vatNumber: "VAT number", + fanNumber: "FAN number", + nationality: "Nationality", + website: "Website", + licenceNumber: "Licence number", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", + houseNo: "House No", + contactPersonName: "Contact person name", + contactPersonPhone: "Contact person phone", + contactPersonEmail: "Contact person email", + contactPersonPosition: "Contact person position", + generalManagerName: "General manager name", + generalManagerPhone: "General manager phone", + generalManagerEmail: "General manager email", + poaName: "PoA name", + poaPhone: "PoA phone", + poaEmail: "PoA email", + poaLocation: "PoA location", + poaAddress: "PoA address", + documents: "Document", +}; + +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "boolean") return value ? "Yes" : "No"; + return String(value); +} + +/** + * Compare the company row before a write against the patch about to be + * applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar + * columns plus a merged `attributes` blob). Only fields with a known label + * are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never + * shows up as noise. + */ +export function diffCompanyUpdate( + before: Company, + patch: Record, +): CompanyRevisionChange[] { + const changes: CompanyRevisionChange[] = []; + const { attributes: attrPatch, ...columnPatch } = patch; + + for (const [field, nextRaw] of Object.entries(columnPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue((before as unknown as Record)[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + + if (attrPatch) { + const beforeAttrs = before.attributes ?? {}; + for (const [field, nextRaw] of Object.entries(attrPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue(beforeAttrs[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + } + + return changes; +} + +/** Short human summary of a change set, e.g. "phone, address changed". */ +export function summarizeCompanyChanges( + changes: CompanyRevisionChange[], +): string { + if (changes.length === 0) return "No changes"; + const labels = changes.map((c) => c.label.toLowerCase()); + return labels.length <= 3 + ? `${labels.join(", ")} changed` + : `${labels.length} fields changed`; +} diff --git a/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts new file mode 100644 index 000000000..b04bb35de --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyRevision } from "./entities/company-revision.entity"; + +@Injectable() +export class CompanyRevisionRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyRevision) + repo: Repository, + ) { + super(repo); + } + + /** Revision history for a company, newest first. */ + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 2634e0943..34071e813 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts new file mode 100644 index 000000000..c93c7387c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -0,0 +1,23 @@ +import { + CompanyRevision, + CompanyRevisionChange, +} from "../entities/company-revision.entity"; + +/** One version-history entry, shown on the backoffice customer detail page. */ +export class CompanyRevisionResponseDto { + id: string; + companyId: string; + actorId: string | null; + summary: string; + changes: CompanyRevisionChange[]; + createdAt: Date; + + constructor(revision: CompanyRevision) { + this.id = revision.id; + this.companyId = revision.companyId; + this.actorId = revision.actorId ?? null; + this.summary = revision.summary; + this.changes = revision.changes ?? []; + this.createdAt = revision.createdAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 6072268dc..f0a19dad7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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 | 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); diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 60f9f9a34..d705e7323 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -97,6 +97,7 @@ export class ResponseCompanyDto { createdAt: Date; updatedAt: Date; + approvedAt: Date | null; constructor(company: Company) { this.id = company.id; @@ -135,5 +136,6 @@ export class ResponseCompanyDto { this.identity = buildCompanyIdentityState(company); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; + this.approvedAt = company.approvedAt ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 5ee6739de..2ba39ecad 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -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", } /** diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts new file mode 100644 index 000000000..222a8364f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -0,0 +1,46 @@ +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. 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; +} + +/** + * Append-only audit of edits made to a company record BEFORE it reaches + * `Active` (the onboarding phase), where {@link CompaniesService.updateProfile} + * and {@link CompaniesService.uploadCompanyDocuments} write straight to the + * live row with no approval gate — and, until this entity, no trace at all. + * Post-approval edits already get history via `CompanyChangeRequest`; this + * covers the gap before that gate exists. + */ +@Entity({ schema: "freight", name: "company_revisions" }) +@Index(["companyId"]) +export class CompanyRevision extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + @Column({ name: "actor_id", type: "uuid", nullable: true }) + actorId?: string | null; + + @Column({ name: "summary", type: "varchar", length: 255 }) + summary!: string; + + @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) + changes!: CompanyRevisionChange[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 5fe3a3f67..f254e0121 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -61,6 +61,10 @@ export class Company extends BaseEntity { }) status!: CompanyStatus; + /** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */ + @Column({ name: "approved_at", type: "timestamptz", nullable: true }) + approvedAt?: Date | null; + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 6978ad446..fc58a731c 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -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"; 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 bc446b508..e76240704 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 }; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 253d8aea5..196161f92 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -2,7 +2,6 @@ import { Alert, Anchor, Badge, - Box, Button, Card, Group, @@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; -import type { Company, CompanyChangeRequest } from "@/types/customer"; +import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; /** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ -const FIELD_LABELS: Record = { +export const FIELD_LABELS: Record = { companyName: "Company name", companyEmail: "Company email", companyPhone: "Company phone", @@ -69,7 +68,7 @@ const FIELD_LABELS: Record = { }; /** Best-effort current value on the live company for a proposed field key. */ -function currentValue(company: Company, key: string): string { +export function currentValue(company: Company, key: string): string { const c = company as unknown as Record; const attrs = (company.attributes ?? {}) as Record; const map: Record = { @@ -160,7 +159,7 @@ function FaydaIdentityDiff({ ); } -function DiffRow({ +export function DiffRow({ label, from, to, @@ -201,8 +200,9 @@ function DiffRow({ /** * Backoffice review surface for a customer's staged profile edits. Shows the - * pending change request as a proposed-vs-current diff with Approve / Reject - * (with note) actions, plus a short history of past decisions. + * pending change request as a proposed-vs-current diff with Approve / Reject / + * Request changes actions. Past decisions live in the History tab's unified + * timeline (see {@link CompanyTimeline}), not here. */ export function ChangeRequestReview({ company }: { company: Company }) { const { user } = useAuth(); @@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) { const reject = useMutation( api.customers.rejectChangeRequest.mutationOptions(), ); + const requestChanges = useMutation( + api.customers.requestChangeRequestChanges.mutationOptions(), + ); const { view, viewer } = useFileViewer(); - const [rejectId, setRejectId] = useState(null); + const [actionTarget, setActionTarget] = useState<{ + id: string; + kind: "reject" | "request-changes"; + } | null>(null); const [note, setNote] = useState(""); const requests = query.data ?? []; const pending = requests.find((r) => r.status === "pending"); - const history = requests.filter((r) => r.status !== "pending").slice(0, 5); - if (!pending && history.length === 0) return null; + if (!pending) return null; const proposedKeys = pending ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") @@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) { const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; - const confirmReject = () => { - if (!rejectId) return; - reject.mutate( - { id: rejectId, note: note.trim() }, + const confirmAction = () => { + if (!actionTarget) return; + const mutation = actionTarget.kind === "reject" ? reject : requestChanges; + mutation.mutate( + { id: actionTarget.id, note: note.trim() }, { onSuccess: () => { - setRejectId(null); + setActionTarget(null); setNote(""); }, }, @@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) { + {pending.note && ( + } + > + Changes were requested on an earlier round of this same + submission: {pending.note} — check whether + this resubmission actually addresses it before approving. + + )} + {proposedKeys.length > 0 ? ( {proposedKeys.map((key) => ( @@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) { variant="light" color="red" onClick={() => { - setRejectId(pending.id); + setActionTarget({ id: pending.id, kind: "reject" }); setNote(""); }} > Reject +