diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3bab613fb..c106ffff2 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -49,6 +49,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; +import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -112,6 +113,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; +import { AuditModule } from "./modules/audit/audit.module"; import { RequestLogMiddleware } from "@edr/api-common"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -208,6 +210,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, StampSettingsModule, + LogoSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, @@ -244,6 +247,7 @@ if (!process.env.APPLICATION_NAME) { EimsModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, 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 94a6809e6..517934bb9 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 @@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -111,6 +112,8 @@ export interface ContractViewModel { hasCustomerSignature: boolean; hasStaffSignature: boolean; dynamicTemplate?: ContractDynamicTemplateView; + /** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */ + logoImageUrl?: string | null; } @Injectable() @@ -121,6 +124,7 @@ export class ContractViewModelBuilder { private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -138,6 +142,7 @@ export class ContractViewModelBuilder { template.freight, ); const signatures = await this.loadSignatures(bookingId); + const logoImageUrl = await this.logoSettings.getLogoImageUrl(); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); @@ -194,6 +199,7 @@ export class ContractViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + logoImageUrl, }; return { booking, view }; 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 d9bc9927f..09bf3031e 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -77,6 +77,12 @@ letter-spacing: 0.08em; width: 72px; } + .logo-mark img { + display: block; + max-height: 100%; + max-width: 100%; + object-fit: contain; + } .kicker { color: #0e5b45; font-family: Arial, sans-serif; diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 6ba7c1610..81631e5d2 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -11,7 +11,7 @@ {{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Services

diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs index 75f795bce..f576c99ce 100644 --- a/apps/edr-freight-api/src/contracts/templates/generic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -9,7 +9,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Contract

diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs index 9437bb18b..0cfef51d5 100644 --- a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -9,6 +9,7 @@ main { padding: 32px 40px; } .brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; } .logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; } + .logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; } .kicker { margin: 0; font-weight: 700; } .muted { margin: 0; color: #666; } h1 { font-size: 20px; margin: 24px 0 4px; } @@ -32,7 +33,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Last-Mile Delivery Contract

diff --git a/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts new file mode 100644 index 000000000..36d70906f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company logo image stamped onto every + * generated document (see LogoSettingsService). Same single-row shape as + * stamp_settings; the app never inserts more than one row. + */ +export class LogoSettings3500000000000 implements MigrationInterface { + name = "LogoSettings3500000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.logo_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + logo_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index 00e590e17..bc5250888 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -14,7 +14,7 @@ const model = (over: Partial = {}): InvoiceDocumentModel = }); describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { - const service = new InvoiceDocumentService({} as never, {} as never); + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); it("renders no QR block when qrImageUrl is unset", () => { const html = service.buildHtml(model()); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index d83b7e2f0..f6b264636 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,8 +1,10 @@ import { Injectable } from "@nestjs/common"; import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; +import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; +import { logoImageCss, logoMarkup } from "./logo-markup.util"; import { PdfColor, assembleSinglePagePdf, @@ -62,6 +64,7 @@ export interface InvoiceDocumentModel { * explicitly only to override that default for one document. */ stampImageUrl?: string | null; + logoImageUrl?: string | null; /** * MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` — * see that column's comment). Set only once an invoice is actually registered; the IRN text @@ -81,6 +84,7 @@ export class InvoiceDocumentService { constructor( private readonly pdf: PdfRenderService, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async render( @@ -90,7 +94,11 @@ export class InvoiceDocumentService { model.stampImageUrl !== undefined ? model.stampImageUrl : await this.stampSettings.getStampImageUrl(); - const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + const logoImageUrl = + model.logoImageUrl !== undefined + ? model.logoImageUrl + : await this.logoSettings.getLogoImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl }; const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; @@ -250,6 +258,7 @@ export class InvoiceDocumentService { model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); const sealInner = sealMarkup(model.stampImageUrl, sealText); const sealCssClass = sealClass(model.stampImageUrl); + const logoInner = logoMarkup(model.logoImageUrl); const qrMarkup = model.qrImageUrl ? `
EIMS verification QRScan to verify (MoR EIMS)
` @@ -293,6 +302,7 @@ export class InvoiceDocumentService { .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } ${sealImageCss()} + ${logoImageCss()} .qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; } .qr img { width: 90px; height: 90px; } .qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; } @@ -322,6 +332,7 @@ export class InvoiceDocumentService {
+ ${logoInner}
Ethio-Djibouti Railway S.C.

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

diff --git a/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts new file mode 100644 index 000000000..f78109855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts @@ -0,0 +1,35 @@ +/** + * The single decision every EDR document makes about its header logo: draw + * the one uploaded company logo when configured (LogoSettingsService), or + * render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next + * to it already covers the no-logo case, so there is no text fallback here + * (contrast seal-markup.util.ts, whose seal has no text of its own). + */ + +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * `` markup for the header logo, or "" when unset. `logoImageUrl` is + * expected to be a data URL from LogoSettingsService.getLogoImageUrl(). + * `className` defaults to "doc-logo" — each document supplies that class's + * sizing in its own
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Warehouse Release / Exit Paper

Official gate clearance and warehouse exit authorization
@@ -5772,6 +5789,8 @@ export class WarehouseInventoryService { } | null; /** The one global company stamp; null falls back to the drawn text seal. */ stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5850,11 +5869,13 @@ export class WarehouseInventoryService { .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Goods Handover Document

EDR to customer warehouse handover
@@ -6167,26 +6188,13 @@ export class WarehouseInventoryService { * fires right after receive, not at marshalling. */ private async notifyCarriageAcceptanceReady(bookingId: string): Promise { - try { - const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( - `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - if (!b?.companyId) return; - const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; - await this.inbox.notify({ - recipients: { companyId: b.companyId }, - audience: NotificationAudience.PORTAL, - type: NotificationType.DOCUMENT_ACTION, - title: 'Carriage acceptance sheet ready', - body, - link: `/bookings/${bookingId}`, - data: { bookingId, reference: b.reference }, - }); - await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); - } catch (err) { - this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); - } + await notifyCarriageAcceptanceReadyShared( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + this.logger, + ); } private async notifyOwnerInventoryReceived(params: { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index e98127042..f3aa1ef2f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1260,6 +1260,16 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:stamp:manage", "Manage the company stamp", ), + perm( + "b4b00004-0001-4000-8000-000000000001", + "edr_freight_app:settings:logo:view", + "View the company logo", + ), + perm( + "b4b00004-0001-4000-8000-000000000002", + "edr_freight_app:settings:logo:manage", + "Manage the company logo", + ), // The per-officer approval teeter (ማህተም) — an individual's own stamp + // signature, not the company seal. It used to ride on settings:stamp:*, which // now gates the ONE company stamp; this key was split out when the two were @@ -1985,6 +1995,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, // and NOT the company seal above. Retired: `invoiceStamp`, which used to // gate the company stamp before the two were untangled. diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index cf6e275ca..a9b9db226 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,6 +52,7 @@ import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; +import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -1044,6 +1045,15 @@ const App = () => { path="invoice-stamp-settings" element={} /> + {/* The ONE company logo, shown in the header of every generated document. */} + + + + } + /> + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + if (!hasAddresses && !handoverSection) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( @@ -41,6 +70,32 @@ export function BookingMileServicesCard({ )} )} + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + + )} {handoverSection} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx new file mode 100644 index 000000000..f8ffe0fd0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from "react"; +import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core"; +import { ImageIcon, RefreshCw, X } from "lucide-react"; + +const MAX_LOGO_MB = 10; + +export interface LogoUploadProps { + /** Logo image as a data URL, or null when none is attached yet. */ + value: string | null; + onChange: (dataUrl: string | null) => void; + label?: string; + description?: string; +} + +/** + * Company logo picker — reads the picked image straight into a data URL, + * same transport as {@link StampUpload}. Kept as its own component (not a + * generalized image-upload) matching how stamp/teeter are already separate + * files here despite the near-identical shape. + */ +export function LogoUpload({ + value, + onChange, + label = "Company logo", + description = "Attach the official company logo.", +}: LogoUploadProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + const readFile = (file: File | undefined | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("The logo must be an image file (PNG or JPG)."); + return; + } + if (file.size > MAX_LOGO_MB * 1024 * 1024) { + setError(`The logo image must be under ${MAX_LOGO_MB} MB.`); + return; + } + const reader = new FileReader(); + reader.onload = () => { + setError(null); + setFileName(file.name); + onChange(typeof reader.result === "string" ? reader.result : null); + }; + reader.onerror = () => setError("Could not read that file. Try another."); + reader.readAsDataURL(file); + }; + + const openPicker = () => inputRef.current?.click(); + + const clear = () => { + setFileName(null); + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( + + + {label} + + + readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company logo + + + + {fileName ?? "Logo attached"} + + + Shown in the header of every generated document. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company logo + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_LOGO_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index e3db92296..c032a3220 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -9,6 +9,7 @@ import { FileText, Hammer, History, + Image as ImageIcon, LayoutDashboard, LayoutGrid, MapPin, @@ -492,6 +493,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.settings.stamp.view, }, + { + label: "Company logo", + href: "/dashboard/logo-settings", + icon: , + permission: FREIGHT_PERMS.settings.logo.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 47b94d69b..a023aeb88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -29,6 +29,7 @@ import { // Repeat, // used by the hidden Move (reassign) button Train, TrainFront, + Truck, Weight, X, } from "lucide-react"; @@ -38,6 +39,7 @@ import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { EntityLink } from "@/components/detail"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, @@ -412,6 +414,31 @@ export function ScheduleWorkspacePanel({ ); }; + // Export cargo that skipped the warehouse (customer truck straight onto the + // wagon) has no GRN and never will — loadBooking's GRN gate would keep + // rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage + // acceptance sheet is the handover document instead, then loads in one click. + const [truckToTrainPending, setTruckToTrainPending] = useState(null); + const doTruckToTrain = (bookingId: string, ref: string) => { + setTruckToTrainPending(bookingId); + bookingsService + .setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN") + .then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId })) + .then(() => { + toast({ title: `${ref} loaded — direct truck-to-train handover` }); + onChanged(); + void yardWorkQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not load as direct truck-to-train", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ) + .finally(() => setTruckToTrainPending(null)); + }; + const doUnload = (bookingId: string, ref: string) => { unloadJourney .mutateAsync({ scheduleId: schedule.id, bookingId }) @@ -710,6 +737,12 @@ export function ScheduleWorkspacePanel({ const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId; const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false); const showUnload = canWork && riding && (journey?.canUnload ?? false); + const showTruckToTrain = + canWork && + !riding && + !done && + boardHere && + b.tradeDirection === "EXPORT"; return ( ) : null} + {showTruckToTrain ? ( + + + + ) : null} {showUnload ? ( + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => logoSettingsService.get(), + }); + +export const useSetLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (logoImageBase64: string) => + logoSettingsService.set(logoImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.updated", "Company logo updated")); + }, + onError: handleError, + }); +}; + +export const useClearLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => logoSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.cleared", "Company logo removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c69aa5d1b..06f1f45da 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -334,6 +334,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, // and NOT the company seal above. Retired: `invoiceStamp`, which used to // gate the company stamp before the two were untangled. diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx new file mode 100644 index 000000000..d2f09cd48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Button } from "@/shared/common/ui/button"; +import { Save, Trash2 } from "lucide-react"; + +import { LogoUpload } from "@/components/contracts/LogoUpload"; +import { + useClearLogo, + useSetLogo, + useLogoSettingsQuery, +} from "@/hooks/useLogoSettings"; + +/** + * The ONE company logo, read by every document path server-side via + * LogoSettingsService: invoices and receipts, contract cover pages, warehouse + * GRN/release/handover papers, train-scheduling manifests, and the payment + * receipt. Single global image — no per-document choice. + */ +export default function LogoSettingsPage() { + const { data, isLoading } = useLogoSettingsQuery(); + const setLogo = useSetLogo(); + const clearLogo = useClearLogo(); + const [draft, setDraft] = useState(null); + + useEffect(() => { + setDraft(null); + }, [data?.logoImageUrl]); + + const value = draft !== null ? draft : (data?.logoImageUrl ?? null); + const dirty = draft !== null && draft !== data?.logoImageUrl; + + const handleSave = async () => { + if (!draft) return; + await setLogo.mutateAsync(draft); + }; + + const handleClear = async () => { + if (!data?.logoImageUrl) return; + await clearLogo.mutateAsync(); + }; + + return ( +
+ + + Company logo + + The single EDR logo, applied to every generated document — + invoices and receipts, contracts, warehouse papers, train-scheduling + manifests, and payment receipts. Replacing it here changes it + everywhere at once. + + + + + +
+ + {data?.logoImageUrl && !dirty && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts new file mode 100644 index 000000000..fd89f2ed3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts @@ -0,0 +1,31 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = "/logo-settings"; + +/** Company logo used on every generated document. */ +export interface LogoSettings { + logoImageUrl: string | null; + updatedById: string | null; + updatedAt: string | null; +} + +export const logoSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + set: async (logoImageBase64: string): Promise => { + const response = await client.put>(BASE, { + logoImageBase64, + }); + return unwrap(response.data); + }, + + clear: async (): Promise => { + const response = await client.delete>(BASE); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a0d33f5ba..c9576d192 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,6 +224,7 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { + BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`, BY_ID: (id: string) => `/api/last-mile-requests/${id}`, SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx index bfb57529f..6619dbd06 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -1,5 +1,6 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -8,6 +9,7 @@ import type { MileLegSummary, MileVehicleSummary, } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import { CardTitle, SectionCard } from "./layout"; @@ -171,12 +173,85 @@ function LegBlock({ ); } +/** + * Reference row for the stored last-mile contract: signed status, open the + * contract page (view / sign), download the PDF. + */ +function LastMileContractRow({ + bookingId, + requestId, + signedAt, + signerDisplayName, +}: { + bookingId: string; + requestId: string; + signedAt?: string | null; + signerDisplayName?: string | null; +}) { + const navigate = useNavigate(); + const download = async () => { + const blob = await lastMileRequestsService.downloadContractDocument(requestId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "last-mile-contract.pdf"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + Last-mile contract + + + {signedAt + ? `Signed ${new Date(signedAt).toLocaleDateString()}${ + signerDisplayName ? ` by ${signerDisplayName}` : "" + }` + : "Awaiting your signature"} + + + + + + + + ); +} + export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { const { data } = useQuery({ queryKey: ["booking-mile-summary", booking.id], queryFn: () => bookingsService.mileSummary(booking.id), }); + // The stored LM contract lives on the booking's approved last-mile request. + const { data: lmRequests } = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: !!booking.lastMileDeliveryAddress, + }); + const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED"); + const firstLeg = data?.firstMile ?? null; const lastLeg = data?.lastMile ?? null; @@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { /> )} {showLast && ( - + + + {approvedRequest && ( + + )} + )} diff --git a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts index 5069eafe3..08133ba08 100644 --- a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts @@ -12,6 +12,7 @@ export interface LastMileRequest { requestedContainerNumbers?: string[] | null; requestedDeliveryDate?: string | null; customerSignedAt?: string | null; + signerDisplayName?: string | null; rejectionReason?: string | null; createdAt: string; updatedAt: string; @@ -46,6 +47,12 @@ export const lastMileRequestsService = { return data.data ?? data; }, + /** The booking's requests, newest first — links the stored LM contract. */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(L.BY_BOOKING(bookingId)); + return data.data ?? data; + }, + /** Confirm which containers go via EDR last-mile and the requested delivery date. */ submit: async ( id: string, diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts new file mode 100644 index 000000000..93fa94b50 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts @@ -0,0 +1,153 @@ +import { buildSeatSummary } from './booking-sms.utils'; + +const seat = (passengerName: string, seatNumber: string, leg = 1, coachType = 'VIP Bed') => ({ + passengerName, + leg, + seat: { seatNumber, coach: { number: 'VIP-0001 (DJ)', coachType: { name: coachType } } }, +}); + +describe('buildSeatSummary', () => { + it('greets a solo traveller by name and omits the name from the seat line', () => { + const { passengerName, trainSeatLines } = buildSeatSummary([seat('Yanet', '9')], 'ONE_WAY'); + + expect(passengerName).toBe('Yanet'); + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + expect(trainSeatLines).not.toContain('Train/Seat'); + expect(trainSeatLines).not.toContain('Yanet'); + }); + + it('greets a group collectively and names each seat', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '4'), seat('Abebe', '6'), seat('Sara', '9'), seat('Helen', '10')], + 'ONE_WAY', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + // The reported bug: the seats query had no orderBy, so Postgres heap order put the LAST + // passenger first and the SMS greeted them while texting the first passenger's phone. + it('is immune to seat rows arriving in an arbitrary order', () => { + const rows = [seat('Yanet', '9'), seat('Helen', '10'), seat('Abebe', '6'), seat('Sara', '4')]; + + const { passengerName, trainSeatLines } = buildSeatSummary(rows, 'ONE_WAY'); + + expect(passengerName).toBe('Passengers'); + // Every line pairs the right person with their own seat, regardless of input order. + expect(trainSeatLines).toBe( + [ + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + it('sorts seat numbers numerically, not lexicographically', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('A', '9'), seat('B', '10'), seat('C', '6'), seat('D', '4')], + 'ONE_WAY', + ); + + expect(trainSeatLines.match(/seat no\. \d+/g)).toEqual([ + 'seat no. 4', + 'seat no. 6', + 'seat no. 9', + 'seat no. 10', + ]); + }); + + it('labels round-trip legs as Outbound/Return, listing each passenger once per leg', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Outbound:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 10', + 'Return:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + // TRANSIT leg 2 is a connecting segment of the same outbound journey — never a return. + it('labels transit legs as Leg 1/Leg 2, never Return', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'TRANSIT', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + expect(trainSeatLines).not.toContain('Return'); + expect(trainSeatLines).not.toContain('Outbound'); + }); + + it('labels all four round-trip-transit legs', () => { + const { trainSeatLines } = buildSeatSummary( + [1, 2, 3, 4].map((leg) => seat('Yanet', String(leg), leg)), + 'ROUND_TRIP_TRANSIT', + ); + + expect(trainSeatLines).toBe( + [ + 'Outbound leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 1', + 'Outbound leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 2', + 'Return leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Return leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + it('greets a solo round-trip traveller by name (same person on both legs)', () => { + const { passengerName } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Yanet'); + }); + + it('trims a trailing space on the coach type instead of emitting "Bed , seat"', () => { + const { trainSeatLines } = buildSeatSummary([seat('Yanet', '9', 1, 'VIP Bed ')], 'ONE_WAY'); + + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + }); + + it('falls back safely on empty or malformed input', () => { + expect(buildSeatSummary([], 'ONE_WAY')).toEqual({ passengerName: 'Passenger', trainSeatLines: '' }); + + const { passengerName, trainSeatLines } = buildSeatSummary([{ leg: 1 }], 'ONE_WAY'); + expect(passengerName).toBe('Passenger'); + expect(trainSeatLines).toBe('-, seat no. -'); + }); + + it('falls back to a generic leg heading for an unknown booking type', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'SOMETHING_NEW', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + }); +}); diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts new file mode 100644 index 000000000..18b1db9ac --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts @@ -0,0 +1,115 @@ +/** + * Builds the two passenger-facing values the `booking.created` SMS/email template needs: + * the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block. + * + * Why this is a shared pure helper rather than inline logic: the salutation used to be + * `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres + * returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of + * the heap — so a group booking regularly greeted the LAST passenger while texting the + * first one's phone. Deriving both values from the whole seat set, sorted deterministically, + * removes the dependency on row order entirely, and keeps the formatting unit-testable + * without a Nest testing module. + * + * Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no + * phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are + * greeted collectively and each seat line names its own occupant. + */ + +export interface SeatSummary { + /** Salutation: the traveller's name when solo, otherwise 'Passengers'. */ + passengerName: string; + /** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */ + trainSeatLines: string; +} + +/** + * Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared + * numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last, + * then alphabetically among themselves. + */ +function compareSeatNumber(a: string, b: string): number { + const na = Number.parseInt(a, 10); + const nb = Number.parseInt(b, 10); + const aNum = Number.isNaN(na); + const bNum = Number.isNaN(nb); + if (aNum && bNum) return a.localeCompare(b); + if (aNum) return 1; + if (bNum) return -1; + return na - nb || a.localeCompare(b); +} + +const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim()); + +const ROUND_TRIP_TRANSIT_LEGS: Record = { + 1: 'Outbound leg 1', + 2: 'Outbound leg 2', + 3: 'Return leg 1', + 4: 'Return leg 2', +}; + +/** + * Leg numbering means different things per booking type — see the enum documented on + * TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound + * journey, so it must never be labelled 'Return'. + */ +function legLabel(bookingType: string | undefined, leg: number): string { + switch (bookingType) { + case 'ROUND_TRIP': + return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`; + case 'TRANSIT': + return `Leg ${leg}`; + case 'ROUND_TRIP_TRANSIT': + return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`; + default: + // Unknown or newly added booking type — degrade to a generic heading rather than guessing. + return `Leg ${leg}`; + } +} + +export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary { + const rows = [...(seats ?? [])].sort( + (a, b) => + (a?.leg ?? 1) - (b?.leg ?? 1) || + str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) || + compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)), + ); + + // Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so + // the same name legitimately repeats — count people, not rows. + const names: string[] = []; + for (const row of rows) { + const name = str(row?.passengerName); + if (name && !names.includes(name)) names.push(name); + } + const isGroup = names.length > 1; + + const line = (row: any): string => { + const coach = str(row?.seat?.coach?.number) || '-'; + const coachType = str(row?.seat?.coach?.coachType?.name); + const seatNo = str(row?.seat?.seatNumber) || '-'; + // Trim each part before joining: the coach-type name carries a trailing space in some + // records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of + // spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'. + const where = [coach, coachType].filter(Boolean).join(' '); + const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : ''; + return `${who}${where}, seat no. ${seatNo}`; + }; + + const legs = [...new Set(rows.map((row) => row?.leg ?? 1))]; + const trainSeatLines = + legs.length > 1 + ? legs + .map((leg) => + [ + `${legLabel(bookingType, leg)}:`, + ...rows.filter((row) => (row?.leg ?? 1) === leg).map(line), + ].join('\n'), + ) + .join('\n') + : rows.map(line).join('\n'); + + return { + passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'), + trainSeatLines, + }; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 9ef922bb3..3b7e7be2f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -8,6 +8,7 @@ import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; +import { buildSeatSummary } from '../../common/utils/booking-sms.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -305,7 +306,7 @@ export class NotificationsService { where: { id: bookingId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } }, orderBy: { leg: 'asc' } }, }, }); @@ -367,30 +368,19 @@ export class NotificationsService { } /** - * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a - * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get - * several lines). + * Builds the interpolation context for the `booking.created` template. `passengerName` and + * `trainSeatLines` both come from buildSeatSummary — a solo booking is greeted by name with + * bare "coach, seat no." lines, while a group is greeted as "Passengers" and each line names + * its own occupant (one SMS goes to Booking.contactPhone for the whole party). */ private buildBookingCreatedContext(booking: any, ref: string): Record { const s = booking?.schedule ?? {}; - const trainName = s.train?.name ?? s.train?.number ?? ''; const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD'; const fmtTime = (d: any) => d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD'; - const seats = booking?.seats ?? []; - const trainSeatLines = seats - .map((bs: any) => { - const coach = bs.seat?.coach?.number ?? '-'; - const cls = bs.seat?.coach?.coachType?.name ?? ''; - const seatNo = bs.seat?.seatNumber ?? '-'; - return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim(); - }) - .join('\n'); - - // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. - const passengerName = seats[0]?.passengerName ?? 'Passenger'; + const { passengerName, trainSeatLines } = buildSeatSummary(booking?.seats ?? [], booking?.bookingType); const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);