From d67297a601f744423c25a3d51cc7c58592363f23 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 11:43:36 +0000 Subject: [PATCH] feat(freight): record when a company is approved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket #420 — add approved_at to companies (migration), stamped at both promotion sites (first-profile auto-approve and manual staff status change). Surfaced as Submitted on/Approved on in the backoffice customer list and detail views. --- .../3120000000000-AddApprovedAtToCompanies.ts | 24 +++++++++++++++++++ .../modules/companies/companies.service.ts | 12 +++++++++- .../companies/dto/response-company.dto.ts | 2 ++ .../companies/entities/company.entity.ts | 4 ++++ .../pages/customers/CustomerDetailPage.tsx | 12 ++++++++++ .../src/pages/customers/CustomersPage.tsx | 10 ++++++++ .../backoffice/src/types/customer.ts | 22 +++++++++++++++++ 7 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts 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/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index c198d149f..32c481873 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -682,7 +682,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. @@ -1472,6 +1481,7 @@ export class CompaniesService { ) { await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, + approvedAt: new Date(), }); this.companyNotifier.companyApproved(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.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-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 666069d98..79f559858 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -757,6 +757,18 @@ export default function CustomerDetailPage() { + + ), }, + { + id: "approved", + header: "Approved", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"} + + ), + }, ], [], ); diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7e69b19d7..0892e5384 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -110,6 +110,27 @@ export interface CompanyChangeRequest { updatedAt: string; } +/** One field/document change recorded on a company revision. */ +export interface CompanyRevisionChange { + field: string; + label: string; + from: string | null; + to: string | null; +} + +/** + * Onboarding-phase edit history — records what changed on a company record + * before it reached Active, the write path that has no approval gate. + */ +export interface CompanyRevision { + id: string; + companyId: string; + actorId: string | null; + summary: string; + changes: CompanyRevisionChange[]; + createdAt: string; +} + /** The channel a customer's password-reset link is delivered over. */ export type ResetChannel = "email" | "phone"; @@ -215,6 +236,7 @@ export interface Company { onboardingCompleted?: boolean; createdAt: string; updatedAt: string; + approvedAt?: string | null; } /**