diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 69596c21d..ced6e3c46 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin'; const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; type PermissionLike = { key?: string }; +type PositionTypeLike = { key?: string }; type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: | { - position?: { permissions?: PermissionLike[] }; + position?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }; delegatedPositions?: { permissions?: PermissionLike[] }[]; } | { - positions?: { permissions?: PermissionLike[] }[]; + positions?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }[]; }[] | null; }; @@ -90,12 +97,110 @@ export function assertFreightPermission( throw new ForbiddenException(`Missing permission: ${permissionKey}`); } +/** + * The caller's IAM position-type keys (`iam.position_types.key`). A position + * type is the platform's notion of a role — it is what carries permissions via + * `iam.position_type_permissions` — and it is the vocabulary contract approval + * chains are configured in. + * + * Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is + * an object on some tokens and an array on others. + * + * Note delegated positions carry no `positionType` in the token, so a delegate + * is not reachable here — they authorize through the permission arm of + * `assertCanApproveContractStep` instead. + */ +export function collectPositionTypeKeys( + user: MeLikeUser | null | undefined, +): string[] { + const employee = user?.employee; + if (!employee) return []; + + const keys = new Set(); + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + if (pos.positionType?.key) keys.add(pos.positionType.key); + } + } + return [...keys]; + } + + if (employee.position?.positionType?.key) { + keys.add(employee.position.positionType.key); + } + return [...keys]; +} + +/** + * Legacy chain roles predate position types. Historical `approval_rules` and + * in-flight `contract_approval_steps` rows still carry them, so map each to the + * position types that stand in for it. Without this, an approver holding a + * modern position type could not action an older step. + */ +const LEGACY_ROLE_POSITION_TYPES: Record = { + LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'], + DIRECTOR: ['director', 'operation-director'], + CEO: ['chief', 'deputy'], +}; + const APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, CEO: FREIGHT_PERMS.bookings.approveCeo, }; +const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, +}; + +const ANY_CONTRACT_APPROVE_PERMISSION = [ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, +]; + +/** + * May this caller action a contract approval step requiring `requiredRole`? + * + * `requiredRole` is an `iam.position_types.key` for chains configured by an + * admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows. + * A caller passes when any of these hold: + * + * - they are a super/organization admin (blanket bypass); + * - their position type matches the step, directly or via a legacy alias; + * - they hold the approve permission the legacy role maps to; + * - they hold any contract approve permission — this covers delegates (whose + * position type is absent from the token) and staff whose IAM position has + * no position type assigned yet. + */ +export function assertCanApproveContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + if (legacyPermission && hasFreightPermission(user, legacyPermission)) return; + + if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) { + return; + } + + throw new ForbiddenException( + `You are not the required approver (${requiredRole}) for this step.`, + ); +} + export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, diff --git a/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts new file mode 100644 index 000000000..1b4674b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bookings no longer run an approval chain — accepting an intake approves the + * booking outright and generates its contract. The approval chain is now a + * contract-only concern, so `freight.approval_rules` is read by contracts alone. + * + * Also widens the role columns: chain steps now reference IAM position-type + * keys (`iam.position_types.key`), and real keys run past the old varchar(30) + * (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail + * on insert. + */ +export class DropBookingApprovalWidenRoles2410000000000 + implements MigrationInterface +{ + name = 'DropBookingApprovalWidenRoles2410000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.booking_approval_step;`, + ); + + for (const [table, column] of [ + ['approval_rules', 'required_role'], + ['approval_rules', 'blocks_role'], + ['contract_approval_steps', 'required_role'], + ['contract_approval_steps', 'blocks_role'], + ] as const) { + await queryRunner.query( + `ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`, + ); + } + } + + /** + * No-op: the booking approval chain is retired, so re-creating the table + * would leave dead schema behind. Narrowing the role columns again would + * truncate any position-type key already stored. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts new file mode 100644 index 000000000..db06b2ba2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for contract document edits. The document stays editable through + * the whole approval chain (each approver may edit on their turn), so the + * contract itself only ever holds the current snapshot — this table records who + * changed which article, and when. + */ +export class CreateContractDocumentRevisions2420000000000 + implements MigrationInterface +{ + name = 'CreateContractDocumentRevisions2420000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_document_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + actor_id uuid, + actor_role varchar(64), + step_id uuid, + summary varchar(255), + changes jsonb NOT NULL DEFAULT '[]'::jsonb + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract + ON freight.contract_document_revisions (contract_id, created_at DESC); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.contract_document_revisions;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index d93b5b7bd..ddb4ce1e5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -1,4 +1,3 @@ -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { Booking } from './entities/booking.entity'; export interface BookingNextStep { @@ -9,7 +8,11 @@ export interface BookingNextStep { export function computeNextStep( booking: Pick, - nextPendingStep?: Pick | null, + /** + * Retained for call-site compatibility — bookings no longer run an approval + * chain, so this is always null. Approvals are a contract-only concern. + */ + nextPendingStep?: { requiredRole: string; stepOrder: number } | null, ): BookingNextStep | null { const { status } = booking; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 607a0d7a4..068ed53af 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => { findById: jest.fn().mockResolvedValue(booking), }; const ruleEngineService = { - instantiateApprovalSteps: jest.fn().mockResolvedValue([]), + assertNoHardBlocks: jest.fn(), + }; + const contractService = { + generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }), }; const service = new BookingTransitionService( bookingsRepository as never, ruleEngineService as never, {} as never, // pricingService - {} as never, // contractService + contractService as never, {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService @@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { dutySlipUploadedToStaff: jest.fn(), } as never, // notifier ); - return { service, bookingsRepository, ruleEngineService }; + return { service, bookingsRepository, ruleEngineService, contractService }; } it('rejects accept when validity days is missing or non-positive', async () => { @@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { const [id, updates] = bookingsRepository.update.mock.calls[0]; expect(id).toBe('b-1'); expect(updates).toMatchObject({ - status: 'PENDING_APPROVAL', + status: 'APPROVED', approvedByStaffId: 'staff-1', contractValidityDays: 10, }); @@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => { expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime()); }); - it('instantiates the approval chain when accepting', async () => { - const { service, ruleEngineService } = makeService(); + it('approves outright and generates the contract (no approval chain)', async () => { + const { service, contractService } = makeService(); await service.acceptIntake('b-1', 'staff-1', 30); - expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ freightType: 'CONTAINER' }), - ); + expect(contractService.generateContract).toHaveBeenCalledWith('b-1'); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index aacc68012..29fec7997 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,9 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; -import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; @@ -248,16 +246,6 @@ export class BookingTransitionService { return fresh; } - /** Auto-create booking approval steps from system rules when none exist yet. */ - private async ensureBookingApprovalSteps(booking: Booking): Promise { - if ((booking.approvalSteps?.length ?? 0) > 0) return; - - await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - } - async acceptIntake( bookingId: string, actorId: string, @@ -283,21 +271,33 @@ export class BookingTransitionService { const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "PENDING_APPROVAL", + // Bookings no longer run a multi-step approval chain — accepting the intake + // approves the booking outright and generates its contract. (The approval + // chain is a contract-only concern now; see contract-transition.service.) + await this.bookingsRepository.update(bookingId, { + status: "APPROVED", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - const fresh = await this.bookingsService.findById(updated!.id); + + // Generating the contract is best-effort: the acceptance is already + // committed, so a failure here must not roll it back. The booking stays + // APPROVED and staff can retry generation from the booking page. + try { + await this.contractService.generateContract(bookingId); + } catch (err) { + this.logger.warn( + `Contract generation failed after accepting booking ${bookingId}: ${err}. ` + + `The booking is APPROVED — retry generation from the booking page.`, + ); + } + + const fresh = await this.bookingsService.findById(bookingId); this.notifier.accepted(fresh); + this.notifier.approved(fresh); return fresh; } @@ -324,140 +324,6 @@ export class BookingTransitionService { return fresh; } - async approveStep( - bookingId: string, - stepId: string, - actorId: string, - requiredRole: string, - authUser?: TCurrentUser, - ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - - let booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - if ((booking.approvalSteps?.length ?? 0) === 0) { - await this.ensureBookingApprovalSteps(booking); - booking = await this.bookingsService.findById(bookingId); - } - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step || step.status !== "PENDING") { - throw new BadRequestException( - "Approval step not found or already actioned", - ); - } - - const next = - await this.bookingsRepository.findNextPendingApprovalStep(bookingId); - if (!next || next.id !== step.id) { - throw new BadRequestException( - "Approval steps must be completed in order", - ); - } - - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - - const blocksRole = step.blocksRole; - if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException( - `Role ${requiredRole} is blocked for this step`, - ); - } - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "APPROVED", - ); - - const updates: Record = {}; - const now = new Date(); - - if (requiredRole === "LINE_STAFF") { - updates.status = "APPROVED_PENDING_SIGNATURE"; - updates.approvedByStaffId = actorId; - updates.approvedByStaffAt = now; - } else if (requiredRole === "DIRECTOR") { - updates.signedByDirectorId = actorId; - updates.signedByDirectorAt = now; - } else if (requiredRole === "CEO") { - updates.signedByCeoId = actorId; - updates.signedByCeoAt = now; - } - - const allDone = - await this.bookingsRepository.allApprovalStepsComplete(bookingId); - if (allDone) { - updates.status = "APPROVED"; - } - - if (Object.keys(updates).length > 0) { - await this.bookingsRepository.update(bookingId, updates as never); - } - - if (allDone) { - const generated = await this.contractService.generateContract(bookingId); - const fresh = await this.bookingsService.findById(generated.id); - this.notifier.approved(fresh); - return fresh; - } - - return this.bookingsService.findById(bookingId); - } - - async rejectStep( - bookingId: string, - stepId: string, - actorId: string, - reason: string, - ): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step) throw new BadRequestException("Approval step not found"); - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "REJECTED", - reason, - ); - - await this.bookingsRepository.createReviewNote( - bookingId, - reason, - "REJECTION", - actorId, - ); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "REJECTED", - } as never); - const fresh = await this.bookingsService.findById(updated!.id); - this.notifier.rejected(fresh, reason); - return fresh; - } - async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["CONTRACT_READY"]); @@ -1298,12 +1164,9 @@ export class BookingTransitionService { } let nextStep: BookingNextStep | null = null; try { - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - nextStep = computeNextStep(booking, nextPending); + // Bookings no longer carry an approval chain, so there is never a pending + // approval step to hint at. + nextStep = computeNextStep(booking, null); } catch (err) { this.logger.warn( `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, 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 56e788b93..bc8c80982 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, - ApproveStepDto, CancelBookingDto, RejectBookingDto, - RejectStepDto, RequestChangesDto, ReviewDocumentDto, RequestOperationDto, @@ -1023,47 +1021,6 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(":id/approval-steps/:stepId/approve") - @BookingStaff([ - FREIGHT_PERMS.bookings.approveLineStaff, - FREIGHT_PERMS.bookings.approveDirector, - FREIGHT_PERMS.bookings.approveCeo, - ]) - @ApiOperation({ summary: "Approve one approval step in sequence" }) - async approveStep( - @Param("id", ParseUUIDPipe) id: string, - @Param("stepId", ParseUUIDPipe) stepId: string, - @Body() dto: ApproveStepDto, - @CurrentUser() user: TCurrentUser, - ) { - const booking = await this.transitionService.approveStep( - id, - stepId, - resolveAuthUserId(user), - dto.requiredRole, - user, - ); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(":id/approval-steps/:stepId/reject") - @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: "Reject at approval step" }) - async rejectStep( - @Param("id", ParseUUIDPipe) id: string, - @Param("stepId", ParseUUIDPipe) stepId: string, - @Body() dto: RejectStepDto, - @CurrentUser() user: AuthUserPayload, - ) { - const booking = await this.transitionService.rejectStep( - id, - stepId, - resolveAuthUserId(user), - dto.reason, - ); - return this.transitionService.enrichBookingResponse(booking); - } - @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) @ApiOperation({ summary: "Generate contract PDF from template" }) 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 15c5e751d..38d7d2ac6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; @@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; Booking, BookingContainer, BookingCargoModifier, - BookingApprovalStep, BookingDocumentReview, BookingRateSnapshot, BookingReviewNote, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index c93ebd9c7..590bd7262 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'oy') .leftJoinAndSelect('booking.destinationYard', 'dy') .leftJoinAndSelect('booking.shippingLine', 'sl') - .leftJoinAndSelect('booking.approvalSteps', 'steps') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') @@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } - /** Lowest-order pending approval step (sequential enforcement). */ - async findNextPendingApprovalStep( - bookingId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, status: 'PENDING' }, - order: { stepOrder: 'ASC' }, - }); - } - - async findApprovalStepById( - bookingId: string, - stepId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, id: stepId }, - }); - } - - /** Get pending approval step for a role (must match next in sequence). */ - async findPendingApprovalStep( - bookingId: string, - requiredRole: string, - ): Promise { - const next = await this.findNextPendingApprovalStep(bookingId); - if (!next || next.requiredRole !== requiredRole) return null; - return next; - } - - /** Mark an approval step complete. */ - async completeApprovalStep( - stepId: string, - actorId: string, - status: 'APPROVED' | 'REJECTED', - remarks?: string, - ): Promise { - await this.dataSource.getRepository(BookingApprovalStep).update(stepId, { - status, - actionedByStaffId: actorId, - actionedAt: new Date(), - remarks, - }); - } - - /** Check if all approval steps are approved. */ - async allApprovalStepsComplete(bookingId: string): Promise { - const pending = await this.dataSource.getRepository(BookingApprovalStep).count({ - where: { bookingId, status: 'PENDING' }, - }); - return pending === 0; - } - // ── Clearance document reviews ──────────────────────────────────────────── findDocumentReviews(bookingId: string): Promise { @@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .where('booking.status IN (:...statuses)', { statuses }); if (options.excludeBulk) { @@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on // Booking → contract, so join the entity by id and select just the diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts deleted file mode 100644 index 68018e883..000000000 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity'; -import { Booking } from './booking.entity'; - -export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const; -export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number]; - -@Entity({ schema: 'freight', name: 'booking_approval_step' }) -@Index(['bookingId']) -@Index(['status']) -@Index(['bookingId', 'stepOrder']) -export class BookingApprovalStep extends BaseEntity { - @Column({ name: 'booking_id', type: 'uuid' }) - bookingId!: string; - - @ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'booking_id' }) - booking?: Booking; - - @Column({ name: 'approval_rule_id', type: 'uuid' }) - approvalRuleId!: string; - - @ManyToOne(() => ApprovalRule) - @JoinColumn({ name: 'approval_rule_id' }) - approvalRule?: ApprovalRule; - - @Column({ name: 'step_order', type: 'smallint' }) - stepOrder!: number; - - @Column({ name: 'required_role', type: 'varchar', length: 30 }) - requiredRole!: string; - - @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) - blocksRole?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) - status!: ApprovalStepStatus; - - @Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true }) - actionedByStaffId?: string | null; - - @Column({ name: 'actioned_at', type: 'timestamptz', nullable: true }) - actionedAt?: Date | null; - - @Column({ name: 'remarks', type: 'text', nullable: true }) - remarks?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index f74cb515e..2aa3ee8c2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Train } from '../../trains/entities/train.entity'; import { FileRecord } from '../../files/entities/file.entity'; -import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingContainerAllocation } from './booking-container-allocation.entity'; @@ -557,8 +556,6 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; - @OneToMany(() => BookingApprovalStep, (s) => s.booking) - approvalSteps?: BookingApprovalStep[]; @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts new file mode 100644 index 000000000..154777aea --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -0,0 +1,168 @@ +import type { + ContractDocumentArticle, + ContractDocumentSnapshot, +} from './entities/contract.entity'; + +/** + * One recorded change between two document snapshots. Granularity is per + * article: a body edit is reported as "the body changed", not as a text diff. + */ +export type ContractDocumentChange = + | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } + | { kind: 'ARTICLE_REMOVED'; articleId: string; title: string } + | { + kind: 'ARTICLE_RENAMED'; + articleId: string; + title: string; + fromTitle: string; + } + | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_REORDERED'; + articleId: string; + title: string; + fromOrder: number; + toOrder: number; + } + | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + +type SnapshotLike = Pick< + ContractDocumentSnapshot, + 'documentTitle' | 'whereasClauses' | 'articles' +> | null; + +/** Match on id when present, else on normalized title (editors may omit ids). */ +function articleKey(article: ContractDocumentArticle): string { + return article.id || `title:${article.title.trim().toLowerCase()}`; +} + +function indexArticles( + articles: ContractDocumentArticle[] | undefined, +): Map { + const map = new Map(); + for (const article of articles ?? []) { + map.set(articleKey(article), article); + } + return map; +} + +/** + * Compare two document snapshots and describe what changed, article by article. + * Returns an empty array when the snapshots are equivalent, so callers can skip + * recording a no-op revision. + */ +export function diffSnapshots( + before: SnapshotLike, + after: SnapshotLike, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + const beforeTitle = before?.documentTitle ?? null; + const afterTitle = after?.documentTitle ?? null; + if (beforeTitle !== afterTitle && afterTitle !== null) { + changes.push({ + kind: 'DOCUMENT_TITLE_CHANGED', + title: afterTitle, + fromTitle: beforeTitle, + }); + } + + const beforeWhereas = before?.whereasClauses ?? []; + const afterWhereas = after?.whereasClauses ?? []; + const beforeWhereasSet = new Set(beforeWhereas); + const afterWhereasSet = new Set(afterWhereas); + const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length; + const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length; + if (whereasAdded > 0 || whereasRemoved > 0) { + changes.push({ + kind: 'WHEREAS_CHANGED', + added: whereasAdded, + removed: whereasRemoved, + }); + } + + const beforeArticles = indexArticles(before?.articles); + const afterArticles = indexArticles(after?.articles); + + for (const [key, article] of afterArticles) { + const previous = beforeArticles.get(key); + if (!previous) { + changes.push({ + kind: 'ARTICLE_ADDED', + articleId: article.id, + title: article.title, + }); + continue; + } + + if (previous.title !== article.title) { + changes.push({ + kind: 'ARTICLE_RENAMED', + articleId: article.id, + title: article.title, + fromTitle: previous.title, + }); + } + if (previous.body !== article.body) { + changes.push({ + kind: 'ARTICLE_BODY_CHANGED', + articleId: article.id, + title: article.title, + }); + } + if (previous.order !== article.order) { + changes.push({ + kind: 'ARTICLE_REORDERED', + articleId: article.id, + title: article.title, + fromOrder: previous.order, + toOrder: article.order, + }); + } + } + + for (const [key, article] of beforeArticles) { + if (afterArticles.has(key)) continue; + changes.push({ + kind: 'ARTICLE_REMOVED', + articleId: article.id, + title: article.title, + }); + } + + return changes; +} + +/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ +export function summarizeChanges(changes: ContractDocumentChange[]): string { + if (changes.length === 0) return 'No changes'; + + const articleVerbs: Record = { + ARTICLE_ADDED: 'added', + ARTICLE_REMOVED: 'removed', + ARTICLE_RENAMED: 'renamed', + ARTICLE_BODY_CHANGED: 'edited', + ARTICLE_REORDERED: 'reordered', + }; + + const counts = new Map(); + const parts: string[] = []; + + for (const change of changes) { + const verb = articleVerbs[change.kind]; + if (verb) { + counts.set(verb, (counts.get(verb) ?? 0) + 1); + } else if (change.kind === 'DOCUMENT_TITLE_CHANGED') { + parts.push('document title changed'); + } else if (change.kind === 'WHEREAS_CHANGED') { + parts.push('recitals changed'); + } + } + + const articleParts = [...counts.entries()].map( + ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, + ); + + return [...articleParts, ...parts].join(', '); +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts new file mode 100644 index 000000000..2808ea6cf --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import type { ContractDocumentSnapshot } from './entities/contract.entity'; + +export interface RecordRevisionInput { + contractId: string; + before: ContractDocumentSnapshot | null; + after: ContractDocumentSnapshot | null; + actorId?: string | null; + actorRole?: string | null; + stepId?: string | null; +} + +@Injectable() +export class ContractDocumentHistoryService { + private readonly logger = new Logger(ContractDocumentHistoryService.name); + + constructor( + @InjectRepository(ContractDocumentRevision) + private readonly revisionRepo: Repository, + ) {} + + /** + * Append a revision describing what an edit changed. Best-effort: recording + * history must never break the edit that triggered it, so failures are logged + * and swallowed. A no-op edit records nothing. + */ + async record(input: RecordRevisionInput): Promise { + try { + const changes = diffSnapshots(input.before, input.after); + if (changes.length === 0) return; + + await this.revisionRepo.save( + this.revisionRepo.create({ + contractId: input.contractId, + actorId: input.actorId ?? null, + actorRole: input.actorRole ?? null, + stepId: input.stepId ?? null, + summary: summarizeChanges(changes), + changes, + }), + ); + } catch (err) { + this.logger.error( + `Failed to record document revision for contract ${input.contractId}: ${String(err)}`, + ); + } + } + + /** Revision history for a contract, newest first. */ + list(contractId: string): Promise { + return this.revisionRepo.find({ + where: { contractId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 6acceaaa0..ba825dfaa 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Injectable, Logger, + ServiceUnavailableException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { assertCanApproveContractStep } from '../../common/freight-permission.util'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; @@ -48,8 +50,12 @@ export interface ContractDocumentDraft { articles: ContractDocumentArticle[]; code: string | null; name: string | null; - /** True once the document may no longer be edited/regenerated. */ + /** True when THIS caller may not edit — the inverse of `editableByMe`. */ locked: boolean; + /** Whether the requesting user is the approver whose turn it is to edit. */ + editableByMe: boolean; + /** Role holding editing rights right now, for "locked because…" messaging. */ + nextApproverRole: string | null; generatedAt: Date | null; status: string; } @@ -62,6 +68,28 @@ export interface ContractDocumentDraft { */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; +/** + * Approval chains are configured in IAM position types, so a step's role no + * longer maps onto the contract's fixed approver columns. These sets keep those + * legacy columns populated for the roles that still correspond to one — both the + * original role strings on historical rows and the position types that replaced + * them. Steps outside these sets are recorded only in `contract_approval_steps`, + * which is the source of truth. + */ +const LEGACY_STAFF_ROLES = new Set([ + 'LINE_STAFF', + 'employee', + 'teamLeader', + 'officeHead', + 'recordOfficer', +]); +const LEGACY_DIRECTOR_ROLES = new Set([ + 'DIRECTOR', + 'director', + 'operation-director', +]); +const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']); + /** * Mask a phone for display — keep the last 4 digits, star the rest * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing @@ -108,6 +136,7 @@ export class ContractTransitionService { private readonly logger = new Logger(ContractTransitionService.name); constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, @@ -255,18 +284,22 @@ export class ContractTransitionService { */ async getContractDocumentDraft( contractId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); const snapshot = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? (await this.resolveDocumentSnapshot(contract)); + const editableByMe = await this.documentIsEditableBy(contract, user); return { documentTitle: snapshot?.documentTitle ?? null, whereasClauses: snapshot?.whereasClauses ?? [], articles: snapshot?.articles ?? [], code: snapshot?.code ?? null, name: snapshot?.name ?? null, - locked: !this.documentIsEditable(contract), + locked: !editableByMe, + editableByMe, + nextApproverRole: await this.nextApproverRole(contract), generatedAt: contract.contractGeneratedAt ?? null, status: contract.status, }; @@ -281,10 +314,12 @@ export class ContractTransitionService { async updateContractDocument( contractId: string, input: ContractDocumentSnapshotInput, + user?: TCurrentUser | null, + actorId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL']); - this.assertDocumentEditable(contract); + await this.assertDocumentEditable(contract, user); const current = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? @@ -296,9 +331,25 @@ export class ContractTransitionService { whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [], articles: input.articles ?? current?.articles ?? [], }; + const next = this.normalizeSnapshot(merged); await this.contractsRepository.update(contractId, { - documentSnapshot: this.normalizeSnapshot(merged), + documentSnapshot: next, } as never); + + // Audit the edit after it lands. Recording history must never break the + // edit itself, so the history service swallows its own failures. + const step = await this.contractsRepository.findNextPendingApprovalStep( + contractId, + ); + await this.documentHistory.record({ + contractId, + before: current, + after: next, + actorId: actorId ?? null, + actorRole: step?.requiredRole ?? null, + stepId: step?.id ?? null, + }); + return this.contractsService.findById(contractId); } @@ -360,23 +411,54 @@ export class ContractTransitionService { } /** - * The per-contract document may be edited/regenerated while the contract is at - * the accept stage (SUBMITTED) or in approval with NO approver having acted - * yet. The first approval action freezes it. + * The contract document stays editable for the whole approval chain, but only + * by the approver whose turn it is: whoever can action the next pending step. + * Approving therefore hands editing rights to the next approver in the chain. + * + * Edits never reset approvals already given — earlier approvers stay approved. */ - private documentIsEditable(contract: Contract): boolean { + private async documentIsEditableBy( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { if (contract.status === 'SUBMITTED') return true; if (contract.status !== 'PENDING_APPROVAL') return false; - return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING'); + + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + if (!next) return false; + if (!user) return false; + + try { + assertCanApproveContractStep(user, next.requiredRole); + return true; + } catch { + return false; + } } - private assertDocumentEditable(contract: Contract): void { - if (!this.documentIsEditable(contract)) { - throw new ConflictException( - 'The contract document is locked — an approver has already acted or the ' + - 'contract has advanced. It can no longer be edited or regenerated.', - ); - } + /** The role that currently holds editing rights, for UI messaging. */ + private async nextApproverRole(contract: Contract): Promise { + if (contract.status !== 'PENDING_APPROVAL') return null; + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + return next?.requiredRole ?? null; + } + + private async assertDocumentEditable( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { + if (await this.documentIsEditableBy(contract, user)) return; + + const role = await this.nextApproverRole(contract); + throw new ConflictException( + role + ? `The contract document can only be edited by the current approver (${role}).` + : 'The contract document is locked — the contract has advanced beyond approval.', + ); } /** @@ -539,25 +621,11 @@ export class ContractTransitionService { contractId: string, stepId: string, actorId: string, - requiredRole: string, authUser?: TCurrentUser, ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); - // Approvers review the generated contract document, so it must exist before - // the first approval can be recorded. Staff generate it (from the frozen, - // optionally-edited snapshot) at the accept stage. - if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) { - throw new BadRequestException( - 'Generate the contract document before it can be approved.', - ); - } - const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step || step.status !== 'PENDING') { throw new BadRequestException('Approval step not found or already actioned'); @@ -567,31 +635,34 @@ export class ContractTransitionService { if (!next || next.id !== step.id) { throw new BadRequestException('Approval steps must be completed in order'); } - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - if (step.blocksRole && step.blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + + // The role is the step's own — never the caller's claim about themselves. + const requiredRole = step.requiredRole; + if (authUser) { + assertCanApproveContractStep(authUser, requiredRole); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); // Record who acted on this step, but DO NOT advance the contract status here — - // approving one step (e.g. LINE_STAFF) must not finalize the chain while later - // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once - // every step in the chain is complete; until then the contract stays in - // PENDING_APPROVAL so the next required role can act. + // approving one step must not finalize the chain while later steps are still + // pending. Status only moves to APPROVED once every step in the chain is + // complete; until then the contract stays in PENDING_APPROVAL so the next + // required approver can act. + // + // `contract_approval_steps` is the source of truth for who approved what — a + // chain is an arbitrary sequence of position types and cannot be represented + // by fixed columns. The legacy columns below are still stamped, best-effort, + // for the three roles that map onto them so older readers keep working. const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { + if (LEGACY_STAFF_ROLES.has(requiredRole)) { updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (LEGACY_CEO_ROLES.has(requiredRole)) { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } @@ -605,14 +676,19 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); - // Every step approved → CONTRACT_READY. The document was already generated - // (and reviewed) at the accept stage, so we reuse it rather than - // re-rendering. Best-effort: a hiccup must not roll back the approval. + // Final approval is what produces the contract PDF — until now there was + // only a live preview. The approval steps are already committed, so a + // render failure must not roll them back; surface it instead of swallowing + // it, since an APPROVED contract with no document needs operator action. try { return await this.finalizeApprovedContract(contractId); } catch (err) { - this.logger.warn( - `Finalizing contract after final approval failed for ${updated.reference}: ${err}`, + this.logger.error( + `Contract PDF generation failed after final approval for ${updated.reference}: ${err}`, + ); + throw new ServiceUnavailableException( + 'All approvals were recorded, but generating the contract PDF failed. ' + + 'Retry generation from the contract page.', ); } } @@ -620,24 +696,13 @@ export class ContractTransitionService { } /** - * Staff (re)generate the contract PDF. Two stages: - * - PENDING_APPROVAL: render from the frozen (optionally staff-edited) - * snapshot so approvers review the real document. Status is UNCHANGED, and - * it is blocked once an approver has acted (the document is then locked). - * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to - * CONTRACT_READY. - * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the - * transition — the document re-renders lazily on view/download. + * Retry path for a contract that finished approval but whose PDF failed to + * render (Chromium unavailable, etc.). The normal flow generates the document + * automatically on the final approval — there is no manual generate step + * before that, only the live preview. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); - - if (contract.status === 'PENDING_APPROVAL') { - this.assertDocumentEditable(contract); - await this.renderContractDocument(contract); - return this.contractsService.findById(contractId); - } - assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); await this.renderContractDocument(contract); await this.contractsRepository.update(contractId, { @@ -652,11 +717,17 @@ export class ContractTransitionService { * changes status. Rendering is best-effort — a Chromium hiccup defers the file * (it re-renders on view/download) but the timestamp is still stamped. */ - private async renderContractDocument(contract: Contract): Promise { + private async renderContractDocument( + contract: Contract, + options: { strict?: boolean } = {}, + ): Promise { const { view } = await this.documentViewModelBuilder.build(contract.id); try { await this.upsertContractPdf(contract.id, contract.reference, view); } catch (err) { + // Strict callers (final approval) need to know the PDF is missing — it is + // the artifact of the completed chain, not a cache that can refill later. + if (options.strict) throw err; this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); @@ -668,15 +739,14 @@ export class ContractTransitionService { } /** - * Every approval step landed → CONTRACT_READY. The document was already - * generated (and reviewed) at the accept stage, so reuse it; render now only - * if it was somehow never generated. Never re-renders over an existing file. + * Every approval step landed → generate the contract PDF, then CONTRACT_READY. + * This is the only point at which the document is produced: approvers review a + * live preview, and the final approval is what turns it into a PDF. Renders + * unconditionally so the file reflects every edit made during the chain. */ private async finalizeApprovedContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); - if (!contract.contractGeneratedAt) { - await this.renderContractDocument(contract); - } + await this.renderContractDocument(contract, { strict: true }); await this.contractsRepository.update(contractId, { status: 'CONTRACT_READY', } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index cb91fbee6..249568046 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -33,6 +33,7 @@ import { import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { assertFreightPermission, @@ -61,7 +62,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { - ApproveStepDto, RejectContractDto, RejectStepDto, RequestChangesDto, @@ -91,6 +91,7 @@ import { @ApiBearerAuth() export class ContractsController { constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, @@ -353,8 +354,22 @@ export class ContractsController { summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', }) - getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) { - return this.transitionService.getContractDocumentDraft(id); + getContractDocumentDraft( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Editability depends on WHO is asking — only the approver whose turn it is + // may edit — so the caller is part of the draft lookup. + return this.transitionService.getContractDocumentDraft(id, user); + } + + @Get(':id/document/revisions') + @BookingStaff(FREIGHT_PERMS.contracts.view) + @ApiOperation({ + summary: 'Audit trail of edits to this contract\'s document (newest first)', + }) + getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) { + return this.documentHistory.list(id); } @Put(':id/document/articles') @@ -366,8 +381,14 @@ export class ContractsController { updateContractDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDocumentDto, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.updateContractDocument(id, dto); + return this.transitionService.updateContractDocument( + id, + dto, + user, + resolveAuthUserId(user), + ); } @Post(':id/staff/request-changes') @@ -397,23 +418,20 @@ export class ContractsController { } @Post(':id/approval-steps/:stepId/approve') - @BookingStaff([ - FREIGHT_PERMS.contracts.approveLineStaff, - FREIGHT_PERMS.contracts.approveDirector, - FREIGHT_PERMS.contracts.approveCeo, - ]) + @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, - @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { + // Whether this caller may approve depends on the step's own required role + // (an IAM position type), so the service resolves the step and authorizes + // against it — the client never declares its own role. return this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), - dto.requiredRole, user, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 5177b6a39..050417a45 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; import { ContractSignature } from './entities/contract-signature.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractReviewNote } from './entities/contract-review-note.entity'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ContractDocumentReview } from './entities/contract-document-review.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; @@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractSignature, ContractApprovalStep, ContractReviewNote, + ContractDocumentRevision, ContractClearanceCycle, ContractDocumentReview, ClearanceMilestone, @@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ClearanceFeeService, ContractNotifierService, ContractTransitionService, + ContractDocumentHistoryService, ContractClearanceService, ClearanceWorkflowService, BookingClearanceService, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts index 3c8c7fd5f..0a47dd3e1 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts @@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity { @Column({ name: 'step_order', type: 'smallint', default: 0 }) stepOrder!: number; - @Column({ name: 'required_role', type: 'varchar', length: 40 }) + @Column({ name: 'required_role', type: 'varchar', length: 64 }) requiredRole!: string; - @Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true }) + @Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true }) blocksRole?: string | null; @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts new file mode 100644 index 000000000..bc7e12e3e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import type { ContractDocumentChange } from '../contract-document-diff.util'; +import { Contract } from './contract.entity'; + +/** + * Append-only audit of contract document edits. The document stays editable + * through the whole approval chain, so this records who changed which article + * and when — the contract itself only ever holds the current snapshot. + */ +@Entity({ schema: 'freight', name: 'contract_document_revisions' }) +@Index(['contractId']) +export class ContractDocumentRevision extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId?: string | null; + + /** The approval step's required role at the time of the edit. */ + @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) + actorRole?: string | null; + + @Column({ name: 'step_id', type: 'uuid', nullable: true }) + stepId?: string | null; + + @Column({ name: 'summary', type: 'varchar', length: 255, nullable: true }) + summary?: string | null; + + @Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` }) + changes!: ContractDocumentChange[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 8d9ed00c9..5d17efe1e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -31,6 +31,15 @@ export class ApprovalRulesController { return this.service.findChain(flag === 'true'); } + @Get('position-types') + @RuleEngineView('approval-rules') + @ApiOperation({ + summary: 'IAM position types to choose from when building an approval chain', + }) + listPositionTypes() { + return this.service.listPositionTypes(); + } + @Post('reorder') @RuleEngineManage('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts index 5861b1ad8..196242802 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts @@ -1,8 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const; - export class CreateApprovalRuleDto { @ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' }) @IsBoolean() @@ -19,9 +17,12 @@ export class CreateApprovalRuleDto { @IsUUID('4') insertAfterId?: string; - @ApiProperty({ enum: ROLES, description: 'Role required to action this step' }) + @ApiProperty({ + description: + 'IAM position-type key required to action this step (see GET /approval-rules/position-types)', + }) @IsString() - @MaxLength(30) + @MaxLength(64) requiredRole!: string; @ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 }) @@ -29,9 +30,11 @@ export class CreateApprovalRuleDto { @MaxLength(50) actionLabel!: string; - @ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' }) + @ApiPropertyOptional({ + description: 'IAM position-type key explicitly blocked from actioning this step', + }) @IsOptional() @IsString() - @MaxLength(30) + @MaxLength(64) blocksRole?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts index 94fb4355d..63448c5f3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts @@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity { @Column({ name: 'step_order', type: 'smallint' }) stepOrder!: number; - @Column({ name: 'required_role', type: 'varchar', length: 30 }) + @Column({ name: 'required_role', type: 'varchar', length: 64 }) requiredRole!: string; @Column({ name: 'action_label', type: 'varchar', length: 50 }) actionLabel!: string; - @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + @Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true }) blocksRole?: string | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 3a342ef62..95b5e381d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -64,7 +64,6 @@ import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; -import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; @@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ApprovalRule, BookingContainer, BookingCargoModifier, - BookingApprovalStep, BookingRateSnapshot, ]), // Team notifications for the priority-rule approval workflow. diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index d1715d9fd..9d7aa6ba9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; import { Rate, RateTrigger } from './entities/rate.entity'; import { @@ -23,15 +22,10 @@ import { IRatesRepository, RATES_REPOSITORY, } from './interfaces/rates.repository.interface'; -import { - IApprovalRulesRepository, - APPROVAL_RULES_REPOSITORY, -} from './interfaces/approval-rules.repository.interface'; import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; -import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; export interface BookingContainerEvalInput { @@ -124,8 +118,6 @@ export class RuleEngineService { private readonly priorityConfigsRepo: IPriorityConfigsRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepo: IRatesRepository, - @Inject(APPROVAL_RULES_REPOSITORY) - private readonly approvalRulesRepo: IApprovalRulesRepository, @Inject(SHIPPING_LINES_REPOSITORY) private readonly shippingLinesRepo: IShippingLinesRepository, private readonly dataSource: DataSource, @@ -390,79 +382,6 @@ export class RuleEngineService { return violations; } - /** - * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. - */ - async ensureDefaultApprovalRules(): Promise { - for (const flag of [false, true] as const) { - const existing = await this.approvalRulesRepo.findChainForCargo(flag); - if (existing.length > 0) continue; - - const rows = DEFAULT_APPROVAL_RULE_ROWS.filter( - (r) => r.requiresDirectorApproval === flag, - ); - for (const row of rows) { - await this.approvalRulesRepo.create({ - requiresDirectorApproval: row.requiresDirectorApproval, - stepOrder: row.stepOrder, - requiredRole: row.requiredRole, - actionLabel: row.actionLabel, - blocksRole: row.blocksRole, - }); - } - } - } - - /** - * Instantiate booking_approval_step rows from approval_rules by freight type. - */ - async instantiateApprovalSteps( - bookingId: string, - options: { - freightType: 'CONTAINER' | 'BULK'; - cargoTypeId?: string | null; - }, - ): Promise { - await this.ensureDefaultApprovalRules(); - - let requiresDirectorApproval = false; - - if (options.cargoTypeId) { - const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); - if (!cargoType) { - throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); - } - requiresDirectorApproval = cargoType.requiresDirectorApproval; - } - - const chain = await this.approvalRulesRepo.findChainForCargo( - requiresDirectorApproval, - ); - - if (chain.length === 0) { - throw new BadRequestException( - `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, - ); - } - - const stepRepo = this.dataSource.getRepository(BookingApprovalStep); - const steps: BookingApprovalStep[] = []; - - for (const rule of chain) { - const step = stepRepo.create({ - bookingId, - approvalRuleId: rule.id, - stepOrder: rule.stepOrder, - requiredRole: rule.requiredRole, - blocksRole: rule.blocksRole ?? null, - status: 'PENDING', - }); - steps.push(await stepRepo.save(step)); - } - - return steps; - } - /** * Snapshot only the rates used in a booking's final price. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts index c22677608..843819dba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -1,5 +1,6 @@ import { PaginatedResponse } from '@edr/types'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; @@ -17,6 +18,7 @@ export class ApprovalRulesService { @Inject(APPROVAL_RULES_REPOSITORY) private readonly repository: IApprovalRulesRepository, private readonly displayOrder: DisplayOrderService, + private readonly dataSource: DataSource, ) {} /** List approval rules — standard paginated envelope with server-side search. */ @@ -29,6 +31,21 @@ export class ApprovalRulesService { return this.repository.findChainForCargo(requiresDirectorApproval); } + /** + * IAM position types, for the approval-step role picker. A chain step names + * the position type that must approve it, so this is the vocabulary an admin + * builds chains from. Read straight from the shared `iam` schema — the same + * pattern the freight API already uses for `iam.users`. + */ + async listPositionTypes(): Promise> { + const rows = await this.dataSource.query< + Array<{ key: string; label: string }> + >(`SELECT key, COALESCE(name->>'en', key) AS label + FROM iam.position_types + ORDER BY 2`); + return rows.map((row) => ({ label: row.label, value: row.key })); + } + /** Get an approval rule by ID. */ async findById(id: string): Promise { const entity = await this.repository.findById(id); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx deleted file mode 100644 index a38e2f0e5..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useMemo, useState } from "react"; -import { Check, ShieldCheck } from "lucide-react"; -import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; - -import { BookingConfirmDialog } from "./BookingConfirmDialog"; -import { useAuth } from "@/auth/useAuth"; -import { formatApprovalProgress } from "@/features/bookings/approval-progress"; -import { - buildApproveActionForStep, - canActOnApprovalStep, - getNextPendingApprovalStep, -} from "@/features/bookings/booking-actions.config"; -import type { useBookingMutations } from "@/hooks/bookings/useBookings"; -import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; -import { SectionCard } from "./detail/SectionCard"; - -type Mutations = ReturnType; - -interface ApprovalStepsCardProps { - booking: BookingDetail; - mutations: Mutations; -} - -/** Approval chain with inline approve on the current pending step. */ -export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { - const { user } = useAuth(); - const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingStep, setPendingStep] = useState(null); - - const steps = useMemo( - () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder), - [booking.approvalSteps], - ); - - const nextPending = getNextPendingApprovalStep(steps); - const summary = formatApprovalProgress(booking.status, steps); - const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null; - - const openApprove = (step: BookingApprovalStep) => { - setPendingStep(step); - setConfirmOpen(true); - }; - - const closeApprove = () => { - setConfirmOpen(false); - setPendingStep(null); - }; - - const runApprove = () => { - if (!pendingStep) return; - mutations.approveStep.mutate( - { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, - { onSuccess: () => closeApprove() }, - ); - }; - - const subtitle = - summary.detail || - (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` - : steps.length - ? "All steps complete" - : "Accept submission to begin"); - - return ( - <> - - {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} - - } - > - - {subtitle} - - - {steps.length === 0 ? ( - - Use Accept for approval in staff actions to instantiate steps. - - ) : ( - - {steps.map((step) => ( - - ))} - - )} - - - { - if (!open) closeApprove(); - else setConfirmOpen(true); - }} - action={pendingAction} - reference={booking.reference} - inputValue="" - onInputChange={() => {}} - onConfirm={runApprove} - isPending={mutations.approveStep.isPending} - /> - - ); -} - -function StepRow({ - step, - steps, - user, - isNext, - isPending, - onApprove, -}: { - step: BookingApprovalStep; - steps: BookingApprovalStep[]; - user: ReturnType["user"]; - isNext: boolean; - isPending: boolean; - onApprove: (step: BookingApprovalStep) => void; -}) { - const canApprove = canActOnApprovalStep(user, step, steps); - const statusColor = - step.status === "APPROVED" - ? "edr-green" - : step.status === "REJECTED" - ? "red" - : isNext - ? "edr-green" - : "gray"; - - return ( - - - - {step.stepOrder} - - - - {step.requiredRole} - - {step.remarks && ( - - {step.remarks} - - )} - - - - {canApprove && ( - - )} - - {step.status} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 13ac9577d..b2d260e37 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -6,7 +6,6 @@ import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; import { useAuth } from "@/auth/useAuth"; import { - getNextPendingApprovalStep, isAllocateAction, isClearanceNavAction, isContractNavAction, @@ -37,7 +36,6 @@ export function BookingActionsMenu({ status: row.status, paymentCurrency: row.paymentCurrency, reference: row.reference, - approvalSteps: row.approvalSteps, schedulingStatus: row.schedulingStatus, customsClearingEnabled: row.customsClearingEnabled, }; @@ -192,28 +190,6 @@ function ActionDialog({ }} isPending={flow.mutations.isPending || flow.detailLoading} confirmDisabled={flow.confirmDisabled} - extra={ - flow.detailLoading ? ( - - Loading approval steps… - - ) : pendingAction?.id === "approve" && - !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( - - No pending approval step. Refresh the page after staff accept, or reject the - booking. - - ) : null - } /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx deleted file mode 100644 index be051371c..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { formatApprovalProgress } from "@/features/bookings/approval-progress"; -import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; - -interface BookingApprovalProgressCellProps { - row: BookingListRow; -} - -export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) { - const summary = formatApprovalProgress(row.status, row.approvalSteps); - - return ( -
-

- {summary.label} -

- {summary.detail ? ( -

- {summary.detail} -

- ) : null} -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx deleted file mode 100644 index c33631d72..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { CheckCircle, Clock, XCircle } from "lucide-react"; -import { Group, Text, Badge, Timeline } from "@mantine/core"; - -import { SectionCard } from "./SectionCard"; -import { - approvalStatusColor, - formatDateTime, - type BookingApprovalStepView, -} from "./booking-detail.styles"; - -export interface BookingApprovalCardProps { - steps: BookingApprovalStepView[]; - approvedCount: number; -} - -/** Vertical timeline of the booking's approval chain. */ -export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) { - return ( - - {approvedCount} / {steps.length} approved - - } - > - - {steps.map((step) => ( - - ) : step.status === "REJECTED" ? ( - - ) : ( - - ) - } - title={ - - - {step.requiredRole.replace(/_/g, " ")} - - - {step.status} - - - } - > - {step.actionedAt && ( - - {formatDateTime(step.actionedAt)} - - )} - - ))} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index 03fcebaae..d3d3e3bc1 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -102,14 +102,6 @@ export interface BookingContainerView { }; } -export interface BookingApprovalStepView { - id: string; - stepOrder: number; - requiredRole: string; - status: string; - actionedAt?: string | null; -} - export interface BookingReviewNoteView { id: string; note: string; @@ -150,7 +142,6 @@ export interface BookingDetailView { cargoType?: BookingNamedRefView; shippingLine?: BookingNamedRefView; bookingContainers?: BookingContainerView[]; - approvalSteps?: BookingApprovalStepView[]; reviewNotes?: BookingReviewNoteView[]; files?: BookingFileView[]; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index b948cc5dc..f4a677991 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -10,7 +10,6 @@ export * from "./BookingLifecycleStepper"; export * from "./BookingRouteCard"; export * from "./BookingContainersCard"; export * from "./BookingContainerUnitsCard"; -export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; export * from "./BookingPaymentCountdownCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index 6db13efc6..be7111745 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -2,12 +2,11 @@ import { useCallback, useState } from "react"; import { getBookingActions, - getNextPendingApprovalStep, type BookingActionContext, type BookingActionDef, } from "@/features/bookings/booking-actions.config"; import { useAuth } from "@/auth/useAuth"; -import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; +import { useBookingMutations } from "@/hooks/bookings/useBookings"; /** A contract validity window must be a whole number of days, 1–365. */ function isValidValidityDays(value: string): boolean { @@ -24,22 +23,11 @@ export function useBookingActionDialog( const [selectedFile, setSelectedFile] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); - const needsApprovalSteps = - pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval"; + // Bookings no longer have an approval chain, so the dialog needs nothing + // beyond the list-row context it was handed. + const detailLoading = false; - const needsApprovalContext = - context.status === "PENDING_APPROVAL" || - context.status === "APPROVED_PENDING_SIGNATURE"; - - const { data: detail, isLoading: detailLoading } = useBookingDetail( - needsApprovalSteps || needsApprovalContext ? bookingId : undefined, - ); - - const mergedContext: BookingActionContext = { - ...context, - approvalSteps: detail?.approvalSteps ?? context.approvalSteps, - reference: detail?.reference ?? context.reference, - }; + const mergedContext: BookingActionContext = { ...context }; const { user } = useAuth(); const mutations = useBookingMutations(bookingId); @@ -86,24 +74,6 @@ export function useBookingActionDialog( { onSuccess }, ); break; - case "approve": { - const step = getNextPendingApprovalStep(mergedContext.approvalSteps); - if (!step) return; - mutations.approveStep.mutate( - { stepId: step.id, requiredRole: step.requiredRole }, - { onSuccess }, - ); - break; - } - case "rejectApproval": { - const step = getNextPendingApprovalStep(mergedContext.approvalSteps); - if (!step) return; - mutations.rejectStep.mutate( - { stepId: step.id, reason: inputValue.trim() }, - { onSuccess }, - ); - break; - } case "viewContract": break; case "startTransit": @@ -122,16 +92,12 @@ export function useBookingActionDialog( pendingAction, inputValue, selectedFile, - mergedContext.approvalSteps, mutations, closeDialog, ]); const confirmDisabled = mutations.isPending || - (needsApprovalSteps && detailLoading) || - (pendingAction?.id === "approve" && - !getNextPendingApprovalStep(mergedContext.approvalSteps)) || (pendingAction?.input === "file" && !selectedFile) || (pendingAction?.input === "reason" && !inputValue.trim()) || (pendingAction?.input === "note" && !inputValue.trim()) || diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index 0aeb624b3..7896da730 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -4,11 +4,10 @@ import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, - FileCheck, + Eye, FilePen, FileSignature, MessageSquareWarning, - RefreshCw, ShieldCheck, XCircle, Zap, @@ -16,8 +15,10 @@ import { import type { Freight } from "@edr/types"; import { api } from "@/services/api"; +import { contractsService } from "@/services/contracts.service"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal"; +import { ContractPreviewModal } from "@/components/contracts/ContractPreviewModal"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; /** Dropdown-settings code holding the admin-configured contract validity days. */ @@ -51,11 +52,21 @@ export function ContractActionsToolbar({ const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); + const [previewOpen, setPreviewOpen] = useState(false); const [changesOpen, setChangesOpen] = useState(false); const [changesNote, setChangesNote] = useState(""); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(""); + // Whether the document is editable depends on WHO is viewing — only the + // approver whose turn it is may edit — so the server decides, not the client. + const { data: draft } = useQuery({ + queryKey: ["contracts", contract.id, "document-draft"], + queryFn: () => contractsService.getContractDocumentDraft(contract.id), + enabled: contract.status === "PENDING_APPROVAL", + staleTime: 0, + }); + // Admin-configured validity durations (days) for the accept dialog. Staff can // only pick one of these — no free-typing. Read-only setting, fetched once. const { data: validitySetting, isLoading: validityLoading } = useQuery({ @@ -87,18 +98,11 @@ export function ContractActionsToolbar({ } const canAccept = status === "SUBMITTED"; - // While the contract is PENDING_APPROVAL and NO approver has acted yet, staff - // can edit this contract's articles and (re)generate its PDF. The first - // approval action locks the document. - const docLocked = - status !== "PENDING_APPROVAL" || - (contract.approvalSteps ?? []).some((s) => s.status !== "PENDING"); - const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked; - const documentGenerated = Boolean(contract.contractGeneratedAt); - // Legacy fallback: if a contract ever lands on APPROVED without a document - // (older flow), still offer a manual generate that moves it to CONTRACT_READY. - const needsManualGenerate = - status === "APPROVED" && !contract.contractGeneratedAt; + // The document stays editable for the whole approval chain, but only by the + // approver whose turn it is. The server resolves that against the caller's + // position type; the client cannot derive it. + const canEditDocument = Boolean(draft?.editableByMe); + const inApproval = status === "PENDING_APPROVAL"; // Signing now happens on the contract VIEW page (staff must open and read the // generated contract before signing) — no sign button in this toolbar. const canViewContract = @@ -154,56 +158,41 @@ export function ContractActionsToolbar({ )} - {canEditGenerate && ( + {inApproval && ( <> - {documentGenerated - ? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval." - : "Review the contract document, edit its articles if needed, then generate it so approvers can review."} + {canEditDocument + ? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves." + : draft?.nextApproverRole + ? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.` + : "Awaiting approval."} - + {canEditDocument && ( + + )} )} - {needsManualGenerate && ( - - )} - {canViewContract && ( - - - { setArticles((prev) => { @@ -215,7 +217,9 @@ export function ContractDocumentEditorModal({ icon={locked ? : } > {locked - ? "This document is locked — an approver has already acted, so it can no longer be edited." + ? draft?.nextApproverRole + ? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.` + : "This document can no longer be edited — the contract has advanced beyond approval." : "Edits apply to THIS contract only. The six shared templates are never changed."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractPreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractPreviewModal.tsx new file mode 100644 index 000000000..c165645b7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractPreviewModal.tsx @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query"; +import { Alert, Group, Loader, Modal, Text } from "@mantine/core"; +import { Info } from "lucide-react"; + +import { contractsService } from "@/services/contracts.service"; + +interface ContractPreviewModalProps { + opened: boolean; + onClose: () => void; + contractId: string; +} + +/** + * Live preview of the contract document. Renders server-side HTML, not the + * stored PDF — the PDF is only produced once the final approver approves, so + * before that this is the document. Served in an iframe so the contract's own + * styles stay sandboxed away from the app. + */ +export function ContractPreviewModal({ + opened, + onClose, + contractId, +}: ContractPreviewModalProps) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["contracts", contractId, "contract-view"], + queryFn: () => contractsService.getContractView(contractId), + enabled: opened, + // The document changes as approvers edit it, so never serve a stale render. + staleTime: 0, + }); + + return ( + + } + color="blue" + variant="light" + mb="sm" + p="xs" + > + + Draft preview. The PDF is generated automatically once the final + approver approves. + + + + {isLoading ? ( + + + + Rendering document… + + + ) : isError || !data?.html ? ( + + The document could not be rendered. Check that the contract has a + template and try again. + + ) : ( +