diff --git a/README.md b/README.md index 125561b9d..50e0b55f6 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed - 3 User accounts (Admin, Passenger, Agent) - Fare rules for ADULT and CHILD passenger categories - Currency exchange rates (ETB, DJF, USD) -- Baggage allowance rules +- Luggage allowance rules - Notification templates - Promotions and FAQ content - Menu items and station crowd signals diff --git a/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts new file mode 100644 index 000000000..9d8d2b5c9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Staging table for customer profile edits that require backoffice review. An + * already-approved company's settings edits are snapshotted here (Pending) + * instead of being written to the live `companies` row; a reviewer approves + * (snapshot applied) or rejects with a note (customer amends & resubmits). + */ +export class CreateCompanyChangeRequest2000000000000 + implements MigrationInterface +{ + name = 'CreateCompanyChangeRequest2000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_change_request', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'snapshot', type: 'jsonb' }, + { name: 'documents', type: 'jsonb', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'note', type: 'text', isNullable: true }, + { name: 'submitted_by', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { 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_change_request', + new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }), + ); + await queryRunner.createIndex( + 'freight.company_change_request', + new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_change_request', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts new file mode 100644 index 000000000..2c2fdf3e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds reviewer note/id/timestamp to company_profiles so a rejected operational + * role (new ProfileStatus 'rejected') can carry the reason back to the customer, + * who can then amend and reapply. + */ +export class AddCompanyProfileReview2000000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileReview2000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('freight.company_profiles', [ + new TableColumn({ name: 'review_note', type: 'text', isNullable: true }), + new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }), + new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('freight.company_profiles', [ + 'review_note', + 'reviewed_by', + 'reviewed_at', + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts new file mode 100644 index 000000000..333a0f541 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in + * kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo + * weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes + * and is NOT touched; truck gross weight has no data yet. Runs exactly once + * (tracked by TypeORM) — re-running would divide again. + */ +export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface { + name = 'WarehouseCapacityKgToTons2020000000000'; + + private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones']; + private readonly columns = ['capacity_weight', 'current_weight', 'max_weight']; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts new file mode 100644 index 000000000..d3720de33 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Business-license files used to live inline as a jsonb array on + * `company_profiles.business_license_files`. They now belong to the FileRecord + * model (`freight.files`, resource `company_profiles`, code `business_license`) + * so they get stable ids and stream through `GET /api/files/:id` — the same + * proxy path regular documents use — instead of broken direct-MinIO URLs. + * + * This copies each existing inline entry into `freight.files` by reference + * (keeping the stored object URL — no bytes are re-uploaded). The original jsonb + * column is left intact for rollback safety. + */ +export class MigrateLicenseFilesToFileRecords2040000000000 + implements MigrationInterface +{ + name = "MigrateLicenseFilesToFileRecords2040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.files + (id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at) + SELECT + gen_random_uuid(), + cp.id, + 'company_profiles', + 'business_license', + COALESCE(elem->>'name', 'license'), + elem->>'url', + COALESCE(NULLIF(elem->>'size', '')::int, 0), + COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'), + now(), + now() + FROM freight.company_profiles cp + CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem + WHERE cp.business_license_files IS NOT NULL + AND jsonb_typeof(cp.business_license_files) = 'array' + AND elem->>'url' IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.files f + WHERE f.resource_id = cp.id + AND f.resource = 'company_profiles' + AND f.code = 'business_license' + AND f.url = elem->>'url' + AND f.deleted_at IS NULL + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse the model migration by dropping the license FileRecords. The + // original jsonb column was never cleared, so the data still exists there. + await queryRunner.query(` + DELETE FROM freight.files + WHERE resource = 'company_profiles' + AND code = 'business_license'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts new file mode 100644 index 000000000..332df2423 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds customer_truck_containers.loaded_at so an assignment (customer planning + * which containers ride which truck) is distinct from the container actually + * being loaded. Stage LOADED now requires loaded_at; customer assignment alone + * keeps the container at its prior stage (RECEIVED/GRN) with its planned truck + * shown. Backfills containers on already-departed trucks (they left loaded). + */ +export class AddCustomerTruckContainerLoadedAt2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.customer_truck_containers ctc + SET loaded_at = a.departed_at + FROM freight.customer_truck_assignments a + WHERE a.id = ctc.assignment_id + AND a.departed_at IS NOT NULL + AND ctc.deleted_at IS NULL + AND ctc.loaded_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..f556751fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -81,6 +83,60 @@ import { hasFreightPermission, } from "../../common/freight-permission.util"; +interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} + +interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} + +/** Trim a first/last-mile record down to a customer-safe operational summary. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeMileLeg(rec?: Record): MileLegSummary | null { + if (!rec) return null; + const num = (v: unknown) => (v == null ? null : Number(v)); + const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any + const currency = + rec.vehicle?.currency ?? + assignments[0]?.vehicle?.currency ?? + rec.booking?.paymentCurrency ?? + 'ETB'; + const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ + plate: a.vehicle?.plateNumber ?? null, + code: a.vehicle?.code ?? null, + driverName: a.vehicle?.assignedDriverName ?? null, + containerNumber: a.containerNumber ?? null, + distanceKm: num(a.distanceKm), + })); + if (!vehicles.length && rec.vehicle) { + vehicles.push({ + plate: rec.vehicle.plateNumber ?? null, + code: rec.vehicle.code ?? null, + driverName: rec.vehicle.assignedDriverName ?? null, + containerNumber: null, + distanceKm: num(rec.exactKm), + }); + } + return { + status: rec.status ?? '', + exactKm: num(rec.exactKm), + remainingPayment: num(rec.remainingPayment), + currency, + invoiced: Boolean(rec.invoice), + vehicles, + }; +} + @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() @@ -94,6 +150,8 @@ export class BookingsController { private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, + private readonly firstMileService: FirstMileService, + private readonly lastMileService: LastMileService, ) {} @Post() @@ -290,6 +348,33 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/mile-summary') + @ApiOperation({ + summary: 'First/last-mile operational summary for a booking (customer-safe)', + }) + async mileSummary( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Customers may only see their own booking's mile summary. + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + + const [first, last] = await Promise.all([ + this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), + this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), + ]); + return { + firstMile: summarizeMileLeg(first.data[0]), + lastMile: summarizeMileLeg(last.data[0]), + }; + } + @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( @@ -350,6 +435,21 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Patch(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + async updateCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.updateTruck(id, assignmentId, dto); + } + @Delete(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 48c5b628b..4b3e3bcca 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentController } from './booking-payment.controller'; @@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), + forwardRef(() => LastMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), forwardRef(() => ContractsModule), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a5f25ad21..48a29988a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1424,6 +1424,20 @@ export class BookingsService { schedule?.status ?? null; } + // A generated-but-unsigned SELF_HAUL handover means the customer must approve + // delivery from the portal (booking-based, one per booking). EDR last-mile + // handovers are per delivering truck and signed by the receiver at the door, + // so they never surface the portal "Approve delivery" action. + const [pendingHandover] = await this.dataSource.query( + `SELECT 1 FROM freight.booking_handovers + WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' + LIMIT 1`, + [id], + ); + (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + Boolean(pendingHandover); + return booking; } diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 6f0ec6f55..4e364a03a 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -42,21 +42,30 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - const isExport = booking.tradeDirection === 'EXPORT'; const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are - // not pre-specified — they are registered + weighed when the truck leaves. - if (isExport) { - if (requested.length < 1 || requested.length > 2) { - throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); - } - } else if (requested.length > 2) { + // Both import and export specify the containers each truck carries. Capacity + // is size-based: a 40ft container fills the truck (max 1); two 20ft containers + // fit (max 2), no size mixing. #trucks <= #containers follows naturally since + // each container is assigned to exactly one truck. + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { throw new BadRequestException('A truck carries at most 2 containers'); } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); + // Never assign more trucks than the booking has containers. + const existingTrucks = await this.dataSource + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (existingTrucks + 1 > bookingNumbers.length) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, + ); + } for (const n of requested) { if (!bookingNumbers.includes(n)) { throw new BadRequestException(`Container ${n} is not one of this booking's containers`); @@ -68,6 +77,13 @@ export class CustomerTruckService { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } + // Size cap: a 40ft container fills the truck. + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } } await this.dataSource.transaction(async (manager) => { @@ -133,6 +149,76 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Edit a truck assignment — plate/driver/type and the containers it carries. + * Allowed only until the truck has arrived (same guard as removal). Container + * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already + * on another truck, and a 40ft container fills the truck (max 1). + */ + async updateTruck( + bookingId: string, + assignmentId: string, + dto: AddCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot edit a truck that has already arrived'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + // Exclude THIS truck's own containers so re-saving the same set is allowed. + const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (assignedElsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }); + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + }); + + return this.listTrucks(bookingId); + } + /** * Register an IMPORT self-haul truck leaving the port: the containers it * actually loaded (replacing any provisional list) and its weighed gross. @@ -226,6 +312,13 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } + // Containers can only be loaded after the truck has physically arrived at the + // warehouse (arrival weighing recorded). Assignment alone is just planning. + if (!assignment.arrivedAt) { + throw new BadRequestException( + 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', + ); + } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { @@ -244,30 +337,35 @@ export class CustomerTruckService { } } - const grossKg = await this.vgmKgForContainers(bookingId, requested); + const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + // Operator loading the truck: stamp loaded_at so these containers move to + // the LOADED stage (customer assignment alone leaves loaded_at null). + const loadedAt = new Date(); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, + loadedAt, }), ), ); - // Provisional gross from the loaded containers' VGM — overridden by the - // weighed gross on departure. + // Provisional gross (tonnes) from the loaded containers' VGM — overridden + // by the weighed gross on departure. (Column is *_kg but holds tonnes.) await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { - grossWeightKg: grossKg, + grossWeightKg: grossTons, }); }); return this.listTrucks(bookingId); } - private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { - const [row]: Array<{ kg: string }> = await this.dataSource.query( - `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + /** Summed VGM (tonnes) of the given containers — provisional truck gross. */ + private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ tons: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -276,7 +374,7 @@ export class CustomerTruckService { AND bcu.deleted_at IS NULL`, [bookingId, numbers], ); - return Number(row?.kg ?? 0); + return Number(row?.tons ?? 0); } /** @@ -399,4 +497,20 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await this.dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((r) => (r.size ?? '').trim()); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts index 110e31671..8b6ecc8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64 }) containerNumber!: string; + + /** + * When the container was actually loaded onto the truck by the operator. + * Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires + * this to be set, so customer assignment alone does not mark a container loaded. + */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; } 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 b1761b35f..f8fbb26b0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -12,6 +12,7 @@ import { HttpStatus, UseInterceptors, UploadedFiles, + BadRequestException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; @@ -33,7 +34,7 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { BusinessLicenseFile } from "./entities/company-profile.entity"; +import { ProfileLicenseFileView } from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; +import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -61,6 +64,25 @@ export class CompaniesController { private readonly filesService: FilesService, ) { } + /** + * License files are FileRecord-backed and previewed through `GET /api/files/:id` + * (the client builds that URL from the returned `id`). Populate each profile + * DTO's `licenseFiles` with its live/pending files in one batched lookup. + */ + private async populateLicenseFiles( + companyId: string, + profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[], + ): Promise { + if (profiles.length === 0) return; + const byProfile = await this.companiesService.assembleLicenseFilesByProfile( + companyId, + profiles.map((p) => p.id), + ); + for (const p of profiles) { + p.licenseFiles = byProfile[p.id] ?? []; + } + } + @Get("getInfo") @ApiOperation({ summary: "Get company info for the current user" }) async getInfo( @@ -68,7 +90,10 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new CompanyInfoResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return new CompanyInfoResponseDto(profile, company, review); } @Get("profile") @@ -78,7 +103,42 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new ProfileResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + const dto = new ProfileResponseDto(profile, company, review); + await this.populateLicenseFiles(company.id, dto.companyProfiles); + return dto; + } + + @Get("profile/change-request") + @ApiOperation({ + summary: "Current user's open profile change request (pending/rejected)", + }) + async getMyChangeRequest( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { company } = + await this.companiesService.getCompanyInfoByUserId(user.id); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return review ? new ChangeRequestResponseDto(review) : null; + } + + @Post("company-profiles/:profileId/reapply") + @ApiOperation({ + summary: "Resubmit a rejected operational role for approval (→ pending)", + }) + async reapplyCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + ): Promise { + const profile = await this.companiesService.reapplyCompanyProfile( + user.id, + profileId, + ); + return new ResponseCompanyProfileDto(profile); } @Get("dashboard") @@ -177,28 +237,72 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Upload business-license document(s) for one of the current user's company profiles", + "Add business-license document(s) to a profile. For an approved company " + + "the upload is staged for backoffice review; during onboarding it goes live.", }) async uploadProfileLicense( @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, @UploadedFiles() files: Array, - ): Promise { - return this.companiesService.uploadProfileLicenseFiles( + ): Promise { + return this.companiesService.addProfileLicenseFiles( user.id, profileId, files, ); } + @Post("company-profiles/:profileId/license/:fileId/replace") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Replace a business-license file with a newly uploaded one (staged for " + + "review on an approved company).", + }) + async replaceProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + @UploadedFiles() files: Array, + ): Promise { + const file = files?.[0]; + if (!file) { + throw new BadRequestException("A replacement file is required"); + } + return this.companiesService.replaceProfileLicenseFile( + user.id, + profileId, + fileId, + file, + ); + } + + @Delete("company-profiles/:profileId/license/:fileId") + @ApiOperation({ + summary: + "Remove a business-license file (staged for review on an approved company).", + }) + async removeProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ): Promise { + return this.companiesService.removeProfileLicenseFile( + user.id, + profileId, + fileId, + ); + } + @Get("company-profiles/:profileId/license") @ApiOperation({ - summary: "List business-license documents for a company profile", + summary: "List business-license documents (with review state) for a profile", }) async listProfileLicense( @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, - ): Promise { + ): Promise { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } @@ -306,7 +410,9 @@ export class CompaniesController { @Param("id", ParseUUIDPipe) id: string, ): Promise { const company = await this.companiesService.findCompanyById(id); - return new ResponseCompanyDto(company); + const dto = new ResponseCompanyDto(company); + await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []); + return dto; } @Patch(":id") @@ -354,26 +460,76 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( + @CurrentUser() user: CurrentIamUser, @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, "companies", files); + // Routed through the service so an approved company's uploads are staged for + // review (and lock the customer), while onboarding uploads pass straight through. + return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } @Patch("company-profiles/:profileId/status") @FreightAdmin() @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( + @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, + dto.note, + user.id, ); return new ResponseCompanyProfileDto(profile); } + @Get(":companyId/change-requests") + @FreightAdmin() + @ApiOperation({ summary: "List a company's profile change requests" }) + async listChangeRequests( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const requests = await this.companiesService.listChangeRequests(companyId); + return requests.map((r) => new ChangeRequestResponseDto(r)); + } + + @Post("change-requests/:id/approve") + @FreightAdmin() + @ApiOperation({ + summary: "Approve a pending profile change request (applies the changes)", + }) + async approveChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + const request = await this.companiesService.approveChangeRequest( + id, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + + @Post("change-requests/:id/reject") + @FreightAdmin() + @ApiOperation({ + summary: "Reject a pending profile change request with a note", + }) + async rejectChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectChangeRequestDto, + ): Promise { + const request = await this.companiesService.rejectChangeRequest( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @FreightAdmin() @ApiOperation({ summary: "Add a profile (employee) to a company" }) 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 42186dd8e..646d01d47 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository"; 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 { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; @Module({ imports: [ - TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + TypeOrmModule.forFeature([ + Company, + ExternalProfile, + CompanyProfile, + CompanyChangeRequest, + Booking, + ]), HttpModule, FilesModule, FileUploadSettingsModule, @@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service"; CompaniesRepository, ExternalProfileRepository, CompanyProfileRepository, + CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, ], 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 fe679627b..e31851aef 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,13 +7,14 @@ import { } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, DashboardScope, } from "./company-dashboard.repository"; -import { MinioService } from "../minio/minio.service"; import { FilesService } from "../files/files.service"; +import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; @@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, ProfileType, ProfileStatus, } from "./entities/company-profile.entity"; +import { + ChangeRequestStatus, + CompanyChangeRequest, + LicenseChangeIntent, +} from "./entities/company-change-request.entity"; + +/** FileRecord `resource` + `code` slots for business-license documents. */ +const LICENSE_RESOURCE = "company_profiles"; +const LICENSE_CODE = "business_license"; +/** Code for a license file staged in an open change request (not yet live). */ +const LICENSE_PENDING_CODE = "business_license_pending"; export interface UserIdentity { userId: string; @@ -54,9 +67,9 @@ export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, + private readonly changeRequestRepo: CompanyChangeRequestRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, - private readonly minioService: MinioService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, @@ -334,28 +347,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); - for (const profile of company.companyProfiles) { - profile.businessLicenseFiles = await this.signLicenseFiles( - profile.businessLicenseFiles, - ); - } return company; } - /** - * Business-license files are stored as raw, unsigned MinIO URLs (see - * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them - * directly. Sign each one with a short-lived URL before it reaches a response. - */ - private async signLicenseFiles( - files?: BusinessLicenseFile[] | null, - ): Promise { - if (!files?.length) return []; - return Promise.all( - files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), - ); - } - /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff @@ -574,12 +568,24 @@ export class CompaniesService { return updated; } - async updateProfile( - userId: string, - dto: UpdateProfileDto, - ): Promise { - const { profile, company } = await this.getCompanyInfoByUserId(userId); + /** Keep only the keys that were actually provided (drop `undefined`). */ + private pickDefined(dto: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(dto)) { + if (v !== undefined) out[k] = v; + } + return out; + } + /** + * Translate an UpdateProfileDto (or a staged change-request snapshot) into a + * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ + * PoA live there). Pure — the caller runs the async TIN-uniqueness check. + */ + private mapProfileDtoToCompanyUpdates( + company: Company, + dto: Partial, + ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -593,21 +599,10 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined && dto.tin !== company.tin) { - // Reject a TIN already taken by a different company (the user's own draft - // placeholder is fine to overwrite). - const owner = await this.companiesRepo.findByTin(dto.tin); - if (owner && owner.id !== company.id) { - throw new ConflictException( - `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, - ); - } + if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; - } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) { - companyUpdates.fanNumber = dto.fanNumber; - } + if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -629,8 +624,7 @@ export class CompaniesService { if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) - attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.licenceNumber !== undefined) @@ -653,11 +647,219 @@ export class CompaniesService { companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; + return companyUpdates; + } - const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - return new ProfileResponseDto(profile, updated); + /** Reject a TIN already registered to a *different* company. */ + private async assertTinAvailable( + company: Company, + tin: string | undefined, + ): Promise { + if (tin === undefined || tin === company.tin) return; + const owner = await this.companiesRepo.findByTin(tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `This TIN (${tin}) is already registered to another company. Please check the number and try again.`, + ); + } + } + + /** The company's open (pending or last-rejected) profile change request. */ + async getOpenChangeRequestForCompany( + companyId: string, + ): Promise { + return this.changeRequestRepo.findLatestOpenByCompanyId(companyId); + } + + /** + * Update the current user's profile. + * + * - Company not yet approved (onboarding) → write straight to the Company row, + * as before. The company/role pending→approve gate already covers first-run. + * - Company already `active` → do NOT touch the live Company. Stage the edit in + * a pending change request (merging into any open one) so a backoffice + * reviewer can approve (apply) or reject (with a note). This locks the + * customer until the review resolves. + */ + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + if (company.status !== CompanyStatus.Active) { + await this.assertTinAvailable(company, dto.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); + const updated = await this.companiesRepo.update( + company.id, + companyUpdates, + ); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + + // Approved company: stage the change for review, leaving the live row intact. + await this.assertTinAvailable(company, dto.tin); + const fields = this.pickDefined(dto); + + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + let request: CompanyChangeRequest; + if (existing) { + request = + (await this.changeRequestRepo.update(existing.id, { + snapshot: { ...(existing.snapshot ?? {}), ...fields }, + submittedBy: userId, + submittedAt: now, + note: null, + })) ?? existing; + } else { + request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot: fields, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + } + + // Live company is unchanged; surface the pending state for the settings page. + return new ProfileResponseDto(profile, company, request); + } + + /** List a company's change requests, newest first (backoffice review). */ + async listChangeRequests( + companyId: string, + ): Promise { + await this.findCompanyById(companyId); + return this.changeRequestRepo.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 + * company, so nothing else needs promoting. + */ + async approveChangeRequest( + id: 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}`, + ); + } + + const company = await this.companiesRepo.findById(request.companyId); + if (!company) + throw new NotFoundException(`Company ${request.companyId} not found`); + + const snapshot = (request.snapshot ?? {}) as Partial; + await this.assertTinAvailable(company, snapshot.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); + await this.companiesRepo.update(company.id, companyUpdates); + await this.applyLicenseChanges(request); + + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Approved, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + note: null, + })) ?? request + ); + } + + /** + * 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. + */ + async uploadCompanyDocuments( + companyId: string, + files: Express.Multer.File[], + submittedBy?: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const uploaded = await this.filesService.uploadMany( + companyId, + "companies", + files, + ); + if (company.status === CompanyStatus.Active) { + await this.stageDocumentChange( + company.id, + uploaded.map((f) => f.id), + submittedBy, + ); + } + return uploaded; + } + + /** Open or append a pending change request recording staged document uploads. */ + private async stageDocumentChange( + companyId: string, + fileIds: string[], + submittedBy?: string, + ): Promise { + if (fileIds.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentFileIds ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { documentFileIds: [...prev, ...fileIds] }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentFileIds: fileIds }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** Reject a pending change request with a note (customer amends & resubmits). */ + async rejectChangeRequest( + 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}`, + ); + } + await this.discardLicenseChanges(request); + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Rejected, + // Staged license uploads were just discarded; drop their intents so an + // amended resubmit never re-references deleted files. + documents: { ...request.documents, licenseChanges: [] }, + note, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + })) ?? request + ); } async deleteCompany(id: string): Promise { @@ -713,6 +915,8 @@ export class CompaniesService { async setCompanyProfileStatus( profileId: string, status: ProfileStatus, + note?: string, + reviewerId?: string, ): Promise { const existing = await this.companyProfilesRepo.findById(profileId); if (!existing) @@ -727,6 +931,18 @@ export class CompaniesService { ); } + // Track the review outcome. Rejection keeps the note so the customer knows + // why; approval clears it. Any decision stamps the reviewer + time. + if (status === ProfileStatus.Rejected) { + patch.reviewNote = note ?? null; + } else if (status === ProfileStatus.Active) { + patch.reviewNote = null; + } + if (status !== ProfileStatus.Pending) { + patch.reviewedBy = reviewerId ?? null; + patch.reviewedAt = new Date(); + } + const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); @@ -744,6 +960,41 @@ export class CompaniesService { return updated; } + /** + * Customer reapplies for a rejected operational role (after fixing whatever the + * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and + * clear the rejection note so it re-enters the approval queue. + */ + async reapplyCompanyProfile( + userId: string, + profileId: string, + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + const companyId = profile.company?.id ?? profile.companyId; + + const target = await this.companyProfilesRepo.findById(profileId); + if (!target || target.companyId !== companyId) { + throw new NotFoundException(`Company profile ${profileId} not found`); + } + if (target.status !== ProfileStatus.Rejected) { + throw new BadRequestException( + "Only a rejected role can be resubmitted for approval", + ); + } + + const updated = await this.companyProfilesRepo.update(profileId, { + status: ProfileStatus.Pending, + reviewNote: null, + reviewedBy: null, + reviewedAt: null, + }); + if (!updated) + throw new NotFoundException(`Company profile ${profileId} not found`); + return updated; + } + async createCompanyProfile( companyId: string, profileType?: ProfileType, @@ -833,12 +1084,12 @@ export class CompaniesService { ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference(type); + // Self-service role adds start Pending and carry no reference — a reference + // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -870,13 +1121,14 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { - const reference = await this.companyProfilesRepo.generateReference(type); + // New self-service roles start Pending (awaiting backoffice approval) and + // carry no reference until approved. The customer can select this mode but + // can't book under it until it's cleared. created = await this.companyProfilesRepo.create({ companyId, type, - reference, businessLicense: businessLicense ?? null, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -971,13 +1223,21 @@ export class CompaniesService { })); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); - // 3. Per-operational-profile business licenses. - const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ - profileId: p.id, - type: p.type, - reference: p.reference ?? "", - uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, - })); + // 3. Per-operational-profile business licenses (FileRecord-backed). + const licenseProfiles = await Promise.all( + (company.companyProfiles ?? []).map(async (p) => { + const records = await this.filesService.findByResource( + p.id, + LICENSE_RESOURCE, + ); + return { + profileId: p.id, + type: p.type, + reference: p.reference ?? "", + uploaded: records.some((r) => r.code === LICENSE_CODE), + }; + }), + ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); const outstanding = [ @@ -1094,61 +1354,321 @@ export class CompaniesService { return owned; } + // ─── Business-license files ──────────────────────────────────────────────── + // + // License documents live in the FileRecord model (`freight.files`) with + // `resource = "company_profiles"`, `resourceId = `. Live files use + // code `LICENSE_CODE`; files staged inside an open change request (add / + // replacement) use `LICENSE_PENDING_CODE` and only become live on approval. + // Preview streams through `GET /api/files/:id` (server-side proxy) — the same + // path regular documents use — so it never hits MinIO directly from the + // browser (which fails on the internal bucket endpoint). + /** - * Upload business-license document(s) and store them directly on the company - * profile (multi-file). Bytes go to object storage; only metadata/URLs are - * persisted on the profile — intentionally not via the FileRecord file model. - * New files are appended to any already present. Returns the full list. + * Upload business-license file(s) for one of the user's profiles. During + * onboarding (company not yet Active) they go live immediately; for an Active + * company they're staged under the pending code and recorded as `add` intents + * on a pending change request for backoffice review. Returns the updated view. */ - async uploadProfileLicenseFiles( + async addProfileLicenseFiles( userId: string, profileId: string, files: Express.Multer.File[], - ): Promise { + ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; - const uploaded: BusinessLicenseFile[] = []; - for (const file of files) { - const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; - const url = await this.minioService.uploadFile( - objectName, - file.buffer, - file.mimetype, + const uploaded = await Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code, + file, + }), + ), + ); + + if (gated) { + await this.stageLicenseChange( + company.id, + uploaded.map((r) => ({ + profileId, + op: "add" as const, + fileId: r.id, + fileName: r.name, + })), + userId, ); - uploaded.push({ - name: file.originalname, - url, - size: file.size, - mimeType: file.mimetype, - }); } - const next = [...(profile.businessLicenseFiles ?? []), ...uploaded]; - await this.companyProfilesRepo.update(profileId, { - businessLicenseFiles: next, - }); - return next; - } - - /** The business-license files stored on a single company profile. */ - async listProfileLicenseFiles( - userId: string, - profileId: string, - ): Promise { - const profile = await this.resolveOwnedProfile(userId, profileId); - return profile.businessLicenseFiles ?? []; + return this.getProfileLicenseView(profileId, company.id); } /** - * Onboarding documents stored on a company profile, fetched by profile id. - * Internal helper (no ownership check) used when a booking reuses the active - * profile's onboarding documents. Returns [] when the profile is unknown. + * Remove a license file. A staged (pending) file is withdrawn outright + * (soft-deleted, its `add` intent dropped). A live file on an Active company + * is kept and recorded as a `remove` intent for review; during onboarding it + * is deleted immediately. + */ + async removeProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== LICENSE_RESOURCE || + record.resourceId !== profileId + ) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + if (record.code === LICENSE_PENDING_CODE) { + // Withdraw a not-yet-approved upload: delete it and drop its add intent. + await this.filesService.remove(fileId); + await this.withdrawLicenseIntent(company.id, fileId); + } else if (gated) { + await this.stageLicenseChange( + company.id, + [{ profileId, op: "remove", fileId, fileName: record.name }], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** + * Replace a live license file with a freshly uploaded one — recorded as a + * `remove` of the old file plus an `add` of the new, so approval swaps them + * atomically. During onboarding the swap is applied immediately. + */ + async replaceProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + file: Express.Multer.File, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const old = await this.filesService.findById(fileId); + if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + const created = await this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE, + file, + }); + + if (gated) { + await this.stageLicenseChange( + company.id, + [ + { profileId, op: "remove", fileId, fileName: old.name }, + { profileId, op: "add", fileId: created.id, fileName: created.name }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** License files for one profile, with each file's review status resolved. */ + async listProfileLicenseFiles( + userId: string, + profileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + return this.getProfileLicenseView(profileId, profile.companyId); + } + + /** + * Live license files for a profile, shaped for by-reference reuse (bookings / + * contracts snapshot these). No ownership check — internal callers only. + * Returns the raw stored URLs; pending (unapproved) files are excluded. */ async getProfileOnboardingFiles( profileId: string, ): Promise { - const profile = await this.companyProfilesRepo.findById(profileId); - return profile?.businessLicenseFiles ?? []; + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter((r) => r.code === LICENSE_CODE) + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); + } + + /** + * Assemble the review-aware license view for a set of profiles in one pass + * (single change-request lookup). Used to enrich company/profile responses. + */ + async assembleLicenseFilesByProfile( + companyId: string, + profileIds: string[], + ): Promise> { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const result: Record = {}; + await Promise.all( + profileIds.map(async (pid) => { + result[pid] = await this.mapLicenseRecords(pid, removeIds); + }), + ); + return result; + } + + /** Single-profile license view (fetches the company's pending request once). */ + private async getProfileLicenseView( + profileId: string, + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + return this.mapLicenseRecords(profileId, removeIds); + } + + private async mapLicenseRecords( + profileId: string, + pendingRemoveIds: Set, + ): Promise { + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter( + (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === LICENSE_PENDING_CODE + ? ("pending_add" as const) + : pendingRemoveIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording license add/remove intents. */ + private async stageLicenseChange( + companyId: string, + changes: LicenseChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.licenseChanges ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + licenseChanges: [...prev, ...changes], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { licenseChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged license intent (add or remove) referencing `fileId` from the + * company's open request. If that empties the request entirely, delete it so + * the customer's settings page unlocks. + */ + private async withdrawLicenseIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.licenseChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, licenseChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged license changes: promote adds, delete removes. */ + private async applyLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, LICENSE_CODE); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged license uploads (adds only). */ + private async discardLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } } /** 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 new file mode 100644 index 000000000..24d988452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +@Injectable() +export class CompanyChangeRequestRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyChangeRequest) + repo: Repository, + ) { + super(repo); + } + + /** The company's current pending request, if any. */ + async findPendingByCompanyId( + companyId: string, + ): Promise { + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Pending }, + order: { createdAt: "DESC" }, + }); + } + + /** + * The company's latest "open" request — pending (locks the customer) or the + * most recent rejected one (drives the reapply banner + prefill). Approved + * requests are terminal and ignored here. + */ + async findLatestOpenByCompanyId( + companyId: string, + ): Promise { + const pending = await this.findPendingByCompanyId(companyId); + if (pending) return pending; + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Rejected }, + order: { createdAt: "DESC" }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + 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/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts new file mode 100644 index 000000000..579ac6ddd --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -0,0 +1,44 @@ +import { + ChangeRequestStatus, + CompanyChangeRequest, + LicenseChangeIntent, +} from "../entities/company-change-request.entity"; + +/** + * A staged profile change request. Used both by the portal (to lock the settings + * page, show the reviewer note, and prefill the proposed values) and by the + * backoffice review screen (to render the proposed-vs-current diff). + */ +export class ChangeRequestResponseDto { + id: string; + companyId: string; + status: ChangeRequestStatus; + /** Proposed field values (Partial) — the diff payload. */ + snapshot: Record; + documentFileIds: string[]; + /** Staged business-license add/remove intents attached to this request. */ + licenseChanges: LicenseChangeIntent[]; + note: string | null; + submittedBy: string | null; + submittedAt: Date | null; + reviewedBy: string | null; + reviewedAt: Date | null; + createdAt: Date; + updatedAt: Date; + + constructor(req: CompanyChangeRequest) { + this.id = req.id; + this.companyId = req.companyId; + this.status = req.status; + this.snapshot = req.snapshot ?? {}; + this.documentFileIds = req.documents?.documentFileIds ?? []; + this.licenseChanges = req.documents?.licenseChanges ?? []; + this.note = req.note ?? null; + this.submittedBy = req.submittedBy ?? null; + this.submittedAt = req.submittedAt ?? null; + this.reviewedBy = req.reviewedBy ?? null; + this.reviewedAt = req.reviewedAt ?? null; + this.createdAt = req.createdAt; + this.updatedAt = req.updatedAt; + } +} 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 04fd42816..9a4fb330a 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 @@ -1,14 +1,43 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyDto } from './response-company.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; export class CompanyInfoResponseDto { profile: ResponseExternalProfileDto; company: ResponseCompanyDto; + /** + * Open profile-edit review, if any. Drives the portal-wide lock (pending → + * settings + new-contract/booking creation disabled) and the reapply banner. + */ + review: { + status: 'pending' | 'rejected'; + note: string | null; + } | null; - constructor(profile: ExternalProfile, company: Company) { + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); + + const open = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.review = open + ? { + status: open.status as 'pending' | 'rejected', + 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 89a52b5e6..89ab954e7 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 @@ -1,5 +1,9 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { @@ -48,7 +52,20 @@ export class ProfileResponseDto { profileId: string; - constructor(profile: ExternalProfile, company: Company) { + /** + * 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. + */ + reviewStatus: "pending" | "rejected" | null; + reviewNote: string | null; + pendingChanges: Record | null; + + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.companyId = company.id; this.companyName = company.name; this.companyType = company.type; @@ -92,5 +109,20 @@ export class ProfileResponseDto { this.poaEmail = attrs.poaEmail ?? null; this.poaLocation = attrs.poaLocation ?? null; this.poaAddress = attrs.poaAddress ?? null; + + const openReview = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.reviewStatus = + openReview?.status === ChangeRequestStatus.Pending + ? "pending" + : openReview?.status === ChangeRequestStatus.Rejected + ? "rejected" + : null; + this.reviewNote = openReview?.note ?? null; + this.pendingChanges = openReview?.snapshot ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts new file mode 100644 index 000000000..c32b44a79 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RejectChangeRequestDto { + /** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} 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 5d90d8d60..0c783cbcf 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 @@ -5,8 +5,8 @@ import { CompanyNationality, } from '../entities/company.entity'; import { - BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; @@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto { status: string; /** @deprecated Superseded by licenseFiles. Kept for back-compat. */ businessLicense?: string | null; - /** Business-license documents stored on the profile (multi-file). */ - licenseFiles: BusinessLicenseFile[]; + /** + * Business-license documents (FileRecord-backed) with review state. Left empty + * by the constructor and populated asynchronously by the controller, since the + * files and their pending-change status require DB lookups. + */ + licenseFiles: ProfileLicenseFileView[]; attributes?: Record | null; + /** Reviewer note when the role is rejected (drives the reapply prompt). */ + reviewNote?: string | null; createdAt: Date; updatedAt: Date; @@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto { this.reference = profile.reference ?? ''; this.status = profile.status; this.businessLicense = profile.businessLicense; - this.licenseFiles = profile.businessLicenseFiles ?? []; + this.licenseFiles = []; this.attributes = profile.attributes; + this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts index 96c02d846..83beb441f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts @@ -1,9 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; import { ProfileStatus } from "../entities/company-profile.entity"; export class UpdateCompanyProfileStatusDto { @ApiProperty({ enum: ProfileStatus }) @IsIn(Object.values(ProfileStatus)) status!: ProfileStatus; + + /** Reviewer note — required in practice when rejecting so the customer knows why. */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; } 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 new file mode 100644 index 000000000..cec670787 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -0,0 +1,87 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +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. + */ +export enum ChangeRequestStatus { + Pending = "pending", + Approved = "approved", + Rejected = "rejected", +} + +/** + * A single staged business-license change on one company profile, awaiting + * review. `add` → a new file was uploaded under the pending code and becomes + * live on approval; `remove` → an existing live file is deleted on approval. + * A "replace" is recorded as a `remove` of the old file plus an `add` of the + * new one. `fileId` is the FileRecord id the op targets. + */ +export interface LicenseChangeIntent { + profileId: string; + op: "add" | "remove"; + fileId: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + +/** File references staged alongside a change request (documents/licenses). */ +export interface ChangeRequestDocuments { + /** FileRecord ids uploaded against the company while this request was open. */ + documentFileIds?: string[]; + /** Staged per-profile business-license add/remove intents. */ + licenseChanges?: LicenseChangeIntent[]; +} + +@Entity({ schema: "freight", name: "company_change_request" }) +@Index(["companyId"]) +@Index(["status"]) +export class CompanyChangeRequest extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** + * Proposed profile field values, shaped as `Partial`. Covers + * the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/ + * PoA fields land in `Company.attributes` on approval). + */ + @Column({ name: "snapshot", type: "jsonb" }) + snapshot!: Record; + + /** Staged document/license file references (see {@link ChangeRequestDocuments}). */ + @Column({ name: "documents", type: "jsonb", nullable: true }) + documents?: ChangeRequestDocuments | null; + + @Column({ + name: "status", + type: "varchar", + length: 20, + default: ChangeRequestStatus.Pending, + }) + status!: ChangeRequestStatus; + + /** Backoffice reviewer's rejection note. */ + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; + + @Column({ name: "submitted_by", type: "uuid", nullable: true }) + submittedBy?: string | null; + + @Column({ name: "submitted_at", type: "timestamptz", nullable: true }) + submittedAt?: Date | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index e61668a07..72696766f 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -13,11 +13,17 @@ export enum ProfileType { export enum ProfileStatus { Active = "active", Pending = "pending", + /** Reviewer declined the role; carries a note. Customer can reapply → Pending. */ + Rejected = "rejected", Suspended = "suspended", Blacklisted = "blacklisted", } -/** A business-license document stored directly on the company profile. */ +/** + * @deprecated Legacy inline shape. Business-license files now live in the + * FileRecord model (`freight.files`, resource `company_profiles`). Kept only for + * the by-reference reuse shape consumed by bookings/contracts snapshots. + */ export interface BusinessLicenseFile { name: string; url: string; @@ -25,6 +31,19 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** A business-license file plus its change-review state, surfaced to clients. */ +export interface ProfileLicenseFileView { + id: string; + name: string; + size: number; + mimeType: string; + /** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ + status: "live" | "pending_add" | "pending_remove"; +} + @Entity({ schema: "freight", name: "company_profiles" }) @Index(["reference"], { unique: true }) @Index(["type"]) @@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; + + /** Reviewer's note when the role is Rejected (cleared on reapply). */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote?: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index aaf064bff..095857959 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -326,10 +326,20 @@ export class ContractsService { companyProfileId: string | null, ): Promise { if (!companyProfileId) return; - const profile = await this.dataSource - .getRepository(CompanyProfile) - .findOne({ where: { id: companyProfileId } }); - const docs = profile?.businessLicenseFiles ?? []; + // Business-license files are FileRecords (resource "company_profiles"); carry + // the live ones by reference. Staged/pending uploads are excluded by code. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const docs = records + .filter((r) => r.code === 'business_license') + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); if (docs.length === 0) return; const slug = (name: string) => diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index e3130da95..f220cae0d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -9,14 +9,19 @@ import { IsOptional, IsString, IsUUID, + Matches, Min, ValidateNested, } from 'class-validator'; /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { - @ApiProperty() + @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value)) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumber must match ISO container format, e.g. ABCD1234567', + }) containerNumber!: string; @ApiPropertyOptional() 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 4966d7ff9..3fd0752f7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -124,6 +124,15 @@ export class FilesService { await this.filesRepository.softDelete(id); } + /** + * Re-slot a stored file under a new `code` (e.g. promote a staged + * `business_license_pending` file to the live `business_license` code once a + * change request is approved). Bytes and URL are untouched. + */ + async setCode(id: string, code: string): Promise { + await this.filesRepository.update(id, { code }); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index e380e541e..8bd4a31d8 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -11,14 +11,15 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { GpsTrackingService } from './gps-tracking.service'; import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@FleetView() +@BookingStaff(FREIGHT_PERMS.tracking.view) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} @@ -44,21 +45,21 @@ export class GpsTrackingController { } @Post('devices') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Register a GPS tracker' }) register(@Body() dto: RegisterDeviceDto) { return this.gps.registerDevice(dto); } @Patch('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { return this.gps.updateDevice(id, dto); } @Delete('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Delete a GPS tracker' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.gps.removeDevice(id); diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts new file mode 100644 index 000000000..9d7f32c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -0,0 +1,37 @@ +import { DataSource } from 'typeorm'; + +import { NotificationsService } from './notifications.service'; + +/** + * Best-effort SMS + email fan-out to a company's contacts. Looks up the + * company's phone/email and sends the message over both channels, swallowing + * per-channel failures so a missing provider never breaks the caller's flow. + */ +export async function sendCompanyChannels( + dataSource: DataSource, + notifications: NotificationsService, + companyId: string, + message: string, +): Promise { + const [contact]: Array<{ phone: string | null; email: string | null }> = + await dataSource.query( + `SELECT COALESCE(phone, etrade_phone) AS phone, email + FROM freight.companies + WHERE id = $1 AND deleted_at IS NULL`, + [companyId], + ); + if (contact?.phone) { + try { + await notifications.directSend('sms', contact.phone, message); + } catch { + /* best-effort: SMS provider unavailable */ + } + } + if (contact?.email) { + try { + await notifications.directSend('email', contact.email, message); + } catch { + /* best-effort: email provider unavailable */ + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f1f63802e..48985bdf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -71,6 +71,24 @@ export class BookingNotifierService { }); } + /** Train carrying the booking departed — dispatched origin → destination. */ + dispatched(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has been dispatched` + + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; + void this.notifyContact(b, msg, 'DISPATCHED'); + this.inApp(b, 'Shipment dispatched', msg); + } + + /** Train carrying the booking arrived at destination. */ + arrived(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; + void this.notifyContact(b, msg, 'ARRIVED'); + this.inApp(b, 'Shipment arrived', msg); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 7efbceca4..b7938857f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -156,6 +156,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService + { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3c75204a6..f858c6c14 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -281,10 +282,38 @@ export class TrainSchedulingService { private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingJourneyService: BookingJourneyService, + private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Notify each booking's customer that their shipment was dispatched / arrived, + * with a deep-link to the booking. Fire-and-forget — never blocks the action. + */ + private async notifyScheduleBookings( + schedule: TrainSchedule, + event: 'dispatched' | 'arrived', + ): Promise { + try { + const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean); + if (!ids.length) return; + const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null; + const destination = + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null; + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(ids) }, + relations: { company: true }, + }); + for (const b of bookings) { + if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); + else this.bookingNotifier.arrived(b, origin, destination); + } + } catch (err) { + this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); + } + } + /** * Complete customer-tracking clearance milestones for every booking on a * schedule when a physical lifecycle event fires (dispatch, arrive, load, @@ -1551,6 +1580,7 @@ export class TrainSchedulingService { { originYardId: schedule.originStationId }, ); } + void this.notifyScheduleBookings(schedule, 'dispatched'); return this.getTrainScheduleById(scheduleId); } @@ -2556,6 +2586,7 @@ export class TrainSchedulingService { { destinationYardId: schedule.destinationStationId }, ); } + void this.notifyScheduleBookings(schedule, 'arrived'); const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index ccdda90d8..56b9d0810 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseYardDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts index fbb057fd5..eb29f751f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 5a8025948..a99ca4f46 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -47,7 +47,7 @@ export class CreateWarehouseDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) @@ -58,4 +58,9 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index 063bb7d1d..c81550cd0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,7 +6,7 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; - @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts new file mode 100644 index 000000000..08b15d536 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Optional explicit storage location. When warehouse/yard/zone are all provided, + * the item is stored there directly; otherwise store() falls back to the + * allocation-rule / capacity-balanced auto pick. + */ +export class StoreInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 290b6f0c2..a54f40973 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Handover — signature needed', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); + } catch (err) { + this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); + } + } list(bookingId: string): Promise { return this.dataSource.getRepository(BookingHandover).find({ @@ -22,6 +54,32 @@ export class HandoverService { }); } + /** + * Ask the customer to sign the booking's handover. Ensures a handover exists + * (creates a booking-level self-haul one if none yet), then fires the + * sign-needed notification (in-app + SMS + email). Idempotent to re-send. + */ + async requestSignature( + bookingId: string, + ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> { + const repo = this.dataSource.getRepository(BookingHandover); + const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); + + if (existing.length === 0) { + // No handover yet (truck not arrived): create a booking-level one so the + // customer has something to sign. ensureForArrivedTruck notifies on create. + const created = await this.ensureForArrivedTruck(bookingId, {}); + return { notified: true, reference: created.reference, alreadySigned: false }; + } + + const unsigned = existing.find((h) => !h.signedAt); + if (!unsigned) { + return { notified: false, reference: existing[0].reference, alreadySigned: true }; + } + await this.notifySignNeeded(bookingId, unsigned.reference); + return { notified: true, reference: unsigned.reference, alreadySigned: false }; + } + /** * Self-haul: ensure a handover exists for a customer truck that just arrived. * Idempotent — one per (booking, truck). Runs inside the caller's transaction @@ -54,6 +112,7 @@ export class HandoverService { }), ); this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + void this.notifySignNeeded(bookingId, reference); return saved; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index ff25258d3..b5894c21b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; + import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { + private readonly logger = new Logger(WarehouseInspectionService.name); + constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -44,7 +53,7 @@ export class WarehouseInspectionService { expectedWeight: expected, actualWeight: actual, weightLoss, - weightLossUnit: weightLoss !== null ? 'kg' : null, + weightLossUnit: weightLoss !== null ? 't' : null, hasMissingItems: dto.hasMissingItems ?? false, missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, @@ -83,8 +92,10 @@ export class WarehouseInspectionService { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", + b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -105,6 +116,36 @@ export class WarehouseInspectionService { if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); + } else if (!hasLastMile && !row.customerTruckAssignedAt) { + // Self-haul import: goods are pickup-ready but no collection truck is + // assigned yet — nudge the customer to assign one from the portal. + void this.notifyTruckAssignmentNeeded(row); + } + } + + /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ + private async notifyTruckAssignmentNeeded(row: { + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + }): Promise { + if (!row.companyId || !row.bookingId) return; + const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, + ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 84baaf4da..242cecc76 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; @@ -267,9 +268,9 @@ export class WarehouseInventoryController { } @Post(':id/store') - @ApiOperation({ summary: 'Mark received inventory as STORED' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.store(id, performedBy); + @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { + return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') @@ -354,12 +355,34 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('bookings/:bookingId/request-handover-signature') + @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) + requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.requestSignature(bookingId); + } + + @Get('bookings/:bookingId/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) + async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } + @Get('bookings/:bookingId/container-weights') + @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) + containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingContainerWeights(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index d2562fe93..9b2f53a99 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; +import { NotificationAudience, NotificationType } from '@edr/types'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -356,12 +359,14 @@ export interface ImportUnloadedRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + hasAssignedTruck: boolean; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; handoverDocumentReference: string | null; handoverDocumentDate: string | null; deliveredAt: string | null; + notes: string | null; } @Injectable() @@ -383,8 +388,41 @@ export class WarehouseInventoryService { private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, private readonly handover: HandoverService, + private readonly inbox: NotificationInboxService, ) {} + /** + * When a self-haul booking (no EDR first/last mile) is received to the warehouse + * but has no customer truck assigned yet, nudge the customer to assign one — with + * a deep-link to the booking's truck-assignment card. Fire-and-forget. + */ + private async notifyTruckAssignmentNeeded(booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }, bookingId: string): Promise { + if (!booking.companyId) return; + if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck + if (booking.customerTruckAssignedAt) return; // already assigned + const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Batch 6 — final terminal release / gate clearance. * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch @@ -866,7 +904,10 @@ export class WarehouseInventoryService { b.customer_truck_driver_name AS "customerTruckDriverName", b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt" + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -1002,6 +1043,7 @@ export class WarehouseInventoryService { result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); @@ -1301,12 +1343,18 @@ export class WarehouseInventoryService { b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", + (b.customer_truck_assigned_at IS NOT NULL + OR EXISTS (SELECT 1 FROM freight.last_mile lm + WHERE lm.booking_id = b.id + AND lm.vehicle_id IS NOT NULL + AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", + inv.notes AS "notes", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -2020,7 +2068,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -2104,13 +2152,30 @@ export class WarehouseInventoryService { // ── Lifecycle transitions ──────────────────────────────────────────────── - async store(id: string, performedBy?: string): Promise { + async store( + id: string, + performedBy?: string, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); + // Explicit location wins when the operator picked warehouse + yard + zone; + // otherwise fall back to the allocation-rule / capacity-balanced auto pick. + const manualLocation = + chosen?.warehouseId && chosen?.yardId && chosen?.zoneId + ? { + warehouseId: chosen.warehouseId, + yardId: chosen.yardId, + zoneId: chosen.zoneId, + path: undefined as string | undefined, + } + : null; + const criteria = await this.getInventoryAllocationCriteria(item); - const ruleLocation = await this.allocation.resolveLocation(criteria); - const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria); + const location = + manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); if (!location) { throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); @@ -2154,18 +2219,19 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } + const storedReason = manualLocation + ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` + : ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', storedAt: new Date(), warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, - notes: this.appendNote( - locked.notes, - ruleLocation?.rule - ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` - : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, - ), + notes: this.appendNote(locked.notes, storedReason), }); await this.activityLog.record( @@ -2173,9 +2239,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_STORED', inventoryId: id, warehouseId: location.warehouseId, - description: ruleLocation?.rule - ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` - : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + description: storedReason.replace(/^Stored/, 'Inventory stored'), performedBy, }, manager, @@ -2294,6 +2358,26 @@ export class WarehouseInventoryService { 'Customer must sign the handover before the exit paper can be generated', ); } + + // Authoritative weight match: the truck's net (gross − tare) must equal the + // total VGM cargo weight of the containers selected as loaded on it. + if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + const selected = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0); + const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3)); + if (expected > 0 && Math.abs(computedNet - expected) > 0.001) { + throw new BadRequestException( + `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`, + ); + } + } + } } } const releaseDate = isTruckLeaving @@ -2519,15 +2603,17 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; - stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; + handoverSigned: boolean; }> > { const rows: Array<{ @@ -2539,6 +2625,7 @@ export class WarehouseInventoryService { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2552,6 +2639,7 @@ export class WarehouseInventoryService { a.plate_number AS "truckPlate", (a.arrived_at IS NOT NULL) AS "truckArrived", (a.departed_at IS NOT NULL) AS "truckLeft", + (ctc.loaded_at IS NOT NULL) AS loaded, b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", @@ -2574,28 +2662,64 @@ export class WarehouseInventoryService { [bookingId], ); + // Booking-level gate: the per-truck exit paper is blocked until the handover + // is fully signed, so the UI can disable "Exit Paper" with a clear reason. + const handoverSigned = await this.handover.isFullySigned(bookingId); + return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + // A container the customer assigned to a truck is ASSIGNED (planned); it + // only becomes LOADED once the operator loads it (loaded_at) on truck + // leaving. Departed → LEFT, delivered → DELIVERED. stage: r.delivered ? 'DELIVERED' : r.truckLeft ? 'LEFT' - : r.truckAssignmentId + : r.loaded ? 'LOADED' - : r.grnNumber - ? 'GRN' - : r.received - ? 'RECEIVED' - : 'PENDING', + : r.truckAssignmentId + ? 'ASSIGNED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', grnNumber: r.grnNumber, truckAssignmentId: r.truckAssignmentId, truckPlate: r.truckPlate, truckArrived: r.truckArrived, truckLeft: r.truckLeft, + loaded: r.loaded, bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + handoverSigned, + })); + } + + /** + * The booking's containers with their VGM cargo weight (tonnes), keyed by + * container number. Drives the truck-leaving exit weighing: the selected + * containers' total cargo weight must match (gross − tare). + */ + async bookingContainerWeights( + bookingId: string, + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string }> = + await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(bcu.vgm_tons, 0) AS "weightTons" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + ORDER BY bcu.container_number`, + [bookingId], + ); + return rows.map((r) => ({ + containerNumber: r.containerNumber, + weightTons: Number(r.weightTons) || 0, })); } @@ -2677,7 +2801,7 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.plateNumber], ['Driver', data.driverName], ['Truck Type', data.truckType], - ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`], ['Gate-Out Time', gateOut], ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], ]; @@ -2901,6 +3025,21 @@ export class WarehouseInventoryService { }; } + /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ + async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3127,7 +3266,22 @@ export class WarehouseInventoryService { [item.bookingId], ); } else { - await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + // EDR last-mile: the handover is per delivering truck. Resolve the + // vehicle that carried this item's container so each truck gets its own + // handover (falls back to a booking-level one when unresolvable). + let truckPlate: string | null = null; + if (item.containerId) { + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + FROM freight.last_mile_container_allocations lca + JOIN freight.vehicles v ON v.id = lca.vehicle_id + WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL + LIMIT 1`, + [item.containerId], + ); + truckPlate = veh?.plate ?? null; + } + await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } } }); @@ -3608,8 +3762,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Received Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Received Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Volume', data.volume == null ? null : data.volume.toLocaleString()], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3731,7 +3885,7 @@ export class WarehouseInventoryService { `${(data.truckPlateNumber && data.truckWeightKg ? data.truckWeightKg : data.weight - ).toLocaleString()} kg`, + ).toLocaleString()} t`, ], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3894,8 +4048,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Inventory Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Inventory Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], @@ -3968,8 +4122,8 @@ export class WarehouseInventoryService { 1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)} Container${esc(data.containerNumber)} Booking Containers${esc(data.bookingContainerSummary)} - Inventory Weight${esc(`${data.weight.toLocaleString()} kg`)} - Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)} + Inventory Weight${esc(`${data.weight.toLocaleString()} t`)} + Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}
Handover Clause
@@ -4303,9 +4457,9 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} kg`, - grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`, + `Tare Weight: ${tareWeight} t`, + grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, + computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -4357,7 +4511,7 @@ export class WarehouseInventoryService { } private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { - const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, ''); + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, ''); if (!value) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; @@ -4420,9 +4574,9 @@ export class WarehouseInventoryService { truck?.driverName ? `Driver: ${truck.driverName}` : null, truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null, truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, - truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, @@ -4430,8 +4584,8 @@ export class WarehouseInventoryService { truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null, truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9ee1dc398..2508e373a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -6,9 +6,11 @@ import { NotFoundException, } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, NotificationAudience, NotificationType } from "@edr/types"; import { DataSource } from "typeorm"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + import { BillingService, InvoiceEventPayload, @@ -135,6 +137,7 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, ) { } // ── Generation ─────────────────────────────────────────────────────────── @@ -968,6 +971,25 @@ export class WarehouseInvoiceService { message, `warehouse fee invoice ${invoice.invoiceNumber}`, ); + + // In-app deep-link to pay the fee from the booking. + if (invoice.customerId && invoice.bookingId) { + try { + await this.inbox.notify({ + recipients: { companyId: invoice.customerId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: "Warehouse fee due", + body: + `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`, + link: `/bookings/${invoice.bookingId}`, + data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber }, + }); + } catch (err) { + this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`); + } + } } private async notifyWarehouseFeePayment( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 5f0ce5749..4011bc14f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + NotificationInboxModule, SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index f92401dfc..140d9f6b4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -64,8 +64,8 @@ export class WarehousesService { currentWeight: 0, currentContainers: 0, currentVolume: 0, - status: 'ACTIVE', - isActive: true, + status: dto.status ?? 'ACTIVE', + isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE', }); } catch (error) { this.mapDbError(error); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..8d70bc61c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), @@ -477,6 +478,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: 'edr_freight_app:tracking:view', + manage: 'edr_freight_app:tracking:manage', }, fuel: { view: 'edr_freight_app:fuel:view', diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx new file mode 100644 index 000000000..924d62436 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -0,0 +1,379 @@ +import { + Alert, + Anchor, + Badge, + Box, + Button, + Card, + Group, + Modal, + SimpleGrid, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { + AlertTriangle, + ClipboardCheck, + Clock, + FilePlus2, + FileX2, +} from "lucide-react"; +import { useState } from "react"; +import { useFileViewer } from "@edr/ui-common"; + +import { fileViewUrl } from "@/constants/apiConfig"; +import { api } from "@/services/api"; +import type { Company, CompanyChangeRequest } from "@/types/customer"; +import { formatDate, humanize } from "./format"; + +/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ +const FIELD_LABELS: Record = { + companyName: "Company name", + companyEmail: "Company email", + companyPhone: "Company phone", + companyLocation: "Location", + companyAddress: "Address", + tin: "TIN", + vatNumber: "VAT number", + fanNumber: "FAN number", + nationality: "Nationality", + licenceNumber: "Licence number", + contactPersonName: "Contact person", + contactPersonPosition: "Contact position", + contactPersonEmail: "Contact email", + contactPersonPhone: "Contact phone", + generalManagerName: "General manager", + generalManagerEmail: "GM email", + generalManagerPhone: "GM phone", + poaName: "PoA name", + poaPhone: "PoA phone", + poaEmail: "PoA email", + poaLocation: "PoA location", + poaAddress: "PoA address", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", + houseNo: "House no.", +}; + +/** Best-effort current value on the live company for a proposed field key. */ +function currentValue(company: Company, key: string): string { + const c = company as unknown as Record; + const attrs = (company.attributes ?? {}) as Record; + const map: Record = { + companyName: c.name, + companyEmail: c.email, + companyPhone: c.phone, + companyLocation: c.country, + companyAddress: c.address, + tin: c.tin, + vatNumber: c.vatNumber, + fanNumber: c.fanNumber, + nationality: c.nationality, + contactPersonName: c.contactPersonName ?? attrs.contactPersonName, + contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, + generalManagerName: c.generalManagerName ?? attrs.generalManagerName, + generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, + generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + }; + const v = key in map ? map[key] : (c[key] ?? attrs[key]); + return v === null || v === undefined || v === "" ? "—" : String(v); +} + +function DiffRow({ + label, + from, + to, +}: { + label: string; + from: string; + to: string; +}) { + const changed = from !== to; + return ( + + + {label} + + + + {from} + + {changed && ( + <> + + → + + + {to} + + + )} + + + ); +} + +/** + * 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. + */ +export function ChangeRequestReview({ company }: { company: Company }) { + const query = useQuery( + api.customers.changeRequests.queryOptions({ input: { id: company.id } }), + ); + const approve = useMutation( + api.customers.approveChangeRequest.mutationOptions(), + ); + const reject = useMutation( + api.customers.rejectChangeRequest.mutationOptions(), + ); + + const { view, viewer } = useFileViewer(); + const [rejectId, setRejectId] = useState(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; + + const proposedKeys = pending + ? Object.keys(pending.snapshot ?? {}) + : ([] as string[]); + const docCount = pending?.documentFileIds?.length ?? 0; + const licenseChanges = pending?.licenseChanges ?? []; + + const confirmReject = () => { + if (!rejectId) return; + reject.mutate( + { id: rejectId, note: note.trim() }, + { + onSuccess: () => { + setRejectId(null); + setNote(""); + }, + }, + ); + }; + + return ( + <> + {pending && ( + + + + + + + Profile changes awaiting review + + + Pending + + + + Submitted {formatDate(pending.submittedAt ?? pending.createdAt)} + + + + {proposedKeys.length > 0 ? ( + + {proposedKeys.map((key) => ( + + ))} + + ) : ( + + No field changes — document uploads only. + + )} + + {docCount > 0 && ( + + {docCount} document{docCount === 1 ? "" : "s"} uploaded with this + request — review them in the Documents tab. + + )} + + {licenseChanges.length > 0 && ( + + + Business license changes + + {licenseChanges.map((c, i) => ( + + {c.op === "add" ? ( + + ) : ( + + )} + + {c.op === "add" ? "Add" : "Remove"} + + + view({ + name: c.fileName ?? "License document", + url: fileViewUrl(c.fileId), + }) + } + style={{ + textDecoration: + c.op === "remove" ? "line-through" : undefined, + }} + > + {c.fileName ?? "License document"} + + + ))} + + )} + + + + + + + + )} + + {history.length > 0 && ( + + + + Review history + + {history.map((r: CompanyChangeRequest) => ( + + + {r.status} + + + + {formatDate(r.reviewedAt ?? r.updatedAt)} + + {r.note && ( + + Note: {r.note} + + )} + + + ))} + + + )} + + setRejectId(null)} + title="Reject changes" + centered + radius="lg" + > + + }> + The customer will see this note and can amend and resubmit. + +