diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5f2d2b8ee..90aa517ff 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -23,6 +23,10 @@ SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= DEFAULT_PASSWORD=password@tria +# Freight org + staff (bookings / rule-engine IAM) +SEED_EDR_ORG=true +SEED_FREIGHT_STAFF=true + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a4ecf321e..8e05a6b0e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -24,12 +24,14 @@ import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { FreightAuthModule } from "./modules/auth/freight-auth.module"; import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; @Module({ imports: [ @@ -70,19 +72,22 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; RuleEngineModule, BackofficeModule, DemoPermissionsModule, + FreightAuthModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); + await this.freightStaffUsersSeeder.run(); } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts new file mode 100644 index 000000000..2ebae8175 --- /dev/null +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -0,0 +1,17 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +export const BookingStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + FreightPermissionGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); + +export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts new file mode 100644 index 000000000..68def6440 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { hasFreightPermission } from './freight-permission.util'; + +export function FreightPermissionGuard( + permissions: string[], +): Type { + @Injectable() + class FreightPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + + if (permissions.some((p) => hasFreightPermission(user, p))) { + return true; + } + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return FreightPermissionsGuard; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts new file mode 100644 index 000000000..d6e258654 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -0,0 +1,99 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const SUPER_ADMIN_ROLE = 'super_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { + position?: { permissions?: PermissionLike[] }; + delegatedPositions?: { permissions?: PermissionLike[] }[]; + } + | { + positions?: { permissions?: PermissionLike[] }[]; + }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE); +} + +/** Flat permission keys from JWT / session user (roles + position permissions). */ +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) { + return [...keys]; + } + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasFreightPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertFreightPermission( + user: TCurrentUser | MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasFreightPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} + +const APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, + CEO: FREIGHT_PERMS.bookings.approveCeo, +}; + +export function assertCanApproveBookingStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isSuperAdmin(user)) return; + const perm = APPROVE_ROLE_PERMISSION[requiredRole]; + if (!perm) { + throw new ForbiddenException(`Unknown approval role: ${requiredRole}`); + } + assertFreightPermission(user, perm); +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts new file mode 100644 index 000000000..12ba30e11 --- /dev/null +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FREIGHT_PERMS, + type RuleEngineResourceSlug, +} from '../seed/freight-permissions.registry'; + +export const RuleEngineView = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), + ); + +export const RuleEngineManage = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), + ); diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 399559fb2..9e0acc6fb 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -1,57 +1,116 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; @Injectable() export class ContractPdfService { private readonly logger = new Logger(ContractPdfService.name); async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + try { const puppeteer = await import('puppeteer'); - const browser = await puppeteer.default.launch({ + const launchOptions: import('puppeteer').LaunchOptions = { headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], - }); + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); try { const page = await browser.newPage(); - await page.setContent(html, { waitUntil: 'load' }); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { + waitUntil: 'load', + timeout: 60_000, + }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 400)); + const pdf = await page.pdf({ format: 'A4', printBackground: true, - margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }, + displayHeaderFooter: true, + headerTemplate: '', + footerTemplate: + '
Page of
', + margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, }); - return Buffer.from(pdf); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error( + `Puppeteer produced invalid PDF (${buffer.length} bytes)`, + ); + } + this.logger.log( + `Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; } finally { await browser.close(); } } catch (err) { - this.logger.warn( - `Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`, + this.logger.error( + `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, + ); + throw new InternalServerErrorException( + 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); - return this.fallbackPdfBuffer(html); } } - /** Minimal valid PDF when Chromium is unavailable. */ - private fallbackPdfBuffer(html: string): Buffer { - const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000); - const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); - const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`; - const len = stream.length; - const pdf = `%PDF-1.4 -1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj -2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj -3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj -4 0 obj<< /Length ${len} >>stream -${stream} -endstream endobj -5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj -xref -0 6 -0000000000 65535 f -trailer<< /Size 6 /Root 1 0 R >> -startxref -0 -%%EOF`; - return Buffer.from(pdf, 'utf-8'); + private injectPdfPrintStyles(html: string): string { + if (html.includes('contract-pdf-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((p) => existsSync(p)); + } + + private isValidPdf(buffer: Buffer): boolean { + return ( + buffer.length >= MIN_VALID_PDF_BYTES && + buffer.subarray(0, 5).toString('ascii') === '%PDF-' + ); } } diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts index f9dc0b7aa..dd961a14b 100644 --- a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -55,7 +55,7 @@ export class ContractPricingScheduleBuilder { })), totalAmount, currency, - equipmentReturn: booking.equipmentReturn ?? undefined, + equipmentReturn: booking.equipmentReturn ?? '—', originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—', destinationLabel: booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index 421621808..a66a516c0 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -23,6 +23,31 @@ describe('ContractRendererService', () => { phone: '+251900000000', email: 'test@example.com', tinNumber: '1234567890', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'SGTD', + destinationLabel: 'Modjo', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: 'Rail transport', + scheduledDate: '1 January 2026', + contractType: 'NEW', + cargoDescription: 'Container cargo', + totalWeightVgm: '24 tons', + equipmentReturn: 'RETURN', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', }, pricing: { lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }], diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 2eab2a01b..676e4f4ba 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -32,6 +32,31 @@ export interface ContractViewModel { phone: string; email: string; tinNumber: string; + vatNumber: string; + fanNumber: string; + businessLicense: string; + }; + provider: { + name: string; + address: string; + phone: string; + email: string; + tinNumber: string; + }; + schedule: { + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + serviceType: string; + scheduledDate: string; + contractType: string; + cargoDescription: string; + totalWeightVgm: string; + equipmentReturn: string; + hazardousLabel: string; + firstMilePickupAddress: string; + lastMileDeliveryAddress: string; }; pricing: PricingSchedule; signatures: ContractSignatureView[]; @@ -81,19 +106,24 @@ export class ContractViewModelBuilder { }), contractYear: new Date().getFullYear(), client: { - // companyName: booking.customer?.companyName ?? 'Client', - // companyAddress: booking.customer?.companyAddress ?? '—', - // companyLocation: booking.customer?.companyLocation ?? '—', - // phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—', - // email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—', - // tinNumber: booking.customer?.tinNumber ?? '—', companyName: booking.company?.name ?? 'Client', - companyAddress: booking.company?.address ?? '—', - companyLocation: booking.company?.country ?? '—', - phone: booking.company?.phone ?? '—', - email: booking.company?.email ?? '—', - tinNumber: booking.company?.tin ?? '—', + companyAddress: this.valueOrDash(booking.company?.address), + companyLocation: this.valueOrDash(booking.company?.country), + phone: this.valueOrDash(booking.company?.phone), + email: this.valueOrDash(booking.company?.email), + tinNumber: this.valueOrDash(booking.company?.tin), + vatNumber: this.valueOrDash(booking.company?.vatNumber), + fanNumber: this.valueOrDash(booking.company?.fanNumber), + businessLicense: this.valueOrDash(booking.company?.businessLicense), }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: this.buildSchedule(booking), pricing, signatures, canSignCustomer: @@ -117,8 +147,61 @@ export class ContractViewModelBuilder { return { role: row.signerRole, signerDisplayName: row.signerDisplayName, - signedAt: row.signedAt.toISOString(), + signedAt: this.formatDate(row.signedAt), signatureImageUrl: row.signatureFile?.url ?? null, }; } + + private buildSchedule(booking: Booking): ContractViewModel['schedule'] { + const cargoName = + booking.freightType === 'BULK' + ? booking.cargoFreeText || + booking.cargoType?.cargoTypeName || + 'Bulk commodity' + : booking.cargoType?.cargoTypeName || 'Container cargo'; + const totalWeight = Number(booking.cargoTotalWeightVgm || 0); + + return { + originLabel: this.yardLabel(booking.originYard), + destinationLabel: this.yardLabel(booking.destinationYard), + tradeDirection: this.valueOrDash(booking.tradeDirection), + freightType: this.valueOrDash(booking.freightType), + serviceType: this.valueOrDash( + booking.serviceType?.serviceName ?? booking.serviceType?.code, + ), + scheduledDate: this.formatDate(booking.scheduledDate), + contractType: this.valueOrDash(booking.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: + totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—', + equipmentReturn: this.valueOrDash(booking.equipmentReturn), + hazardousLabel: booking.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash( + booking.firstMilePickupAddress, + ), + lastMileDeliveryAddress: this.valueOrDash( + booking.lastMileDeliveryAddress, + ), + }; + } + + private yardLabel(yard?: { label?: string; code?: string } | null): string { + return this.valueOrDash(yard?.label ?? yard?.code); + } + + private formatDate(value?: Date | string | null): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + private valueOrDash(value?: string | number | null): string { + if (value === undefined || value === null || value === '') return '—'; + return String(value); + } } diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs index ebdfa744f..d8b3d3fe1 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs @@ -1,8 +1,11 @@

Article 1: Objective and Scope of Services

-

Objective: {{template.article1.objective}}

+

+ 1.1 Objective. + {{template.article1.objective}} +

{{#if template.article1.scope.length}} -

Scope:

+

1.2 Scope of Services.

    {{#each template.article1.scope}}
  1. {{this}}
  2. diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index d56c8b8a6..cb3440739 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -1,22 +1,31 @@

    Article 5: Contract Price and Terms of Payment

    Contract Price

    -

    Corridor: {{pricing.originLabel}} → {{pricing.destinationLabel}}

    +

    + The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate + schedule, and any approved operational surcharges. +

    + + + + + + + + + + + + + + + +
    Corridor{{pricing.originLabel}} → {{pricing.destinationLabel}}Currency{{pricing.currency}}
    Payment currency{{paymentArticle}}Equipment return{{pricing.equipmentReturn}}
    {{#if pricing.equipmentReturn}}

    Equipment return: {{pricing.equipmentReturn}}

    {{/if}} - {{#if pricing.containerLines.length}} - - - - - - {{#each pricing.containerLines}} - - {{/each}} - -
    Container typeQuantityVGM / unit (t)
    {{label}}{{quantity}}{{vgmPerUnitTons}}
    - {{/if}} + +

    Charges

    @@ -29,6 +38,10 @@ {{/each}} + {{#if pricing.surcharges.length}} + + + {{#each pricing.surcharges}} @@ -36,12 +49,18 @@ {{/each}} - + {{/if}} +
    ItemDescriptionAmount
    {{currency}} {{amount}}
    Surcharges and Adjustments
    {{label}}{{currency}} {{amount}}
    Total contract value {{pricing.currency}} {{pricing.totalAmount}}

    Terms of payment

    -

    All payments shall be made in accordance with EDR policy in {{paymentArticle}}, unless otherwise agreed in writing.

    +

    + Unless otherwise agreed in writing, the Client shall settle the contract value in + {{paymentArticle}} before the service is performed and in accordance with EDR payment + instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the + responsibility of the Client where applicable. +

    diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs index e48e1e453..8a82c72d4 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs @@ -1,5 +1,6 @@
    -

    Article 2: Obligations of the Client (summary)

    +

    Article 2: Obligations of the Client

    +

    The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:

      {{#each template.clientObligations}}
    1. {{this}}
    2. @@ -8,7 +9,8 @@
    -

    Article 3: Obligations of the Service Provider (summary)

    +

    Article 3: Obligations of the Service Provider

    +

    EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:

      {{#each template.providerObligations}}
    1. {{this}}
    2. diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs index ed901a808..adb1ac797 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs @@ -1,5 +1,6 @@

      Article 6: Contract Documents

      +

      The following documents form part of this Agreement and shall be read together with the signed contract:

        {{#each template.contractDocuments}}
      1. {{this}}
      2. diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs new file mode 100644 index 000000000..9531ac4ae --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs @@ -0,0 +1,65 @@ +
        +

        Booking Schedule and Commercial Summary

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Trade direction{{schedule.tradeDirection}}
        Freight type{{schedule.freightType}}Service type{{schedule.serviceType}}
        Scheduled date{{schedule.scheduledDate}}Contract type{{schedule.contractType}}
        Cargo{{schedule.cargoDescription}}Total VGM{{schedule.totalWeightVgm}}
        Equipment return{{schedule.equipmentReturn}}Hazardous cargo{{schedule.hazardousLabel}}
        First mile{{schedule.firstMilePickupAddress}}Last mile{{schedule.lastMileDeliveryAddress}}
        + + {{#if pricing.containerLines.length}} +

        Container Details

        + + + + + + + + + + {{#each pricing.containerLines}} + + + + + + {{/each}} + +
        Container typeQuantityVGM / unit (tons)
        {{label}}{{quantity}}{{vgmPerUnitTons}}
        + {{/if}} +
        diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs index 6785d1be6..d8c1d1287 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs @@ -1,4 +1,12 @@

        Article 4: Force Majeure

        -

        Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.

        +

        + Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control, + including natural disaster, war, civil unrest, government restriction, railway interruption, port closure, + or other force majeure events interpreted under the Ethiopian Civil Code. +

        +

        + The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the + effect of the force majeure event on the performance of this Agreement. +

        diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs index ed5b7048d..05dd450e4 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -1,30 +1,44 @@
        -

        Service Provider (EDR)

        +

        For the Service Provider

        +

        {{provider.name}}

        {{#if hasStaffSignature}} {{#each signatures}} {{#if (eq role "STAFF")}} - {{#if signatureImageUrl}}Staff signature{{/if}} -

        {{signerDisplayName}}

        -

        Signed: {{signedAt}}

        +
        + {{#if signatureImageUrl}}Staff signature{{/if}} +
        +

        Name: {{signerDisplayName}}

        +

        Role: Authorized EDR representative

        +

        Date: {{signedAt}}

        {{/if}} {{/each}} {{else}} -

        Authorized representative (pending)

        +
        Signature pending
        +

        Name: Authorized representative

        +

        Role: EDR representative

        +

        Date:

        {{/if}}
        -

        Client — {{client.companyName}}

        +

        For the Client

        +

        {{client.companyName}}

        {{#if hasCustomerSignature}} {{#each signatures}} {{#if (eq role "CUSTOMER")}} - {{#if signatureImageUrl}}Customer signature{{/if}} -

        {{signerDisplayName}}

        -

        Signed: {{signedAt}}

        +
        + {{#if signatureImageUrl}}Customer signature{{/if}} +
        +

        Name: {{signerDisplayName}}

        +

        Role: Authorized client representative

        +

        Date: {{signedAt}}

        {{/if}} {{/each}} {{else}} -

        Client representative (pending)

        +
        Signature pending
        +

        Name: Client representative

        +

        Role: Authorized client representative

        +

        Date:

        {{/if}}
        diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 601768185..43a5495ec 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -1,22 +1,258 @@ diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs index 7e6069e19..75f795bce 100644 --- a/apps/edr-freight-api/src/contracts/templates/generic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -6,25 +6,86 @@ {{> styles}} -
        -

        Contract Agreement

        -

        {{template.title}}

        -

        Contract Ref No: {{reference}}

        -

        Year: {{contractYear}}

        -
        +
        +
        +
        +
        EDR
        +
        +

        Ethio-Djibouti Standard Gauge Railway Share Company

        +

        Freight Transport Contract

        +
        +
        -

        This Contract Agreement is made on {{contractDate}}.

        -

        Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and {{client.companyName}} at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.

        +
        +

        Contract Agreement

        +

        {{template.title}}

        +

        {{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}

        +
        -

        Whereas

        -

        {{template.whereas}}

        -

        Now therefore, the parties agree as follows:

        + + + + + + + + + + + + + +
        Contract Ref No.{{reference}}Contract Year{{contractYear}}
        Contract Date{{contractDate}}Status{{status}}
        +
        - {{> article1}} - {{> articles_obligations}} - {{> article5_pricing}} - {{> force_majeure}} - {{> contract_documents}} - {{> signatures_block}} +
        +

        Parties to the Agreement

        +

        + This Contract Agreement is made on {{contractDate}} between the Service Provider and the Client named below. +

        + +
        +
        +

        Service Provider

        +

        {{provider.name}}

        +
        +
        Address
        {{provider.address}}
        +
        Phone
        {{provider.phone}}
        +
        Email
        {{provider.email}}
        +
        TIN
        {{provider.tinNumber}}
        +
        +
        +
        +

        Client

        +

        {{client.companyName}}

        +
        +
        Address
        {{client.companyAddress}}
        +
        Location
        {{client.companyLocation}}
        +
        Phone
        {{client.phone}}
        +
        Email
        {{client.email}}
        +
        TIN
        {{client.tinNumber}}
        +
        VAT
        {{client.vatNumber}}
        +
        FAN
        {{client.fanNumber}}
        +
        Business license
        {{client.businessLicense}}
        +
        +
        +
        +
        + + {{> contract_schedule}} + +
        +

        Whereas

        +

        {{template.whereas}}

        +

        Now therefore, the parties agree as follows:

        +
        + + {{> article1}} + {{> articles_obligations}} + {{> force_majeure}} + {{> article5_pricing}} + {{> contract_documents}} + {{> signatures_block}} +
        diff --git a/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts new file mode 100644 index 000000000..a1830c370 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface { + name = 'AddBlocksRoleToApprovalStep1749600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP COLUMN IF EXISTS blocks_role; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts new file mode 100644 index 000000000..f972f50c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed ITMLS US-06 approval chains if missing (standard + bulk). + */ +export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface { + name = 'SeedDefaultApprovalRules1749700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Keep seeded rules on rollback to avoid breaking in-flight bookings. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts new file mode 100644 index 000000000..a689ba24e --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { FreightMeController } from './freight-me.controller'; +import { FreightMeService } from './freight-me.service'; + +@Module({ + controllers: [FreightMeController], + providers: [FreightMeService], +}) +export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts new file mode 100644 index 000000000..b85ecea84 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FreightMeService } from './freight-me.service'; + +@ApiTags('auth') +@Controller('me') +@ApiBearerAuth() +export class FreightMeController { + constructor(private readonly freightMeService: FreightMeService) {} + + @Get() + @UseGuards(JwtGuard) + @ApiOperation({ + summary: 'Current user with flat permissionKeys for backoffice gating', + }) + getMe(@CurrentUser() user: TCurrentUser) { + return this.freightMeService.getEnrichedProfile(user); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts new file mode 100644 index 000000000..50c90213b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + collectPermissionKeys, + isSuperAdmin, +} from '../../common/freight-permission.util'; +import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; + +@Injectable() +export class FreightMeService { + getEnrichedProfile(user: TCurrentUser) { + const employee = user.employee + ? [ + { + id: user.employee.id, + organizationId: user.employee.organizationId, + unitId: user.employee.unitId, + name: user.employee.name, + positions: user.employee.position + ? [ + { + id: user.employee.position.id, + key: user.employee.position.key, + employeePositionId: user.employee.position.employeePositionId, + name: user.employee.position.name, + isDelegate: user.employee.position.isDelegate, + parentPositionId: user.employee.position.parentPositionId, + permissions: user.employee.position.permissions ?? [], + }, + ] + : [], + }, + ] + : []; + + const permissionKeys = collectPermissionKeys(user); + + return { + id: user.id, + email: user.email, + name: user.name, + username: user.username, + phoneNumber: user.phoneNumber, + userType: user.userType, + status: user.status, + hasFinishedRegistration: user.hasFinishedRegistration, + hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding, + roles: user.roles, + permissions: user.permissions, + employee, + permissionKeys, + isSuperAdmin: isSuperAdmin(user), + permissionsCatalog: PERMISSIONS_CATALOG, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 4eadff0fc..ab8a8dfa7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -12,6 +12,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -68,7 +69,7 @@ export class BookingContractService { async getContractView(bookingId: string): Promise { const { view } = await this.viewModelBuilder.build(bookingId); - await this.enrichSignatureUrls(view.signatures); + await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); return { bookingId: view.bookingId, @@ -90,33 +91,8 @@ export class BookingContractService { assertBookingStatus(booking, ['APPROVED']); const templateKey = this.templateResolver.resolve(booking); - const { view } = await this.viewModelBuilder.build(bookingId); - view.templateKey = templateKey; - view.template = getTemplateMeta(templateKey); - - const html = this.renderer.render(view); - const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); const summary = this.buildContractSummary(booking); - - const file: Express.Multer.File = { - fieldname: 'contract', - originalname: `contract-${booking.reference}.pdf`, - encoding: '7bit', - mimetype: 'application/pdf', - size: pdfBuffer.length, - buffer: pdfBuffer, - stream: Readable.from(pdfBuffer), - destination: '', - filename: '', - path: '', - }; - - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'contract', - file, - }); + await this.upsertContractPdf(bookingId, booking.reference, templateKey); const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { @@ -129,18 +105,15 @@ export class BookingContractService { } async streamContract(bookingId: string) { - try { - const record = await this.filesService.findByCode( - bookingId, - 'bookings', - 'contract', - ); - return this.filesService.streamById(record.id); - } catch { - throw new NotFoundException( - 'Contract document not found. Generate the contract first.', - ); - } + const booking = await this.requireBooking(bookingId); + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const record = await this.upsertContractPdf( + bookingId, + booking.reference, + templateKey, + ); + return this.filesService.streamById(record.id); } async signContract( @@ -218,33 +191,84 @@ export class BookingContractService { } const updated = await this.bookingsRepository.update(bookingId, updates as never); + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); return updated!; } async getSignatures(bookingId: string) { const rows = await this.bookingsRepository.findContractSignatures(bookingId); const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); - await this.enrichSignatureUrls(views); + await this.inlineSignatureImages(views); return { signatures: views }; } - private async enrichSignatureUrls( + private async upsertContractPdf( + bookingId: string, + reference: string, + templateKey: string, + ): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + view.templateKey = templateKey; + view.template = getTemplateMeta(templateKey); + await this.inlineSignatureImages(view.signatures); + + const html = this.renderer.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${reference}.pdf`, + encoding: '7bit', + mimetype: 'application/pdf', + size: pdfBuffer.length, + buffer: pdfBuffer, + stream: Readable.from(pdfBuffer), + destination: '', + filename: '', + path: '', + }; + + return this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + } + + private async inlineSignatureImages( signatures: Array<{ signatureImageUrl?: string | null }>, ): Promise { for (const sig of signatures) { if (!sig.signatureImageUrl) continue; try { - const objectName = this.extractObjectName(sig.signatureImageUrl); - sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600); + if (sig.signatureImageUrl.startsWith('data:')) continue; + const objectName = this.minioService.getObjectNameFromUrl( + sig.signatureImageUrl, + ); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + sig.signatureImageUrl = `data:image/png;base64,${buffer.toString( + 'base64', + )}`; } catch { /* keep original url */ } } } - private extractObjectName(url: string): string { - const parts = url.split('/'); - return parts.slice(4).join('/'); + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); } private decodeSignatureImage(base64: string): Buffer { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts new file mode 100644 index 000000000..ce15125c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -0,0 +1,54 @@ +export const BOOKING_LIST_TAB_KEYS = [ + 'all', + 'intake', + 'in_approval', + 'approved_contract', + 'payment', + 'operations', + 'completed', + 'closed', +] as const; + +export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number]; + +export const BOOKING_LIST_TABS: ReadonlyArray<{ + key: BookingListTabKey; + statuses: readonly string[] | null; +}> = [ + { key: 'all', statuses: null }, + { key: 'intake', statuses: ['SUBMITTED'] }, + { + key: 'in_approval', + statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + }, + { + key: 'approved_contract', + statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + }, + { key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, + { + key: 'operations', + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], + }, + { key: 'completed', statuses: ['COMPLETED'] }, + { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, +]; + +export function mapStatusCountsToTabs( + statusCounts: Record, +): Record { + const result = {} as Record; + + for (const tab of BOOKING_LIST_TABS) { + if (!tab.statuses?.length) { + result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0); + continue; + } + result[tab.key] = tab.statuses.reduce( + (sum, status) => sum + (statusCounts[status] ?? 0), + 0, + ); + } + + return result; +} 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 new file mode 100644 index 000000000..c0a865949 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -0,0 +1,68 @@ +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { Booking } from './entities/booking.entity'; + +export interface BookingNextStep { + action: string; + description: string; + requiredRole?: string; +} + +export function computeNextStep( + booking: Pick, + nextPendingStep?: Pick | null, +): BookingNextStep | null { + const { status } = booking; + + switch (status) { + case 'SUBMITTED': + return { + action: 'ACCEPT_INTAKE', + description: 'Line Staff must accept the submission to begin approval', + }; + case 'PENDING_APPROVAL': + case 'APPROVED_PENDING_SIGNATURE': + if (nextPendingStep) { + return { + action: 'APPROVE_STEP', + requiredRole: nextPendingStep.requiredRole, + description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`, + }; + } + return { + action: 'APPROVE_STEP', + description: 'Complete the pending approval step in sequence', + }; + case 'APPROVED': + return { + action: 'GENERATE_CONTRACT', + description: 'Generate the contract document', + }; + case 'CONTRACT_READY': + return { + action: 'CUSTOMER_SIGN', + description: 'Customer must sign the contract', + }; + case 'SIGNED_CUSTOMER': + return { + action: 'STAFF_SIGN', + description: 'Internal staff must counter-sign the contract', + }; + case 'FULLY_EXECUTED': + return { + action: 'PAY', + description: 'Complete in-app payment', + }; + case 'PAID': + return { + action: 'START_TRANSIT', + description: 'Mark shipment as in transit', + }; + case 'IN_TRANSIT': + return { + action: 'COMPLETE', + description: 'Mark shipment complete', + }; + default: + return null; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 80476ead2..4697728e7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -1,127 +1,47 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { FilesService } from '../files/files.service'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -const PROOF_MAX_BYTES = 5 * 1024 * 1024; -const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png']; +export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {} @Injectable() export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - private readonly filesService: FilesService, - ) {} + constructor(private readonly bookingsRepository: BookingsRepository) {} - async generatePnr(bookingId: string): Promise { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED']); - - if (booking.paymentCurrency !== 'ETB') { - throw new BadRequestException('PNR generation is only for ETB payers'); - } - - const year = new Date().getFullYear(); - const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`; - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'PNR_GENERATED', - pnrCode, - paymentStatus: 'PNR_GENERATED', - } as never); - return updated!; - } - - async submitPaymentProof( + async pay( bookingId: string, - file: Express.Multer.File, - ): Promise { + ): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['FULLY_EXECUTED']); - if (booking.paymentCurrency !== 'USD') { - throw new BadRequestException('Payment proof upload is only for USD payers'); - } - - this.validateProofFile(file); - - await this.filesService.upload({ - resourceId: bookingId, - resource: 'bookings', - code: 'payment_proof', - file, - }); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'PAYMENT_VERIFICATION_IN_PROGRESS', - paymentStatus: 'VERIFICATION_IN_PROGRESS', - } as never); - return updated!; - } - - async verifyPayment(bookingId: string): Promise { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']); + const receipt = this.buildMockReceipt(booking); const updated = await this.bookingsRepository.update(bookingId, { status: 'PAID', paymentStatus: 'PAID', } as never); - return updated!; + + return { booking: updated!, receipt }; } - async handleBankCallback(pnrCode: string): Promise { - const booking = await this.bookingsRepository.findByPnrCode(pnrCode); - if (!booking) { - throw new NotFoundException(`No booking found for PNR ${pnrCode}`); - } + private buildMockReceipt(booking: Booking): InAppPaymentReceipt { + const timestamp = Date.now(); + const isEtb = booking.paymentCurrency === 'ETB'; + const prefix = isEtb ? 'TB' : 'CARD'; + const provider = isEtb ? 'TELEBIRR' : 'CARD'; - if (booking.status !== 'PNR_GENERATED') { - throw new BadRequestException( - `Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`, - ); - } - - const updated = await this.bookingsRepository.update(booking.id, { - status: 'PAID', - paymentStatus: 'PAID', - } as never); - return updated!; - } - - async getPaymentRequestLetter( - bookingId: string, - ): Promise<{ buffer: Buffer; filename: string }> { - const booking = await this.requireBooking(bookingId); - const body = [ - 'PAYMENT REQUEST LETTER (STUB)', - `Reference: ${booking.reference}`, - `Amount: ${booking.totalAmount} ${booking.paymentCurrency}`, - 'Pay at your bank and upload stamped proof.', - ].join('\n'); return { - buffer: Buffer.from(body, 'utf-8'), - filename: `payment-request-${booking.reference}.txt`, + success: true, + provider, + providerRef: `${prefix}-${booking.reference}-${timestamp}`, + amount: booking.totalAmount, + currency: booking.paymentCurrency, + paidAt: new Date().toISOString(), }; } - private validateProofFile(file: Express.Multer.File): void { - if (!file?.buffer?.length) { - throw new BadRequestException('Payment proof file is required'); - } - if (file.size > PROOF_MAX_BYTES) { - throw new BadRequestException('Payment proof must be 5MB or less'); - } - if (!PROOF_MIMES.includes(file.mimetype)) { - throw new BadRequestException('Payment proof must be PDF, JPG, or PNG'); - } - } - private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findById(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); 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 ad76fa7a3..56ab6394b 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 @@ -1,10 +1,13 @@ import { BadRequestException, forwardRef, Inject, Injectable } 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 { RuleEngineService } from '../rule-engine/rule-engine.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; +import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; @@ -60,6 +63,16 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** 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): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['SUBMITTED']); @@ -103,13 +116,23 @@ export class BookingTransitionService { stepId: string, actorId: string, requiredRole: string, + authUser?: TCurrentUser, ): Promise { - const booking = await this.bookingsService.findById(bookingId); + 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, @@ -131,7 +154,7 @@ export class BookingTransitionService { ); } - const blocksRole = step.approvalRule?.blocksRole; + const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); } @@ -271,6 +294,7 @@ export class BookingTransitionService { async enrichBookingResponse(booking: Booking): Promise { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, @@ -279,10 +303,17 @@ export class BookingTransitionService { const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); + const nextPending = + booking.status === 'PENDING_APPROVAL' || + booking.status === 'APPROVED_PENDING_SIGNATURE' + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + const nextStep = computeNextStep(booking, nextPending); return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, + nextStep, }; } } 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 03c5cfde4..2e01bdb7c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - Header, HttpCode, Param, ParseUUIDPipe, @@ -12,13 +11,13 @@ import { Query, Request, Res, - StreamableFile, UploadedFiles, - UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, @@ -31,13 +30,13 @@ import { import type { Response } from 'express'; import { BookingContractService } from './booking-contract.service'; -import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { @@ -65,7 +64,6 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, - private readonly paymentService: BookingPaymentService, ) {} @Post() @@ -104,6 +102,13 @@ export class BookingsController { return this.bookingsService.findAll(filter); } + @Get('list-summary') + @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @ApiOkResponse({ type: BookingListSummaryDto }) + findListSummary(@Query() filter: FilterBookingDto) { + return this.bookingsService.getListSummary(filter); + } + @Get('queues/:queue') @ApiOperation({ summary: 'List bookings for a dashboard queue', @@ -162,7 +167,7 @@ export class BookingsController { } @Post(':id/staff/request-changes') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: 'Staff return booking for customer updates' }) async requestChanges( @Param('id', ParseUUIDPipe) id: string, @@ -178,7 +183,7 @@ export class BookingsController { } @Post(':id/staff/accept') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) async acceptIntake( @Param('id', ParseUUIDPipe) id: string, @@ -192,7 +197,7 @@ export class BookingsController { } @Post(':id/staff/reject') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.reject) @ApiOperation({ summary: 'Staff final reject' }) async staffReject( @Param('id', ParseUUIDPipe) id: string, @@ -208,25 +213,30 @@ export class BookingsController { } @Post(':id/approval-steps/:stepId/approve') - @UseGuards(JwtGuard) + @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: AuthUserPayload, + @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') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) @ApiOperation({ summary: 'Reject at approval step' }) async rejectStep( @Param('id', ParseUUIDPipe) id: string, @@ -244,7 +254,7 @@ export class BookingsController { } @Post(':id/contract/generate') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.generateContract) @ApiOperation({ summary: 'Generate contract PDF from template' }) async generateContract(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); @@ -262,22 +272,23 @@ export class BookingsController { @ApiOperation({ summary: 'Download contract PDF' }) async downloadContractDocument( @Param('id', ParseUUIDPipe) id: string, - @Res({ passthrough: true }) res: Response, - ) { + @Res() res: Response, + ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.set({ - 'Content-Type': record.mimeType ?? 'application/pdf', - 'Content-Disposition': `attachment; filename="${record.name}"`, - }); - return new StreamableFile(stream); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); } @Get(':id/contract') @ApiOperation({ summary: 'Download contract file (alias)' }) async downloadContract( @Param('id', ParseUUIDPipe) id: string, - @Res({ passthrough: true }) res: Response, - ) { + @Res() res: Response, + ): Promise { return this.downloadContractDocument(id, res); } @@ -326,7 +337,7 @@ export class BookingsController { } @Post(':id/marketing/approve') - @UseGuards(JwtGuard) + @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', }) @@ -347,47 +358,8 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/payment/pnr') - @ApiOperation({ summary: 'Generate PNR code (ETB)' }) - async generatePnr(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.paymentService.generatePnr(id); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(':id/payment/proof') - @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload USD payment proof' }) - async submitPaymentProof( - @Param('id', ParseUUIDPipe) id: string, - @UploadedFiles() files: Express.Multer.File[], - ) { - const file = files?.[0]; - const booking = await this.paymentService.submitPaymentProof(id, file); - return this.transitionService.enrichBookingResponse(booking); - } - - @Get(':id/payment/request-letter') - @ApiOperation({ summary: 'Download payment request letter (USD stub)' }) - @Header('Content-Type', 'text/plain') - async paymentRequestLetter( - @Param('id', ParseUUIDPipe) id: string, - @Res({ passthrough: true }) res: Response, - ) { - const { buffer, filename } = - await this.paymentService.getPaymentRequestLetter(id); - res.set('Content-Disposition', `attachment; filename="${filename}"`); - return new StreamableFile(buffer); - } - - @Post(':id/payment/verify') - @ApiOperation({ summary: 'Finance verify USD payment' }) - async verifyPayment(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.paymentService.verifyPayment(id); - return this.transitionService.enrichBookingResponse(booking); - } - @Post(':id/operations/start-transit') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Mark in transit' }) async startTransit(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); @@ -395,6 +367,7 @@ export class BookingsController { } @Post(':id/operations/complete') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Mark completed' }) async complete(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); @@ -402,6 +375,7 @@ export class BookingsController { } @Post(':id/cancel') + @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: 'Cancel booking' }) async cancel( @Param('id', ParseUUIDPipe) id: string, 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 628ea90d3..70c9f8717 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -12,10 +12,10 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; +import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; -import { PaymentsWebhookController } from './payments-webhook.controller'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; @@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu // CustomersModule, RuleEngineModule, ], - controllers: [BookingsController, PaymentsWebhookController], + controllers: [BookingsController, PayController], providers: [ BookingsService, BookingsRepository, 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 eb7bbd10a..9c17eff3e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, FindOptionsWhere, Repository } from 'typeorm'; +import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; @@ -17,6 +17,20 @@ import { import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; +export interface BookingListFilterOptions { + statuses?: string[]; + status?: string; + companyId?: string; + contractType?: string; + serviceTypeId?: string; + cargoTypeId?: string; + freightType?: string; + tradeDirection?: string; + paymentCurrency?: string; + allowConsolidation?: boolean; + consolidationPaired?: string; +} + @Injectable() export class BookingsRepository extends BaseRepository { constructor( @@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository { return this.dataSource.getRepository(BookingApprovalStep).findOne({ where: { bookingId, status: 'PENDING' }, order: { stepOrder: 'ASC' }, - relations: ['approvalRule'], }); } @@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository { ): Promise { return this.dataSource.getRepository(BookingApprovalStep).findOne({ where: { bookingId, id: stepId }, - relations: ['approvalRule'], }); } @@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); } - async findByPnrCode(pnrCode: string): Promise { - return this.repository.findOne({ where: { pnrCode } }); - } - /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; @@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository { const qb = this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') - // .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .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) { @@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository { } const sortField = - options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at'; + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; qb.orderBy(sortField, options.sortOrder ?? 'DESC'); const [items, total] = await qb @@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository { return { items, total }; } + /** Paginated list with optional multi-status filter (API tab queues). */ + async findAllPaginated(options: BookingListFilterOptions & { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page; + const pageSize = options.pageSize; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.deleted_at IS NULL'); + + this.applyListFilters(qb, options); + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async getStatusCounts(): Promise> { + const rows = await this.repository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getListSummaryMetrics( + options: BookingListFilterOptions & { + page: number; + pageSize: number; + needsActionStatuses: readonly string[]; + urgentPriorityThreshold: number; + }, + ): Promise<{ + inQueue: number; + onThisPage: number; + needsAction: number; + urgent: number; + }> { + const baseQb = () => { + const qb = this.repository + .createQueryBuilder('booking') + .where('booking.deleted_at IS NULL'); + this.applyListFilters(qb, options); + return qb; + }; + + const inQueue = await baseQb().getCount(); + + const needsAction = await baseQb() + .andWhere('booking.status IN (:...needsActionStatuses)', { + needsActionStatuses: [...options.needsActionStatuses], + }) + .getCount(); + + const urgent = await baseQb() + .andWhere('booking.priority_score >= :urgentPriorityThreshold', { + urgentPriorityThreshold: options.urgentPriorityThreshold, + }) + .getCount(); + + const offset = (options.page - 1) * options.pageSize; + const onThisPage = Math.min( + options.pageSize, + Math.max(0, inQueue - offset), + ); + + return { inQueue, onThisPage, needsAction, urgent }; + } + + private applyListFilters( + qb: SelectQueryBuilder, + options: BookingListFilterOptions, + ): void { + if (options.statuses?.length) { + qb.andWhere('booking.status IN (:...statuses)', { + statuses: options.statuses, + }); + } else if (options.status) { + qb.andWhere('booking.status = :status', { status: options.status }); + } + + if (options.companyId) { + qb.andWhere('booking.company_id = :companyId', { + companyId: options.companyId, + }); + } + if (options.contractType) { + qb.andWhere('booking.contract_type = :contractType', { + contractType: options.contractType, + }); + } + if (options.serviceTypeId) { + qb.andWhere('booking.service_type_id = :serviceTypeId', { + serviceTypeId: options.serviceTypeId, + }); + } + if (options.cargoTypeId) { + qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + cargoTypeId: options.cargoTypeId, + }); + } + if (options.freightType) { + qb.andWhere('booking.freight_type = :freightType', { + freightType: options.freightType, + }); + } + if (options.tradeDirection) { + qb.andWhere('booking.trade_direction = :tradeDirection', { + tradeDirection: options.tradeDirection, + }); + } + if (options.paymentCurrency) { + qb.andWhere('booking.payment_currency = :paymentCurrency', { + paymentCurrency: options.paymentCurrency, + }); + } + if (options.allowConsolidation !== undefined) { + qb.andWhere('booking.allow_consolidation = :allowConsolidation', { + allowConsolidation: options.allowConsolidation, + }); + } + if (options.consolidationPaired === 'true') { + qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); + } else if (options.consolidationPaired === 'false') { + qb.andWhere('booking.consolidation_partner_id IS NULL'); + } + } + async findAndCountFiltered(where: FindOptionsWhere, options: { skip: number; take: number; 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 89085f144..9870077cf 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -4,8 +4,6 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; - // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { FilesService } from '../files/files.service'; @@ -19,12 +17,25 @@ import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; +import { mapStatusCountsToTabs } from './booking-list-tabs.config'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; -import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity'; +import { + BOOKING_STATUSES, + CUSTOMER_EDITABLE_STATUSES, + FreightType, +} from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; +const URGENT_PRIORITY_THRESHOLD = 1000; +const NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + @Injectable() export class BookingsService { constructor( @@ -379,44 +390,88 @@ export class BookingsService { return { booking, warnings }; } + /** Parse comma-separated or repeated status query values. */ + private parseStatusFilter(filter: FilterBookingDto): { + statuses?: string[]; + status?: string; + } { + const allowed = new Set(BOOKING_STATUSES); + const raw = filter.statuses; + const statusList = raw + ? raw + .split(',') + .map((s) => s.trim()) + .filter((s) => allowed.has(s)) + : []; + + if (statusList.length > 0) { + return { statuses: statusList }; + } + if (filter.status && allowed.has(filter.status)) { + return { status: filter.status }; + } + return {}; + } + /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); - const where: Record = {}; - if (filter.status) where.status = filter.status; - // if (filter.customerId) where.customerId = filter.customerId; - if (filter.companyId) where.companyId = filter.companyId; - if (filter.contractType) where.contractType = filter.contractType; - if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId; - if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId; - if (filter.freightType) where.freightType = filter.freightType; - if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; - if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; - if (filter.allowConsolidation !== undefined) { - where.allowConsolidation = filter.allowConsolidation; - } - if (filter.consolidationPaired === 'true') { - where.consolidationPartnerId = Not(IsNull()); - } else if (filter.consolidationPaired === 'false') { - where.consolidationPartnerId = IsNull(); - } - - const sortField = filter.sortBy ?? 'createdAt'; - const sortDir = filter.sortOrder ?? 'DESC'; - - const [items, total] = await this.bookingsRepository.findAndCount({ - where, - skip: (page - 1) * pageSize, - take: pageSize, - order: { [sortField]: sortDir }, - relations: ['company', 'originYard', 'destinationYard', 'serviceType'], - // relations: ['customer', 'originYard', 'destinationYard', 'serviceType'], + return this.bookingsRepository.findAllPaginated({ + page, + pageSize, + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, }); - return { items, total }; + } + + /** Aggregate metrics and tab counts for the backoffice booking list. */ + async getListSummary(filter: FilterBookingDto): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + const listFilter = { + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + }; + + const [statusCounts, metrics] = await Promise.all([ + this.bookingsRepository.getStatusCounts(), + this.bookingsRepository.getListSummaryMetrics({ + ...listFilter, + page, + pageSize, + needsActionStatuses: NEEDS_ACTION_STATUSES, + urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, + }), + ]); + + return { + metrics, + tabs: mapStatusCountsToTabs(statusCounts), + }; } /** Get a single booking by ID with files. */ @@ -429,7 +484,7 @@ export class BookingsService { if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { - const objectName = this.extractObjectName(file.url); + const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }), @@ -439,11 +494,6 @@ export class BookingsService { return booking; } - private extractObjectName(url: string): string { - const parts = url.split('/'); - return parts.slice(4).join('/'); - } - async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { @@ -467,10 +517,11 @@ export class BookingsService { ): Promise<{ items: Booking[]; total: number }> { const statusMap: Record = { intake: 'SUBMITTED', - approval: 'PENDING_APPROVAL', + approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], marketing: 'SIGNED_CUSTOMER', - finance: 'PAYMENT_VERIFICATION_IN_PROGRESS', + finance: 'FULLY_EXECUTED', }; const status = statusMap[queue]; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts new file mode 100644 index 000000000..30ca4bdb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookingListSummaryMetricsDto { + @ApiProperty({ example: 42 }) + inQueue!: number; + + @ApiProperty({ example: 10 }) + onThisPage!: number; + + @ApiProperty({ example: 8 }) + needsAction!: number; + + @ApiProperty({ example: 3 }) + urgent!: number; +} + +export class BookingListSummaryTabsDto { + @ApiProperty() all!: number; + @ApiProperty() intake!: number; + @ApiProperty() in_approval!: number; + @ApiProperty() approved_contract!: number; + @ApiProperty() payment!: number; + @ApiProperty() operations!: number; + @ApiProperty() completed!: number; + @ApiProperty() closed!: number; +} + +export class BookingListSummaryDto { + @ApiProperty({ type: BookingListSummaryMetricsDto }) + metrics!: BookingListSummaryMetricsDto; + + @ApiProperty({ type: BookingListSummaryTabsDto }) + tabs!: BookingListSummaryTabsDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 50165ac00..03fe73683 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -14,6 +14,18 @@ export class FilterBookingDto { @IsIn([...BOOKING_STATUSES]) status?: string; + @ApiPropertyOptional({ + description: + 'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + statuses?: string; + // @ApiPropertyOptional({ format: 'uuid' }) // @IsOptional() // @IsUUID() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts new file mode 100644 index 000000000..50db3864b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InAppPaymentReceiptDto { + @ApiProperty({ example: true }) + success!: boolean; + + @ApiProperty({ example: 'TELEBIRR' }) + provider!: string; + + @ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' }) + providerRef!: string; + + @ApiProperty({ example: 15000 }) + amount!: number; + + @ApiProperty({ example: 'ETB' }) + currency!: string; + + @ApiProperty({ example: '2026-06-05T12:00:00.000Z' }) + paidAt!: string; +} + +export class PayBookingResponseDto { + @ApiProperty({ type: InAppPaymentReceiptDto }) + paymentReceipt!: InAppPaymentReceiptDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 6f716388c..99855d49f 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -34,9 +34,3 @@ export class CancelBookingDto { @MinLength(1) reason!: string; } - -export class BankCallbackDto { - @ApiProperty() - @IsString() - pnrCode!: string; -} 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 index 4f3032b73..68018e883 100644 --- 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 @@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity { @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; diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts new file mode 100644 index 000000000..de66afac5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingNextStep } from './booking-next-step.util'; + +@ApiTags('payments') +@ApiBearerAuth() +@Controller('bookings') +export class PayController { + constructor( + private readonly paymentService: BookingPaymentService, + private readonly transitionService: BookingTransitionService, + ) {} + + @Post(':id/payment/pay') + @ApiOperation({ summary: 'Complete in-app payment (mock)' }) + @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) + async pay(@Param('id', ParseUUIDPipe) id: string): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + paymentReceipt: InAppPaymentReceiptDto; + } + > { + const { booking, receipt } = await this.paymentService.pay(id); + const abstract = await this.transitionService.enrichBookingResponse(booking); + return { ...abstract, paymentReceipt: receipt }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts deleted file mode 100644 index d0f60f8df..000000000 --- a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { BookingPaymentService } from './booking-payment.service'; -import { BankCallbackDto } from './dto/request-changes.dto'; - -@ApiTags('payments') -@Controller('webhooks/payments') -export class PaymentsWebhookController { - constructor(private readonly paymentService: BookingPaymentService) {} - - @Post('bank') - @ApiOperation({ summary: 'Bank payment callback (stub)' }) - bankCallback(@Body() dto: BankCallbackDto) { - return this.paymentService.handleBankCallback(dto.pnrCode); - } -} 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 1f0076986..97a5e9e34 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -79,13 +79,8 @@ export class FilesService { async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> { const record = await this.findById(id); - const objectName = this.extractObjectName(record.url); + const objectName = this.minioService.getObjectNameFromUrl(record.url); const stream = await this.minioService.getFileStream(objectName); return { stream, record }; } - - private extractObjectName(url: string): string { - const parts = url.split("/"); - return parts.slice(4).join("/"); - } } diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 4dba9c66e..9f7a5e65d 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { Client } from "minio"; import { Readable } from "stream"; @@ -54,6 +54,30 @@ export class MinioService { return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; } + getObjectNameFromUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new NotFoundException("File object path is empty"); + } + + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed.replace(/^\/+/, ""); + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] === this.bucket) { + parts.shift(); + } + + const objectName = parts.join("/"); + if (!objectName) { + throw new NotFoundException("File object path is empty"); + } + + return objectName; + } + async deleteFile(objectName: string): Promise { try { await this.client.removeObject(this.bucket, objectName); diff --git a/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts new file mode 100644 index 000000000..19fd8c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts @@ -0,0 +1,31 @@ +/** ITMLS US-06 default approval chains — seeded automatically when missing. */ +export const DEFAULT_APPROVAL_RULE_ROWS = [ + { + requiresDirectorApproval: false, + stepOrder: 1, + requiredRole: 'LINE_STAFF', + actionLabel: 'Review & Approve', + blocksRole: null as string | null, + }, + { + requiresDirectorApproval: false, + stepOrder: 2, + requiredRole: 'DIRECTOR', + actionLabel: 'Final Signature', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 1, + requiredRole: 'DIRECTOR', + actionLabel: 'Review & Approve', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 2, + requiredRole: 'CEO', + actionLabel: 'Final Signature', + blocksRole: null as string | null, + }, +] as const; 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 3c34cc71b..72e35b296 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 @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; @@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service'; @ApiTags('approval-rules') @Controller('approval-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ApprovalRulesController { constructor(private readonly service: ApprovalRulesService) {} @Get() + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'List approval rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -28,30 +29,35 @@ export class ApprovalRulesController { } @Get('chain') + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) findChain(@Query('requiresDirectorApproval') flag: string) { return this.service.findChain(flag === 'true'); } @Get(':id') + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get an approval rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('approval-rules') @ApiOperation({ summary: 'Create an approval rule step' }) create(@Body() dto: CreateApprovalRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('approval-rules') @ApiOperation({ summary: 'Update an approval rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete an approval rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index de855c7ef..4941a5ebb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,18 +3,19 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoTypesService } from '../services/cargo-types.service'; @ApiTags('cargo-types') @Controller('cargo-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class CargoTypesController { constructor(private readonly service: CargoTypesService) {} @Get() + @RuleEngineView('cargo-types') @ApiOperation({ summary: 'List cargo types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -32,24 +33,28 @@ export class CargoTypesController { } @Get(':id') + @RuleEngineView('cargo-types') @ApiOperation({ summary: 'Get a cargo type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('cargo-types') @ApiOperation({ summary: 'Create a cargo type' }) create(@Body() dto: CreateCargoTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('cargo-types') @ApiOperation({ summary: 'Update a cargo type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('cargo-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a cargo type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 380688309..43dfcec33 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,18 +3,19 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerTypesService } from '../services/container-types.service'; @ApiTags('container-types') @Controller('container-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ContainerTypesController { constructor(private readonly service: ContainerTypesService) {} @Get() + @RuleEngineView('container-types') @ApiOperation({ summary: 'List container types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class ContainerTypesController { } @Get(':id') + @RuleEngineView('container-types') @ApiOperation({ summary: 'Get a container type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('container-types') @ApiOperation({ summary: 'Create a container type' }) create(@Body() dto: CreateContainerTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('container-types') @ApiOperation({ summary: 'Update a container type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('container-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a container type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts index d268fc577..bee5cf85b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; @@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service'; @ApiTags('priority-rules') @Controller('priority-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class PriorityRulesController { constructor(private readonly service: PriorityRulesService) {} @Get() + @RuleEngineView('priority-rules') @ApiOperation({ summary: 'List priority rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class PriorityRulesController { } @Get(':id') + @RuleEngineView('priority-rules') @ApiOperation({ summary: 'Get a priority rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('priority-rules') @ApiOperation({ summary: 'Create a priority rule' }) create(@Body() dto: CreatePriorityRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('priority-rules') @ApiOperation({ summary: 'Update a priority rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('priority-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a priority rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 0ff7ef543..3c7776a27 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -1,10 +1,10 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, - Param, ParseUUIDPipe, Patch, Post, Query, UseGuards, + Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateRateDto } from '../dto/create-rate.dto'; import { type AuthUserPayload, @@ -15,12 +15,12 @@ import { RatesService } from '../services/rates.service'; @ApiTags('rates') @Controller('rates') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class RatesController { constructor(private readonly service: RatesService) {} @Get() + @RuleEngineView('rates') @ApiOperation({ summary: 'List rates' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -32,19 +32,21 @@ export class RatesController { } @Get('live') + @RuleEngineView('rates') @ApiOperation({ summary: 'List all LIVE rates effective now' }) findLive() { return this.service.findLiveRates(); } @Get(':id') + @RuleEngineView('rates') @ApiOperation({ summary: 'Get a rate by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() - @UseGuards(JwtGuard) + @RuleEngineManage('rates') @ApiOperation({ summary: 'Create a rate (DRAFT)' }) create( @Body() dto: CreateRateDto, @@ -54,19 +56,21 @@ export class RatesController { } @Patch(':id') + @RuleEngineManage('rates') @ApiOperation({ summary: 'Update a DRAFT rate' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { return this.service.update(id, dto); } @Post(':id/submit') + @RuleEngineManage('rates') @ApiOperation({ summary: 'Submit rate for CEO approval' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.service.submitForApproval(id); } @Post(':id/approve') - @UseGuards(JwtGuard) + @RuleEngineManage('rates') @ApiOperation({ summary: 'CEO approves a rate' }) approve( @Param('id', ParseUUIDPipe) id: string, @@ -76,6 +80,7 @@ export class RatesController { } @Delete(':id') + @RuleEngineManage('rates') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a rate' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 263178a92..3044515fb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; @@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service'; @ApiTags('service-types') @Controller('service-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ServiceTypesController { constructor(private readonly service: ServiceTypesService) {} @Get() + @RuleEngineView('service-types') @ApiOperation({ summary: 'List service types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -29,24 +30,28 @@ export class ServiceTypesController { } @Get(':id') + @RuleEngineView('service-types') @ApiOperation({ summary: 'Get a service type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('service-types') @ApiOperation({ summary: 'Create a service type' }) create(@Body() dto: CreateServiceTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('service-types') @ApiOperation({ summary: 'Update a service type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('service-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a service type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index fc022725c..40a67c7f5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; @@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service'; @ApiTags('shipping-lines') @Controller('shipping-lines') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ShippingLinesController { constructor(private readonly service: ShippingLinesService) {} @Get() + @RuleEngineView('shipping-lines') @ApiOperation({ summary: 'List shipping lines' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class ShippingLinesController { } @Get(':id') + @RuleEngineView('shipping-lines') @ApiOperation({ summary: 'Get a shipping line by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('shipping-lines') @ApiOperation({ summary: 'Create a shipping line' }) create(@Body() dto: CreateShippingLineDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('shipping-lines') @ApiOperation({ summary: 'Update a shipping line' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('shipping-lines') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a shipping line' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts index 24213a28f..be4c3011a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; @@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service'; @ApiTags('surcharge-types') @Controller('surcharge-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class SurchargeTypesController { constructor(private readonly service: SurchargeTypesService) {} @Get() + @RuleEngineView('surcharge-types') @ApiOperation({ summary: 'List surcharge types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class SurchargeTypesController { } @Get(':id') + @RuleEngineView('surcharge-types') @ApiOperation({ summary: 'Get a surcharge type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('surcharge-types') @ApiOperation({ summary: 'Create a surcharge type' }) create(@Body() dto: CreateSurchargeTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('surcharge-types') @ApiOperation({ summary: 'Update a surcharge type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('surcharge-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a surcharge type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index 2d4372a76..c3f0c1472 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; @@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service' @ApiTags('weight-limit-rules') @Controller('weight-limit-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class WeightLimitRulesController { constructor(private readonly service: WeightLimitRulesService) {} @Get() + @RuleEngineView('weight-limit-rules') @ApiOperation({ summary: 'List weight limit rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -26,24 +27,28 @@ export class WeightLimitRulesController { } @Get(':id') + @RuleEngineView('weight-limit-rules') @ApiOperation({ summary: 'Get a weight limit rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('weight-limit-rules') @ApiOperation({ summary: 'Create a weight limit rule' }) create(@Body() dto: CreateWeightLimitRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('weight-limit-rules') @ApiOperation({ summary: 'Update a weight limit rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('weight-limit-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 89fa002a5..88523967e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; @@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service'; @ApiTags('yards') @Controller('yards') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class YardsController { constructor(private readonly service: YardsService) {} @Get() + @RuleEngineView('yards') @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -26,24 +27,28 @@ export class YardsController { } @Get(':id') + @RuleEngineView('yards') @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('yards') @ApiOperation({ summary: 'Create a yard' }) create(@Body() dto: CreateYardDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('yards') @ApiOperation({ summary: 'Update a yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('yards') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a yard' }) remove(@Param('id', ParseUUIDPipe) id: string) { 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 452bee90b..98aeaffbf 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 @@ -35,6 +35,7 @@ import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; +import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; export interface BookingContainerEvalInput { containerTypeId: string; @@ -238,6 +239,29 @@ export class RuleEngineService { }; } + /** + * 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. */ @@ -248,6 +272,8 @@ export class RuleEngineService { cargoTypeId?: string | null; }, ): Promise { + await this.ensureDefaultApprovalRules(); + let requiresDirectorApproval = options.freightType === 'BULK'; if (options.cargoTypeId) { @@ -264,6 +290,12 @@ export class RuleEngineService { 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[] = []; @@ -273,6 +305,7 @@ export class RuleEngineService { approvalRuleId: rule.id, stepOrder: rule.stepOrder, requiredRole: rule.requiredRole, + blocksRole: rule.blocksRole ?? null, status: 'PENDING', }); steps.push(await stepRepo.save(step)); diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index dda476841..c2705673f 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,3 +1,9 @@ +import { + BOOKING_RULE_ENGINE_PERMISSIONS, + BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './freight-permissions.registry'; + export type FreightSeedRole = { key: string; name: { en: string }; @@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS, + ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; + export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ { key: "edr_employee", @@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:position_types:view", ], }, + { + key: "edr_line_staff", + name: { en: "EDR Line Staff" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + }, + { + key: "edr_director", + name: { en: "EDR Director" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + }, + { + key: "edr_ceo", + name: { en: "EDR CEO" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + }, + { + key: "edr_finance", + name: { en: "EDR Finance" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, + { + key: "edr_marketing", + name: { en: "EDR Marketing" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, permissionKeys: [ - ...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key), + ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), + ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...POSITION_TYPE_PERMISSIONS.map((p) => p.key), IAM_PERMISSION_KEYS.createEmployee, IAM_PERMISSION_KEYS.deactivateEmployee, IAM_PERMISSION_KEYS.activateEmployee, diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 49620b593..8243ef267 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -8,6 +8,8 @@ import { } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; const EDR_ORG_KEY = "edr_freight"; @@ -37,6 +39,7 @@ export class EdrOrgSeeder { await this.ensureOrganizationConfiguration(manager, organization.id); await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); + await this.ensureSuperAdminPermissions(manager); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -166,4 +169,40 @@ export class EdrOrgSeeder { this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn( + `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, + ); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) { + this.logger.warn('No booking/rule-engine permissions found for super_admin'); + return; + } + + await manager.getRepository(RolePermission).upsert( + permissions.map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + + this.logger.log( + `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, + ); + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts new file mode 100644 index 000000000..dba80d96b --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -0,0 +1,152 @@ +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +export type FreightPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +export const RULE_ENGINE_RESOURCE_SLUGS = [ + 'cargo-types', + 'container-types', + 'service-types', + 'yards', + 'shipping-lines', + 'weight-limit-rules', + 'surcharge-types', + 'priority-rules', + 'rates', + 'approval-rules', +] as const; + +export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; + +const slugToResourceKey = (slug: RuleEngineResourceSlug): string => + slug.replace(/-/g, '_'); + +const perm = ( + id: string, + key: string, + en: string, +): FreightPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: EDR_FREIGHT_APP_KEY, +}); + +export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'), + perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'), + perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'), + perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'), + perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'), + perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'), + perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'), + perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'), + perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'), + perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'), + perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'), + perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), + perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), + perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), +]; + +const RULE_ENGINE_PERMISSION_IDS: Record = { + 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, + 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, + yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, + 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, + 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' }, + 'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' }, + 'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' }, + rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' }, + 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' }, +}; + +export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( + (slug) => { + const resource = slugToResourceKey(slug); + const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + return [ + perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), + perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ]; + }, +); + +export const BOOKING_RULE_ENGINE_PERMISSIONS = [ + ...BOOKING_PERMISSIONS, + ...RULE_ENGINE_PERMISSIONS, +]; + +export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( + (p) => p.key, +); + +export const FREIGHT_PERMS = { + bookings: { + view: 'edr_freight_app:bookings:view', + staffAccept: 'edr_freight_app:bookings:staff_accept', + requestChanges: 'edr_freight_app:bookings:request_changes', + reject: 'edr_freight_app:bookings:reject', + approveLineStaff: 'edr_freight_app:bookings:approve_line_staff', + approveDirector: 'edr_freight_app:bookings:approve_director', + approveCeo: 'edr_freight_app:bookings:approve_ceo', + rejectApproval: 'edr_freight_app:bookings:reject_approval', + generateContract: 'edr_freight_app:bookings:generate_contract', + signStaff: 'edr_freight_app:bookings:sign_staff', + operations: 'edr_freight_app:bookings:operations', + cancel: 'edr_freight_app:bookings:cancel', + }, + ruleEngine: { + view: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, + manage: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + }, +} as const; + +const allRuleEngineViewKeys = () => + RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); + +export const ROLE_PERMISSION_PRESETS = { + lineStaff: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, + ...allRuleEngineViewKeys(), + ], + director: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.generateContract, + ...allRuleEngineViewKeys(), + ], + ceo: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveCeo, + FREIGHT_PERMS.bookings.rejectApproval, + ...allRuleEngineViewKeys(), + ], + finance: [FREIGHT_PERMS.bookings.view], + marketing: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.generateContract, + FREIGHT_PERMS.bookings.signStaff, + ], + orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], +} as const; + +export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ + key: p.key, + label: p.name.en, + module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine', +})); diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts new file mode 100644 index 000000000..06b9afa94 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_FREIGHT_STAFF'; +const EDR_ORG_KEY = 'edr_freight'; + +const STAFF_USERS = [ + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +@Injectable() +export class FreightStaffUsersSeeder { + private readonly logger = new Logger(FreightStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = + process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const roleRepository = manager.getRepository(Role); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + const employeeRepository = manager.getRepository(Employee); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await roleRepository.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + + if (!role) { + throw new Error(`missing_role:${staff.roleKey}`); + } + + let user = await userRepository.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded freight staff user ${staff.email}`); + } + + const activeCredentialExists = await userCredentialRepository.exists({ + where: { userId: user.id, isActive: true }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 884fde00b..04274c917 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Boxes, @@ -12,6 +13,11 @@ import { import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; +import { + canAccessBookings, + canAccessRuleEngineResource, + hasPermission, +} from "@/lib/permissions"; import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; @@ -19,7 +25,6 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; -import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -29,102 +34,114 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import { + getCategorySidebarChildren, + RULE_ENGINE_RESOURCES, + type RuleEngineNavCategory, +} from "./pages/ruleEngine/config/resources"; +import type { RuleEngineResourceSlug } from "./types/rule-engine"; -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - mutedTitle: true, - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - ...demoItems, - ], - }, - { - title: "Administration", - items: [ - { - label: "User management", - href: "/dashboard/user-management", - icon: , - children: [ - { - label: "Users", - href: "/dashboard/user-management/users", - }, - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Position Types", - href: "/dashboard/user-management/position-types", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: getCategorySidebarChildren("configuration"), - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -const hasPermission = ( +const filterRuleEngineChildren = ( user: ReturnType["user"], - key: string, -) => { - if (!user) return false; - if (user.permissions?.some((p) => p.key === key)) return true; + category: RuleEngineNavCategory, +): SidebarItem[] => + getCategorySidebarChildren(category).filter((item) => { + const slug = item.href.split("/").pop() as RuleEngineResourceSlug; + return canAccessRuleEngineResource(user, slug, "view"); + }); - return (user.employee ?? []).some((emp) => - (emp.positions ?? []).some((pos) => - (pos.permissions ?? []).some((p) => p.key === key), - ), - ); +const buildSidebarSections = ( + user: ReturnType["user"], + demoItems: SidebarItem[], +): SidebarSection[] => { + const configurationChildren = filterRuleEngineChildren(user, "configuration"); + const rulesChildren = filterRuleEngineChildren(user, "rules"); + + const mainItems: SidebarItem[] = [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + ...(canAccessBookings(user) + ? [ + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ] + : []), + ...demoItems, + ]; + + const freightConfigItems: SidebarItem[] = []; + if (configurationChildren.length) { + freightConfigItems.push({ + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: configurationChildren, + }); + } + if (rulesChildren.length) { + freightConfigItems.push({ + label: "Rules", + href: "/dashboard/rules", + icon: , + children: rulesChildren, + }); + } + + const sections: SidebarSection[] = [ + { title: "Main menu", mutedTitle: true, items: mainItems }, + { + title: "Administration", + items: [ + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { label: "Users", href: "/dashboard/user-management/users" }, + { label: "Position Types", href: "/dashboard/user-management/position-types" }, + { label: "Permissions", href: "/dashboard/user-management/permissions" }, + { label: "Roles", href: "/dashboard/user-management/roles" }, + ], + }, + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + }, + ], + }, + ]; + + if (freightConfigItems.length) { + sections.push({ + title: "Freight configuration", + mutedTitle: true, + items: freightConfigItems, + }); + } + + return sections; }; +const PermissionRoute = ({ + allow, + children, +}: { + allow: boolean; + children: ReactNode; +}) => (allow ? children : ); + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -132,26 +149,14 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = [ ...(hasPermission(user, "can:demo:user1") - ? [ - { - label: "User1", - href: "/dashboard/user1", - icon: , - }, - ] + ? [{ label: "User1", href: "/dashboard/user1", icon: }] : []), ...(hasPermission(user, "can:demo:user2") - ? [ - { - label: "User2", - href: "/dashboard/user2", - icon: , - }, - ] + ? [{ label: "User2", href: "/dashboard/user2", icon: }] : []), ]; - const sidebarSections = buildSidebarSections(demoItems); + const sidebarSections = buildSidebarSections(user, demoItems); const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -169,6 +174,8 @@ const DashboardShell = () => { ); }; +const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug); + const App = () => { const { user, loading } = useAuth(); @@ -185,63 +192,104 @@ const App = () => { ); } + const canBookings = canAccessBookings(user); + return ( - } /> - } /> + } /> + } /> - }> - } /> + }> + } /> - } /> - } /> - } - /> + + + + } + /> + + + + } + /> + + + + } + /> - } /> - } /> - } /> - {/* } /> */} - } /> - } /> + } /> + } /> + } /> + } /> + } /> - } /> - } /> + } /> + } /> - } - /> - } /> + } + /> + + canAccessRuleEngineResource(user, slug, "view"), + )} + > + + + } + /> - } - /> - } /> + } + /> + + canAccessRuleEngineResource(user, slug, "view"), + )} + > + + + } + /> - } - /> - } /> + } + /> + } /> - } /> - } /> + } /> + } /> - } - /> - } - /> - + } + /> + } + /> + - } /> + } /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts index 8c5b11b54..f72e72949 100644 --- a/apps/edr-freight-web/backoffice/src/auth/api.ts +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -18,6 +18,6 @@ export const verifyMfaRequest = async (payload: { }; export const getMeRequest = async () => { - const response = await api.get("/auth/me"); + const response = await api.get("/me"); return response.data; }; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 2d4ecd536..fdff357bb 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -39,6 +39,9 @@ export interface AuthUser { name?: LocaleText; roles?: AuthRole[]; permissions?: AuthPermission[]; + /** Flat keys from GET /api/me (roles + position permissions). */ + permissionKeys?: string[]; + isSuperAdmin?: boolean; employee?: AuthEmployeeRecord[]; hasSetPassword?: boolean; status?: string; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 64cca7db3..fcffe97d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -2,7 +2,9 @@ import { useMemo } from "react"; import { ShieldCheck } from "lucide-react"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; +import { formatApprovalProgress } from "@/features/bookings/approval-progress"; import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config"; +import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { Badge } from "@edr/ui-common"; import { cn } from "@/lib/utils"; @@ -21,31 +23,32 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) { ); const nextPending = getNextPendingApprovalStep(steps); + const summary = formatApprovalProgress(booking.status, steps); return ( -
        -
        -
        - +
        +
        +
        +

        Approval chain

        - Next:{" "} - {nextPending - ? `${nextPending.requiredRole} · step ${nextPending.stepOrder}` - : steps.length - ? "All steps complete" - : "Accept submission to begin"} + {summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin")}

        {steps.length === 0 ? ( -

        +

        Use Accept for approval{" "} in staff actions to instantiate steps.

        @@ -74,24 +77,31 @@ function StepRow({ }) { const statusStyles = step.status === "APPROVED" - ? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300" + ? "border-emerald-500/25 bg-emerald-500/10 text-black" : step.status === "REJECTED" - ? "bg-red-500/15 text-red-800 dark:text-red-300" + ? "bg-red-500/10 text-red-800 dark:text-red-300" : isNext - ? "bg-amber-500/15 text-amber-800 dark:text-amber-300" - : "bg-muted text-muted-foreground"; + ? "border-emerald-500/25 bg-emerald-500/10 text-black" + : "bg-muted/40 text-muted-foreground"; return (
      3. - + {step.stepOrder}
        @@ -107,7 +117,7 @@ function StepRow({
        {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 29b4e9b19..e4e1d8db9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -4,12 +4,14 @@ import { ExternalLink, Loader2, MoreHorizontal, - Upload, } from "lucide-react"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; +import { useAuth } from "@/auth/useAuth"; import { + getNextPendingApprovalStep, + isContractNavAction, listRowHasActions, type BookingActionContext, } from "@/features/bookings/booking-actions.config"; @@ -30,18 +32,23 @@ interface BookingActionsMenuProps { /** Compact table cell vs. larger detail toolbar */ variant?: "table" | "toolbar"; className?: string; + /** Suppresses table row navigation after menu/dialog close (click-through). */ + onSuppressRowClick?: () => void; } export function BookingActionsMenu({ row, variant = "table", className, + onSuppressRowClick, }: BookingActionsMenuProps) { const navigate = useNavigate(); + const { user } = useAuth(); const context: BookingActionContext = { status: row.status, paymentCurrency: row.paymentCurrency, reference: row.reference, + approvalSteps: row.approvalSteps, }; const flow = useBookingActionDialog(row.id, context); @@ -50,9 +57,7 @@ export function BookingActionsMenu({ const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); - const showUsdPaymentHint = - row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD"; - const hasMenu = listRowHasActions(row) || showUsdPaymentHint; + const hasMenu = listRowHasActions(row, user); const primary = actions.find((a) => a.primary) ?? actions[0]; @@ -73,8 +78,9 @@ export function BookingActionsMenu({ return ( <>
        - primary.id === "viewContract" + isContractNavAction(primary.id) ? goToContract() : flow.openAction(primary) } @@ -119,7 +125,7 @@ export function BookingActionsMenu({ )} disabled={mutations.isPending} onClick={() => - action.id === "viewContract" + isContractNavAction(action.id) ? goToContract() : flow.openAction(action) } @@ -164,36 +170,29 @@ export function BookingActionsMenu({ "gap-2 cursor-pointer", action.variant === "destructive" && "text-red-700 focus:text-red-700", )} - onClick={() => - action.id === "viewContract" - ? goToContract() - : flow.openAction(action) - } + onSelect={(event) => { + event.preventDefault(); + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }} > {action.label} ); })} - {showUsdPaymentHint && ( - - navigate(`/dashboard/booking-requests/${row.id}`) - } - > - - Upload payment proof… - - )} - {(actions.length > 0 || showUsdPaymentHint) && ( - - )} + {actions.length > 0 && } - navigate(`/dashboard/booking-requests/${row.id}`) - } + onSelect={(event) => { + event.preventDefault(); + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} > Open full details @@ -205,12 +204,20 @@ export function BookingActionsMenu({ { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} action={pendingAction} reference={flow.mergedContext.reference} inputValue={flow.inputValue} onInputChange={flow.setInputValue} - onConfirm={flow.runAction} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} isPending={mutations.isPending || flow.detailLoading} confirmDisabled={flow.confirmDisabled} extra={ @@ -220,10 +227,10 @@ export function BookingActionsMenu({ Loading approval steps…

        ) : pendingAction?.id === "approve" && - !flow.mergedContext.approvalSteps?.length ? ( + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (

        - No pending approval step found. Accept the submission on the detail - page first. + 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/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 8c0b123d5..006c57e11 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,5 +1,4 @@ -import { useRef } from "react"; -import { Download, Upload, Zap } from "lucide-react"; +import { Download, Zap } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; @@ -7,6 +6,7 @@ import { bookingSurface } from "./booking-ui.styles"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import { Button } from "@edr/ui-common"; +import { cn } from "@/lib/utils"; type Mutations = ReturnType; @@ -15,15 +15,13 @@ interface BookingActionsToolbarProps { mutations: Mutations; } -/** Detail-page actions: primary toolbar + payment uploads + downloads. */ +/** Detail-page actions: primary toolbar + downloads. */ export function BookingActionsToolbar({ booking, mutations, }: BookingActionsToolbarProps) { - const fileRef = useRef(null); const row = toBookingListRow(booking); - const { status, paymentCurrency } = booking; - const pending = mutations.isPending; + const { status } = booking; const downloadBlob = async (fn: () => Promise, filename: string) => { const blob = await fn(); @@ -47,7 +45,7 @@ export function BookingActionsToolbar({ return ( {booking.latestChangeRequestNote && ( -

        +

        {booking.latestChangeRequestNote}

        )} @@ -74,50 +72,11 @@ export function BookingActionsToolbar({
        - {status === "FULLY_EXECUTED" && paymentCurrency === "USD" && ( - - { - const file = e.target.files?.[0]; - if (file) mutations.submitPaymentProof.mutate(file); - }} - /> -
        - - -
        -
        - )} - {status === "CONTRACT_READY" && ( + ) : null; + } + return (
        - + {onAdd ? ( + + ) : null}
        ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 02fe21ffc..89be2b58a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -32,6 +32,8 @@ export const QUERY_KEYS = { ROOT: ["bookings"] as const, list: (filter?: BookingListFilter) => ["bookings", "list", filter ?? {}] as const, + listSummary: (filter?: BookingListFilter) => + ["bookings", "list-summary", filter ?? {}] as const, byId: (id: string) => ["bookings", "detail", id] as const, }, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 174416857..32b16d43a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -77,6 +77,7 @@ export const URL_CONSTANTS = { BOOKINGS: { BASE: "/bookings", + LIST_SUMMARY: "/bookings/list-summary", BY_ID: (id: string) => `/bookings/${id}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`, STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, @@ -95,11 +96,7 @@ export const URL_CONSTANTS = { SUMMARY: (id: string) => `/bookings/${id}/summary`, CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`, MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`, - PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`, - PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`, - PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`, - PAYMENT_REQUEST_LETTER: (id: string) => - `/bookings/${id}/payment/request-letter`, + PAYMENT_PAY: (id: string) => `/bookings/${id}/payment/pay`, START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`, COMPLETE: (id: string) => `/bookings/${id}/operations/complete`, CANCEL: (id: string) => `/bookings/${id}/cancel`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/approval-progress.ts b/apps/edr-freight-web/backoffice/src/features/bookings/approval-progress.ts new file mode 100644 index 000000000..6ada30634 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookings/approval-progress.ts @@ -0,0 +1,79 @@ +import type { BookingApprovalStep, BookingStatus } from "@/types/booking"; +import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config"; + +export interface ApprovalProgressSummary { + label: string; + detail: string; + complete: boolean; +} + +/** Compact approval chain summary for list rows and badges. */ +export function formatApprovalProgress( + status: BookingStatus | string, + steps?: BookingApprovalStep[] | null, +): ApprovalProgressSummary { + const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder); + + if (sorted.length === 0) { + if (status === "SUBMITTED") { + return { + label: "Awaiting accept", + detail: "Staff must accept intake", + complete: false, + }; + } + if ( + status === "PENDING_APPROVAL" || + status === "APPROVED_PENDING_SIGNATURE" + ) { + return { + label: "No steps", + detail: "Approval chain not started", + complete: false, + }; + } + if ( + [ + "APPROVED", + "CONTRACT_READY", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + "PAID", + "COMPLETED", + ].includes(status) + ) { + return { + label: "Approved", + detail: "Internal approval complete", + complete: true, + }; + } + return { label: "—", detail: "", complete: false }; + } + + const approved = sorted.filter((s) => s.status === "APPROVED").length; + const total = sorted.length; + const next = getNextPendingApprovalStep(sorted); + + if (!next && approved === total) { + return { + label: `${approved}/${total} done`, + detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "), + complete: true, + }; + } + + if (next) { + return { + label: `${approved}/${total}`, + detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`, + complete: false, + }; + } + + return { + label: `${approved}/${total}`, + detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "), + complete: approved === total, + }; +} diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index d1bd6f867..01a0efb20 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -12,6 +12,8 @@ import { XCircle, } from "lucide-react"; +import type { AuthUser } from "@/auth/types"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import type { BookingApprovalStep, BookingDetail, @@ -26,12 +28,13 @@ export type BookingActionId = | "rejectApproval" | "generateContract" | "viewContract" - | "generatePnr" - | "verifyPayment" + | "signContractStaff" + | "payBooking" | "startTransit" - | "complete"; + | "complete" + | "cancel"; -export type BookingActionInputKind = "note" | "reason"; +export type BookingActionInputKind = "note" | "reason" | "file"; export interface BookingActionDef { id: BookingActionId; @@ -140,18 +143,118 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [ }, ]; +const CANCEL_ACTION: BookingActionDef = { + id: "cancel", + label: "Cancel booking", + shortLabel: "Cancel", + description: "Cancel this booking", + confirmTitle: "Cancel booking?", + confirmDescription: + "The booking will be marked cancelled. Provide a reason for the audit trail.", + variant: "destructive", + icon: Ban, + input: "reason", + inputLabel: "Cancellation reason", + inputPlaceholder: "Reason for cancellation…", +}; + +const VIEW_CONTRACT_ACTION: BookingActionDef = { + id: "viewContract", + label: "View contract", + shortLabel: "Contract", + description: "Open contract document and signatures", + confirmTitle: "", + confirmDescription: "", + variant: "outline", + icon: FileSignature, +}; + +const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = { + id: "signContractStaff", + label: "Sign contract", + shortLabel: "Sign", + description: "Open contract page and apply staff counter-signature", + confirmTitle: "", + confirmDescription: "", + variant: "default", + icon: FileSignature, + primary: true, +}; + +const PAY_BOOKING_ACTION: BookingActionDef = { + id: "payBooking", + label: "Pay", + shortLabel: "Pay", + description: "Complete in-app payment", + confirmTitle: "Complete payment?", + confirmDescription: + "This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.", + variant: "default", + icon: Wallet, + primary: true, +}; + +function withCancel(actions: BookingActionDef[]): BookingActionDef[] { + return [...actions, CANCEL_ACTION]; +} + +const ACTION_PERMISSION: Partial> = { + accept: FREIGHT_PERMS.bookings.staffAccept, + requestChanges: FREIGHT_PERMS.bookings.requestChanges, + reject: FREIGHT_PERMS.bookings.reject, + rejectApproval: FREIGHT_PERMS.bookings.rejectApproval, + generateContract: FREIGHT_PERMS.bookings.generateContract, + viewContract: FREIGHT_PERMS.bookings.view, + signContractStaff: FREIGHT_PERMS.bookings.signStaff, + payBooking: FREIGHT_PERMS.bookings.view, + startTransit: FREIGHT_PERMS.bookings.operations, + complete: FREIGHT_PERMS.bookings.operations, + cancel: FREIGHT_PERMS.bookings.cancel, +}; + +const approvePermissionForRole = (role: string): string | undefined => { + if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff; + if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector; + if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo; + return undefined; +}; + +function filterActionsByUser( + actions: BookingActionDef[], + user: AuthUser | null | undefined, + approvalSteps?: BookingApprovalStep[] | null, +): BookingActionDef[] { + if (!user) return []; + const next = getNextPendingApprovalStep(approvalSteps); + return actions.filter((action) => { + if (action.id === "approve" && next) { + const perm = approvePermissionForRole(next.requiredRole); + return perm ? hasPermission(user, perm) : false; + } + const perm = ACTION_PERMISSION[action.id]; + return perm ? hasPermission(user, perm) : true; + }); +} + /** Actions available for the current booking status (detail or list). */ -export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] { - const { status, paymentCurrency, approvalSteps } = ctx; +export function getBookingActions( + ctx: BookingActionContext, + user?: AuthUser | null, +): BookingActionDef[] { + const { status, approvalSteps } = ctx; + + let actions: BookingActionDef[]; switch (status) { case "SUBMITTED": - return SUBMITTED_ACTIONS; + actions = withCancel(SUBMITTED_ACTIONS); + break; case "PENDING_APPROVAL": case "APPROVED_PENDING_SIGNATURE": - return approvalActions(approvalSteps); + actions = withCancel(approvalActions(approvalSteps)); + break; case "APPROVED": - return [ + actions = [ { id: "generateContract", label: "Generate contract", @@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] icon: FileText, primary: true, }, + CANCEL_ACTION, ]; + break; case "CONTRACT_READY": + actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }]; + break; case "SIGNED_CUSTOMER": + actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION]; + break; case "FULLY_EXECUTED": - return [ - { - id: "viewContract", - label: - status === "SIGNED_CUSTOMER" - ? "View & sign contract (staff)" - : status === "CONTRACT_READY" - ? "View contract" - : "View executed contract", - shortLabel: "Contract", - description: "Open contract document and signatures", - confirmTitle: "", - confirmDescription: "", - variant: "default", - icon: FileSignature, - primary: true, - }, - ]; - case "FULLY_EXECUTED": - if (paymentCurrency === "ETB") { - return [ - { - id: "generatePnr", - label: "Generate PNR", - shortLabel: "PNR", - description: "Issue PNR for ETB bank payment", - confirmTitle: "Generate PNR?", - confirmDescription: - "A payment reference number will be issued for the customer.", - variant: "default", - icon: Wallet, - primary: true, - }, - ]; - } - return []; - case "PAYMENT_VERIFICATION_IN_PROGRESS": - return [ - { - id: "verifyPayment", - label: "Verify payment", - shortLabel: "Verify", - description: "Confirm USD payment proof", - confirmTitle: "Verify payment?", - confirmDescription: - "Finance confirms the uploaded proof and marks the booking as paid.", - variant: "default", - icon: Check, - primary: true, - }, + actions = [ + PAY_BOOKING_ACTION, + { ...VIEW_CONTRACT_ACTION, label: "View executed contract" }, ]; + break; case "PAID": - case "PNR_GENERATED": - return [ + actions = [ { id: "startTransit", label: "Start transit", @@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] primary: true, }, ]; + break; case "IN_TRANSIT": - return [ + actions = [ { id: "complete", label: "Complete booking", @@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] primary: true, }, ]; + break; + case "CHANGES_REQUESTED": + actions = [CANCEL_ACTION]; + break; default: - return []; + actions = []; } + + if (user === undefined) return actions; + return filterActionsByUser(actions, user, approvalSteps); } -export function listRowHasActions(row: { - status: BookingStatus; - paymentCurrency: string; -}): boolean { - const actions = getBookingActions({ - status: row.status, - paymentCurrency: row.paymentCurrency, - reference: "", - }); - if (actions.length > 0) return true; - return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD"; +/** Opens contract page without confirmation dialog. */ +export function isContractNavAction(id: BookingActionId): boolean { + return id === "viewContract" || id === "signContractStaff"; +} + +export function listRowHasActions( + row: { + status: BookingStatus; + paymentCurrency: string; + approvalSteps?: BookingApprovalStep[] | null; + }, + user?: AuthUser | null, +): boolean { + const actions = getBookingActions( + { + status: row.status, + paymentCurrency: row.paymentCurrency, + reference: "", + approvalSteps: row.approvalSteps, + }, + user, + ); + return actions.length > 0; } diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 71892a5dc..b3312f3d3 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -199,20 +199,31 @@ export const BOOKING_STATUS_META: Record = { }; export const BOOKING_LIST_TABS = [ - { key: "all", label: "All bookings", status: null }, - { key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" }, - { key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" }, + { key: "all", label: "All bookings", statuses: null as string[] | null }, + { key: "intake", label: "Submitted", statuses: ["SUBMITTED"] }, { - key: "APPROVED_PENDING_SIGNATURE", - label: "Pending Signature", - status: "APPROVED_PENDING_SIGNATURE", + key: "in_approval", + label: "In approval", + statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"], }, - { key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" }, { - key: "PAYMENT_VERIFICATION_IN_PROGRESS", - label: "Payment Verification", - status: "PAYMENT_VERIFICATION_IN_PROGRESS", + key: "approved_contract", + label: "Approved & contract", + statuses: [ + "APPROVED", + "CONTRACT_READY", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + ], }, + { + key: "payment", + label: "Payment", + statuses: ["FULLY_EXECUTED", "PAID"], + }, + { key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] }, + { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, + { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, ] as const; export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"]; @@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [ }, { label: "Payment", - statuses: [ - "PNR_GENERATED", - "PAYMENT_VERIFICATION_IN_PROGRESS", - "PAID", - ], + statuses: ["FULLY_EXECUTED", "PAID"], }, { label: "Operations", diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index 6ad2a0cc7..6b32cbf30 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { return { id: booking.id, reference: booking.reference, + approvalSteps: booking.approvalSteps, customerLabel: labelFromRef(booking.company, booking.companyId), // customerLabel: labelFromRef(booking.customer, booking.customerId), status: booking.status, diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 241fe5e43..d68535f78 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -17,6 +17,14 @@ export function useBookingList(filter?: BookingListFilter, enabled = true) { }); } +export function useBookingListSummary(filter?: BookingListFilter, enabled = true) { + return useQuery({ + queryKey: QUERY_KEYS.BOOKINGS.listSummary(filter), + queryFn: () => bookingsService.getListSummary(filter), + enabled, + }); +} + export function useBookingDetail(id: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""), @@ -103,23 +111,10 @@ export function useBookingMutations(bookingId: string) { onError: () => toast.error("Failed to sign contract"), }); - const generatePnr = useMutation({ - mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }), - onSuccess: (data) => onSuccess(data, "PNR generated"), - onError: () => toast.error("Failed to generate PNR"), - }); - - const submitPaymentProof = useMutation({ - mutationFn: (file: File) => - bookingsService.submitPaymentProof(bookingId, file), - onSuccess: (data) => onSuccess(data, "Payment proof uploaded"), - onError: () => toast.error("Failed to upload payment proof"), - }); - - const verifyPayment = useMutation({ - mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }), - onSuccess: (data) => onSuccess(data, "Payment verified"), - onError: () => toast.error("Failed to verify payment"), + const payBooking = useMutation({ + mutationFn: () => api.bookings.payBooking.call({ id: bookingId }), + onSuccess: (data) => onSuccess(data, "Payment completed"), + onError: () => toast.error("Failed to complete payment"), }); const startTransit = useMutation({ @@ -149,9 +144,7 @@ export function useBookingMutations(bookingId: string) { rejectStep.isPending || generateContract.isPending || signContract.isPending || - generatePnr.isPending || - submitPaymentProof.isPending || - verifyPayment.isPending || + payBooking.isPending || startTransit.isPending || complete.isPending || cancel.isPending; @@ -164,15 +157,11 @@ export function useBookingMutations(bookingId: string) { rejectStep, generateContract, signContract, - generatePnr, - submitPaymentProof, - verifyPayment, + payBooking, startTransit, complete, cancel, isPending, downloadContract: () => bookingsService.downloadContract(bookingId), - downloadPaymentLetter: () => - bookingsService.downloadPaymentRequestLetter(bookingId), }; } diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts new file mode 100644 index 000000000..c5c25a6aa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -0,0 +1,82 @@ +import type { AuthUser } from "@/auth/types"; +import type { RuleEngineResourceSlug } from "@/types/rule-engine"; + +export const FREIGHT_PERMS = { + bookings: { + view: "edr_freight_app:bookings:view", + staffAccept: "edr_freight_app:bookings:staff_accept", + requestChanges: "edr_freight_app:bookings:request_changes", + reject: "edr_freight_app:bookings:reject", + approveLineStaff: "edr_freight_app:bookings:approve_line_staff", + approveDirector: "edr_freight_app:bookings:approve_director", + approveCeo: "edr_freight_app:bookings:approve_ceo", + rejectApproval: "edr_freight_app:bookings:reject_approval", + generateContract: "edr_freight_app:bookings:generate_contract", + signStaff: "edr_freight_app:bookings:sign_staff", + operations: "edr_freight_app:bookings:operations", + cancel: "edr_freight_app:bookings:cancel", + }, +} as const; + +const slugToResourceKey = (slug: RuleEngineResourceSlug): string => + slug.replace(/-/g, "_"); + +export function getPermissionKeys(user: AuthUser | null | undefined): string[] { + if (!user) return []; + if (user.permissionKeys?.length) return user.permissionKeys; + + const keys = new Set(); + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const emp of user.employee ?? []) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; +} + +export function isSuperAdmin(user: AuthUser | null | undefined): boolean { + if (user?.isSuperAdmin) return true; + return Boolean(user?.roles?.some((r) => r.key === "super_admin")); +} + +export function hasPermission( + user: AuthUser | null | undefined, + key: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; + return getPermissionKeys(user).includes(key); +} + +export function canAccessBookings(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.bookings.view); +} + +export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string { + return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`; +} + +export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string { + return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`; +} + +export function canAccessRuleEngineResource( + user: AuthUser | null | undefined, + slug: RuleEngineResourceSlug, + mode: "view" | "manage", +): boolean { + const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug); + return hasPermission(user, key); +} + +export function canAccessAnyRuleEngineView( + user: AuthUser | null | undefined, + slugs: RuleEngineResourceSlug[], +): boolean { + return slugs.some((slug) => canAccessRuleEngineResource(user, slug, "view")); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 32a7d9750..7c22f9d45 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -17,7 +17,6 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { invalidateBookingDetail } from "@/utils/queryInvalidation"; import { bookingsService, - type ContractView, type SignContractPayload, } from "@/services/bookings.service"; import { cn } from "@/lib/utils"; @@ -37,6 +36,7 @@ export default function BookingContractPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); @@ -82,7 +82,10 @@ export default function BookingContractPage() { } }, [id, data?.reference]); - const handlePrint = () => window.print(); + const handlePrint = () => { + iframeRef.current?.contentWindow?.focus(); + iframeRef.current?.contentWindow?.print(); + }; const openSign = () => { setSignerName(""); @@ -135,7 +138,7 @@ export default function BookingContractPage() { />
        -
        +
        -
        + A PDF has not been stored yet. Download will generate the latest + contract document automatically. +
      4. + )} + +