From 16a7aebaddc9dfc48179049260c71348e5e2c59d Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Fri, 24 Jul 2026 14:01:21 +0300 Subject: [PATCH 01/11] update migration to before deployment in freight api --- .github/workflows/deploy.yml | 7 +++--- apps/edr-freight-api/Dockerfile | 4 ++++ apps/edr-freight-api/package.json | 1 + .../src/config/database.config.ts | 15 +++++++----- apps/edr-freight-api/src/data-source.ts | 24 ++++++------------- apps/edr-freight-api/src/scripts/migrate.ts | 24 +++++++++++++++++++ 6 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/migrate.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f0beccd20..c648fec09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -144,15 +144,16 @@ jobs: run: ./scripts/deploy/create-npmrc.sh - name: Resolve env file path for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | case "${{ matrix.service }}" in + freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;; passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; esac - name: Build migration image for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker build \ @@ -163,7 +164,7 @@ jobs: . - name: Run migrations for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 984e622db..e2c4137cc 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -31,6 +31,10 @@ COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +FROM deployer AS migration +WORKDIR /deploy +CMD ["node", "dist/scripts/migrate.js"] + FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat ENV NODE_ENV=production diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bca74475e..8ecd815ed 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,6 +36,7 @@ "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "migration:run": "node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index dcf09fbd9..65029819d 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,5 +1,6 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSourceOptions } from "typeorm"; import { join, dirname } from "path"; import { DefaultPosition, @@ -95,7 +96,7 @@ const iamMigrationsGlob = join( ); const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); -export default registerAs("database", (): TypeOrmModuleOptions => { +export function buildDataSourceOptions(): DataSourceOptions { return { type: "postgres", host: process.env.DB_HOST ?? "localhost", @@ -111,17 +112,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => { // The search_path is instead applied per-connection via a pool `connect` // handler in app.module.ts (see setPoolSearchPath). entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], - autoLoadEntities: true, migrations: [ - // IAM schema + tables must be created before freight migrations iamMigrationsGlob, freightMigrationsGlob, ], - migrationsRun: true, migrationsTransactionMode: "each", - // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, logging: process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; -}); +} + +export default registerAs("database", (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, +})); diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index 2ae202ebd..03baa31e2 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -1,21 +1,11 @@ // apps/edr-freight-api/src/data-source.ts -import 'dotenv/config'; -import { DataSource } from 'typeorm'; -//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed +import "dotenv/config"; +import { DataSource, DataSourceOptions } from "typeorm"; +import { buildDataSourceOptions } from "./config/database.config"; export const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5433), - username: process.env.DB_USER ?? 'postgres', - password: process.env.DB_PASSWORD ?? '', - database: process.env.DB_NAME ?? 'edr_freight', - schema: 'freight', // default schema for entities without an explicit schema - entities: [__dirname + '/**/*.entity{.ts,.js}'], - migrations: [__dirname + '/migrations/*{.ts,.js}'], - synchronize: false, - logging: process.env.TYPEORM_LOGGING === 'true', -}); + ...buildDataSourceOptions(), + schema: "freight", +} as DataSourceOptions); -// Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. +export default AppDataSource; diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts new file mode 100644 index 000000000..7f331e1d3 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -0,0 +1,24 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; +import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas"; +import { buildDataSourceOptions } from "../config/database.config"; + +async function main(): Promise { + await ensurePostgresSchemas(buildDataSourceOptions()); + + await AppDataSource.initialize(); + try { + const applied = await AppDataSource.runMigrations(); + for (const migration of applied) { + console.log(`applied: ${migration.name}`); + } + if (applied.length === 0) console.log("no pending migrations"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 83d3fc3b788ca0f68b6266eee2fd8bc3cab5b50b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 11:43:07 +0000 Subject: [PATCH 02/11] feat(freight-portal): show account-status banner on every login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket #422 — surface Pending/Suspended/Approved company status as a banner, branching AccountReviewBanner on companyStatus instead of only per-profile review state. --- .../onboarding/OnboardingResumeBanner.tsx | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx index 21d4d0746..a7cf1a2aa 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router-dom"; -import { AlertTriangle, ArrowRight, Clock } from "lucide-react"; +import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react"; import { api } from "@/services/api"; import useAuth from "@/hooks/useAuth"; import type { OnboardingRequirements } from "@/services/companies.service"; @@ -157,11 +157,34 @@ export default function OnboardingResumeBanner({ * Self-hides when there's nothing outstanding. */ export function AccountReviewBanner() { - const { company, reviewStatus, reviewNote } = useAuth(); + const { company, companyStatus, reviewStatus, reviewNote } = useAuth(); const profiles = company?.company?.companyProfiles ?? []; const pending = profiles.filter((p) => p.status === "pending"); const approved = profiles.filter((p) => p.status === "active"); + // 0. Account suspended/blacklisted — the hardest lock, takes priority over + // everything else since nothing below matters if the account is shut down. + if (companyStatus === "suspended" || companyStatus === "blacklisted") { + return ( +
+
+ + + + + + Your account has been suspended + + + Contact EDR support to resolve this before you can continue + working. + + +
+
+ ); + } + // 1. Profile-edit review pending — the account-wide lock. if (reviewStatus === "pending") { return ( @@ -216,8 +239,45 @@ export function AccountReviewBanner() { ); } - // 3. Per-operational-profile approval (existing behaviour). - if (profiles.length === 0 || pending.length === 0) return null; + // 3. Company approved and awaiting its first operational profile — nothing + // profile-specific to report yet, but the account itself is pending. + if (profiles.length === 0) { + if (companyStatus === "pending") { + return ( +
+
+ + + + + Your account is pending approval + +
+
+ ); + } + return null; + } + + // 4. Per-operational-profile approval (existing behaviour). + if (pending.length === 0) { + // Nothing outstanding — a quiet confirmation that the account is live. + if (companyStatus === "active") { + return ( +
+
+ + + + + Your account is approved + +
+
+ ); + } + return null; + } const pendingLabel = pending .map((p) => p.type.replace(/_/g, " ")) From d67297a601f744423c25a3d51cc7c58592363f23 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 11:43:36 +0000 Subject: [PATCH 03/11] 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; } /** From 7c5d96795cc349bd95f7235b5a038d8f69c2fadb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 11:45:09 +0000 Subject: [PATCH 04/11] feat(freight): track onboarding-phase edit history for companies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket #238 — pre-approval edits and document uploads write straight to the live company row with no approval gate and, until now, no trace. Adds an append-only company_revisions log (diffed field changes, document uploads) recorded from updateProfile and uploadCompanyDocuments, exposed via GET /companies/:id/revisions and shown as "Version history" on the backoffice customer detail page. --- .../3130000000000-CreateCompanyRevisions.ts | 48 ++++++++++ .../modules/companies/companies.controller.ts | 12 +++ .../companies.fayda-identity.spec.ts | 8 ++ .../src/modules/companies/companies.module.ts | 4 + .../companies.poa-delegation.spec.ts | 8 ++ .../companies/companies.role-deselect.spec.ts | 1 + .../modules/companies/companies.service.ts | 52 +++++++++++ .../companies/company-revision-diff.util.ts | 90 +++++++++++++++++++ .../companies/company-revision.repository.ts | 23 +++++ .../dto/company-revision-response.dto.ts | 23 +++++ .../entities/company-revision.entity.ts | 39 ++++++++ .../customers/CompanyRevisionHistory.tsx | 53 +++++++++++ .../src/components/customers/index.ts | 1 + .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 2 + .../backoffice/src/services/api.ts | 8 ++ .../src/services/customers.service.ts | 8 ++ 18 files changed, 383 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-revision.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/CompanyRevisionHistory.tsx 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..fdfd4e344 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({ 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 32c481873..5dae44f2b 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,7 @@ import { DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; +import { CompanyRevision } from "./entities/company-revision.entity"; /** FileRecord `resource` + `code` slots for business-license documents. */ const LICENSE_RESOURCE = "company_profiles"; @@ -176,10 +183,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, @@ -849,6 +859,33 @@ 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, + ): Promise { + try { + const changes = diffCompanyUpdate(before, patch); + 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, @@ -911,6 +948,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); } @@ -995,6 +1033,12 @@ 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); + } + /** * 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 @@ -1064,6 +1108,14 @@ export class CompaniesService { uploaded.map((f) => f.id), submittedBy, ); + } else if (uploaded.length > 0) { + await this.recordCompanyRevision( + company, + { + documents: uploaded.map((f) => f.name).join(", "), + }, + submittedBy, + ); } return uploaded; } 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-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/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts new file mode 100644 index 000000000..4872dfff9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -0,0 +1,39 @@ +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. */ +export interface CompanyRevisionChange { + field: string; + label: string; + from: string | null; + to: 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-web/backoffice/src/components/customers/CompanyRevisionHistory.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyRevisionHistory.tsx new file mode 100644 index 000000000..a933e003d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyRevisionHistory.tsx @@ -0,0 +1,53 @@ +import { Badge, Box, Card, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { History } from "lucide-react"; + +import { api } from "@/services/api"; +import { formatDate } from "./format"; + +/** + * Onboarding-phase edit history: what changed on the company record before it + * reached Active, the write path that has no approval gate (unlike edits made + * after approval, which go through {@link ChangeRequestReview} instead). + */ +export function CompanyRevisionHistory({ companyId }: { companyId: string }) { + const query = useQuery( + api.customers.revisions.queryOptions({ input: { id: companyId } }), + ); + const revisions = query.data ?? []; + if (revisions.length === 0) return null; + + return ( + + + + + + Version history + + + {revisions.map((rev) => ( + + + + {formatDate(rev.createdAt)} + + + {rev.summary} + + + {rev.changes.length > 0 && ( + + {rev.changes.map((c, i) => ( + + {c.label}: {c.from ?? "—"} → {c.to ?? "—"} + + ))} + + )} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index dd35c3c48..a4cc985bd 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,6 +13,7 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { CompanyRevisionHistory } from "./CompanyRevisionHistory"; export { RequestDocumentChangeModal, type RequestDocumentChangeModalProps, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index c57a607d5..6e95a27fc 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -42,6 +42,8 @@ export const QUERY_KEYS = { ["customers", "detail", id, "reset-target"] as const, changeRequests: (id: string) => ["customers", "detail", id, "change-requests"] as const, + revisions: (id: string) => + ["customers", "detail", id, "revisions"] as const, }, INVOICES: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 142e6c3e7..7651e30d1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -78,6 +78,7 @@ export const URL_CONSTANTS = { `/companies/company-profiles/${profileId}/status`, CHANGE_REQUESTS: (companyId: string) => `/companies/${companyId}/change-requests`, + REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`, CHANGE_REQUEST_APPROVE: (id: string) => `/companies/change-requests/${id}/approve`, CHANGE_REQUEST_REJECT: (id: 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 79f559858..681ab343e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -39,6 +39,7 @@ import { BookingStatusBadge, ChangeRequestPendingBadge, ChangeRequestReview, + CompanyRevisionHistory, CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, @@ -710,6 +711,7 @@ export default function CustomerDetailPage() { )} + QUERY_KEYS.CUSTOMERS.changeRequests(id), ), + revisions: endpoint<{ id: string }, CompanyRevision[]>( + "customers", + "revisions", + ({ id }) => customersService.revisions(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id), + ), + approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>( "customers", "approveChangeRequest", diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index 6c6d94304..044952cf5 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -5,6 +5,7 @@ import type { CompanyChangeRequest, CompanyListFilter, CompanyProfile, + CompanyRevision, CompanyStats, CustomerBooking, CustomerDocument, @@ -146,6 +147,13 @@ export const customersService = { .then((r) => r.data); }, + /** Onboarding-phase edit history for a company (version history), newest first. */ + revisions(companyId: string): Promise { + return apiClient + .get(URL_CONSTANTS.COMPANIES.REVISIONS(companyId)) + .then((r) => r.data); + }, + /** Approve a pending change request — applies the proposed changes. */ approveChangeRequest(id: string): Promise { return apiClient From 3952e8bbdfd2eb7587ea4b45e0c2fbbf3b3938f1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 11:45:24 +0000 Subject: [PATCH 05/11] feat(freight): add download for the TIN business registration record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket #181 — eTrade auto-fetch already existed; adds a client-side download of the fetched record (eTrade returns data, not a document) on the portal onboarding step and the backoffice customer detail page. --- .../pages/customers/CustomerDetailPage.tsx | 62 +++++++++++++++---- .../src/components/onboarding/ETradeInfo.tsx | 36 +++++++++++ 2 files changed, 86 insertions(+), 12 deletions(-) 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 681ab343e..69d831310 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -65,6 +65,7 @@ import { } from "@/services/files.service"; import { api } from "@/services/api"; import type { + Company, CompanyProfile, CustomerBooking, CustomerDocument, @@ -79,6 +80,32 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */ +function downloadTinRecord(company: Company) { + const lines = [ + `TIN: ${company.tin}`, + `Company name: ${company.name}`, + `Licence number: ${company.licenceNumber ?? ""}`, + `Status: ${company.statusDescription ?? ""}`, + `Date registered: ${company.dateRegistered ?? ""}`, + `Renewed from: ${company.renewedFrom ?? ""}`, + `Renewal date: ${company.renewalDate ?? ""}`, + `Renewed to: ${company.renewedTo ?? ""}`, + `Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`, + ]; + const blob = new Blob([lines.join("\n")], { + type: "text/plain;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `tin-${company.tin}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + function InfoField({ label, value }: { label: string; value?: string | null }) { return ( @@ -814,18 +841,29 @@ export default function CustomerDetailPage() { - - - eTrade registration - - {hasEtradeRecord ? ( - - Verified with eTrade - - ) : ( - - No eTrade record - + + + + eTrade registration + + {hasEtradeRecord ? ( + + Verified with eTrade + + ) : ( + + No eTrade record + + )} + + {hasEtradeRecord && ( + downloadTinRecord(company)} + > + + )} {hasEtradeRecord ? ( diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index d36845dd9..082331c7b 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -28,6 +28,31 @@ interface ETradeInfoProps { const isValidTin = (tin: string) => tin.length === 10; +/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */ +function downloadTinRecord(tin: string, data: CompanyRegistrationData) { + const lines = [ + `TIN: ${tin}`, + `Company name: ${data.companyName}`, + `Licence number: ${data.licenceNumber}`, + `Status: ${data.statusDescription}`, + `Date registered: ${data.dateRegistered}`, + `Renewed from: ${data.renewedFrom}`, + `Renewal date: ${data.renewalDate}`, + `Renewed to: ${data.renewedTo}`, + `Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`, + `Manager: ${data.managerName}`, + ]; + const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `tin-${tin}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + export default function ETradeInfo({ tin, register, @@ -123,6 +148,17 @@ export default function ETradeInfo({ {isLoading ? "Getting..." : "Get Data"} )} + {status === "verified" && mutation.data && !mutation.data.tinTaken && ( + + )} {notFound && ( From 4f81a0bbb8e8d8614e196e6b0130fe0e398b84ea Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 14:12:30 +0000 Subject: [PATCH 06/11] feat(freight): non-terminal change-request review + unified customer timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval). --- .../modules/companies/companies.controller.ts | 19 ++ .../modules/companies/companies.service.ts | 194 +++++++++++- .../company-change-request.repository.spec.ts | 35 +- .../company-change-request.repository.ts | 12 +- .../dto/company-info-response.dto.ts | 10 +- .../companies/dto/profile-response.dto.ts | 15 +- .../entities/company-change-request.entity.ts | 10 +- .../entities/company-revision.entity.ts | 9 +- .../src/modules/files/files.controller.ts | 10 +- .../src/modules/files/files.service.ts | 22 +- .../customers/ChangeRequestReview.tsx | 130 ++++---- .../customers/CompanyRevisionHistory.tsx | 53 ---- .../components/customers/CompanyTimeline.tsx | 299 ++++++++++++++++++ .../src/components/customers/index.ts | 2 +- .../backoffice/src/constants/URLS.ts | 2 + .../pages/customers/CustomerDetailPage.tsx | 12 +- .../backoffice/src/services/api.ts | 15 + .../src/services/customers.service.ts | 13 + .../backoffice/src/types/customer.ts | 14 +- .../onboarding/OnboardingResumeBanner.tsx | 49 ++- .../portal/src/pages/SettingsPage.tsx | 20 ++ .../portal/src/services/companies.service.ts | 8 +- .../portal/src/types/profile.ts | 8 +- 23 files changed, 795 insertions(+), 166 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/components/customers/CompanyRevisionHistory.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx 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 fdfd4e344..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -731,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.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 5dae44f2b..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -70,7 +70,10 @@ import { DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; -import { CompanyRevision } from "./entities/company-revision.entity"; +import { + CompanyRevision, + CompanyRevisionChange, +} from "./entities/company-revision.entity"; /** FileRecord `resource` + `code` slots for business-license documents. */ const LICENSE_RESOURCE = "company_profiles"; @@ -869,9 +872,10 @@ export class CompaniesService { before: Company, patch: Record, actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], ): Promise { try { - const changes = diffCompanyUpdate(before, patch); + const changes = [...diffCompanyUpdate(before, patch), ...extraChanges]; if (changes.length === 0) return; await this.revisionRepo.create({ companyId: before.id, @@ -992,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 { @@ -1039,6 +1047,47 @@ export class CompaniesService { 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 @@ -1068,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, @@ -1078,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, @@ -1091,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", @@ -1108,14 +1240,9 @@ export class CompaniesService { uploaded.map((f) => f.id), submittedBy, ); - } else if (uploaded.length > 0) { - await this.recordCompanyRevision( - company, - { - documents: uploaded.map((f) => f.name).join(", "), - }, - submittedBy, - ); + } + if (documentChanges.length > 0) { + await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); } return uploaded; } @@ -1231,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); @@ -1292,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); @@ -2326,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({ @@ -2652,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; @@ -2932,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/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/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/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 index 4872dfff9..222a8364f 100644 --- 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 @@ -2,12 +2,19 @@ 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. */ +/** + * 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; } /** 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 +