Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-13 11:34:57 +00:00
47 changed files with 1441 additions and 72 deletions

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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;

View File

@@ -11,7 +11,7 @@
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Services</p>

View File

@@ -9,7 +9,7 @@
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>

View File

@@ -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 @@
<body>
<main>
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Last-Mile Delivery Contract</p>

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
}
}

View File

@@ -14,7 +14,7 @@ const model = (over: Partial<InvoiceDocumentModel> = {}): 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());

View File

@@ -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
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
@@ -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 {
<div class="doc">
<div class="top">
<div>
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* `<img>` 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 <style> block (see logoImageCss()).
*/
export function logoMarkup(
logoImageUrl: string | null | undefined,
className = "doc-logo",
): string {
if (!logoImageUrl) return "";
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
}
/** Default CSS for the header logo — append inside a document's <style> block. */
export function logoImageCss(className = "doc-logo"): string {
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { BadRequestException, ConflictException } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
@@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
});
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => {
const db = new FakeDb([
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }),
]);
const postBearer = jest.fn();
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
ConflictException,
);
expect(postBearer).not.toHaveBeenCalled();
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
it("refuses to cancel an invoice that was never registered", async () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager } from "typeorm";
@@ -38,8 +38,11 @@ export class EimsCancellationService {
) {}
/**
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
* that was never registered — there is no IRN to cancel.
* Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in
* MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a
* repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not
* a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to
* cancel.
*/
async cancelInvoiceWithEims(
invoiceId: string,
@@ -48,7 +51,12 @@ export class EimsCancellationService {
): Promise<EimsInvoiceStatusView> {
const eligible = await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new ConflictException({
code: "EIMS_ALREADY_CANCELLED",
message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`,
});
}
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NOT_REGISTERED",
@@ -57,7 +65,6 @@ export class EimsCancellationService {
}
return invoice;
});
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
// Outside any transaction — no DB lock is held across the wire.

View File

@@ -81,7 +81,7 @@ export class EimsInvoiceController {
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
"Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.",
})
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);

View File

@@ -13,6 +13,7 @@ import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesService } from '../signatures/signatures.service';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
@@ -41,6 +42,7 @@ export class LastMileContractService {
private readonly pdfService: ContractPdfService,
private readonly signaturesService: SignaturesService,
private readonly dataSource: DataSource,
private readonly logoSettings: LogoSettingsService,
) {}
async getContractView(id: string, viewerUserId?: string | null) {
@@ -194,6 +196,7 @@ export class LastMileContractService {
return {
companyName: booking.company?.name ?? 'Customer',
bookingReference: booking.reference ?? request.bookingId,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
containerCount: containers.length || null,
containerList: containers.join(', '),
cargoDescription,

View File

@@ -48,6 +48,19 @@ export class LastMileRequestsController {
return this.requestsService.freeTruckCount().then((count) => ({ count }));
}
// Customer-facing like :id — booking detail (portal + backoffice) lists the
// booking's requests to link the stored LM contract. Ownership-checked in
// the service for portal callers.
@Get('by-booking/:bookingId')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" })
findForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.findForBooking(bookingId, user?.id ?? null);
}
@Get(':id/price-estimate')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({

View File

@@ -221,6 +221,28 @@ export class LastMileRequestsService {
return record;
}
/**
* Every request on a booking, newest first — the booking-detail pages
* (portal + backoffice) use this to surface the LM contract later. Portal
* callers pass their userId and are ownership-checked against the booking's
* company, mirroring findById.
*/
async findForBooking(bookingId: string, userId?: string | null): Promise<LastMileRequest[]> {
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId) {
const booking = await this.bookingsRepository.findById(bookingId);
if (booking?.companyId && booking.companyId !== companyId) {
throw new BadRequestException('This booking does not belong to your company');
}
}
}
return this.requestsRepository.findAll({
where: { bookingId },
order: { createdAt: 'DESC' },
});
}
/**
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
* delivery point, straight-line) × the LIVE last-mile rate rules against the

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
export class UpdateLogoSettingDto {
@ApiProperty({ description: "Logo image as a base64 data URL (PNG/JPG)." })
@IsString()
@MinLength(1)
logoImageBase64!: string;
}

View File

@@ -0,0 +1,25 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
/**
* Single-row table holding the one company logo image stamped onto every
* generated document (invoices/receipts, contracts, warehouse papers,
* train-scheduling manifests, payment receipts). Same single-row shape as
* stamp_settings — `get()` lazily creates the row, and there is never more
* than one.
*/
@Entity({ schema: "freight", name: "logo_settings" })
export class LogoSetting extends BaseEntity {
@Column({ name: "logo_file_id", type: "uuid", nullable: true })
logoFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: "logo_file_id" })
logoFile?: FileRecord | null;
/** IAM user id of the last operator to set/clear the logo. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
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 { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
import { LogoSettingsService } from "./logo-settings.service";
@ApiTags("logo-settings")
@ApiBearerAuth()
@Controller("logo-settings")
export class LogoSettingsController {
constructor(private readonly service: LogoSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company logo used on every generated document" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company logo" })
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company logo (documents fall back to their text mark)",
})
clear(@CurrentUser() user: TCurrentUser) {
return this.service.clearLogo(user?.id ?? null);
}
}

View File

@@ -0,0 +1,24 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { LogoSetting } from "./entities/logo-setting.entity";
import { LogoSettingsController } from "./logo-settings.controller";
import { LogoSettingsRepository } from "./logo-settings.repository";
import { LogoSettingsService } from "./logo-settings.service";
/**
* Global so every document-generating module (billing, contracts,
* warehouses, train-scheduling, payment) can inject {@link LogoSettingsService}
* without pulling in a circular dependency — same reasoning as
* StampSettingsModule.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([LogoSetting]), FilesModule, MinioModule],
controllers: [LogoSettingsController],
providers: [LogoSettingsRepository, LogoSettingsService],
exports: [LogoSettingsService],
})
export class LogoSettingsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { LogoSetting } from "./entities/logo-setting.entity";
@Injectable()
export class LogoSettingsRepository extends BaseRepository<LogoSetting> {
constructor(
@InjectRepository(LogoSetting)
repo: Repository<LogoSetting>,
) {
super(repo);
}
/** The single settings row, with its logo file joined, or null before first upload. */
findSingleton(): Promise<LogoSetting | null> {
return this.repository.findOne({ where: {}, relations: ["logoFile"] });
}
}

View File

@@ -0,0 +1,161 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { LogoSettingsRepository } from "./logo-settings.repository";
import { LogoSetting } from "./entities/logo-setting.entity";
export interface LogoSettingView {
logoImageUrl: string | null;
updatedById: string | null;
updatedAt: Date | null;
}
/**
* Owns the single `logo_settings` row: the one company logo image used on
* every generated document. Same single-row shape as StampSettingsService,
* the value is an uploaded image (via FilesService) rather than a scalar.
*/
@Injectable()
export class LogoSettingsService {
private readonly logger = new Logger(LogoSettingsService.name);
constructor(
private readonly repository: LogoSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
async get(): Promise<LogoSetting> {
const existing = await this.repository.findSingleton();
if (existing) return existing;
return this.repository.create({ logoFileId: null, updatedById: null });
}
/** Current logo, with the image inlined as a data URL (or null if unset). */
async getView(): Promise<LogoSettingView> {
const setting = await this.get();
return {
logoImageUrl: await this.inlineImageUrl(setting.logoFile?.url),
updatedById: setting.updatedById ?? null,
updatedAt: setting.updatedAt ?? null,
};
}
/**
* The logo image for embedding into generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the logo lookup fails; callers render their existing text/mark
* fallback on null (see logo-markup.util.ts).
*/
async getLogoImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
const inlined = await this.inlineImageUrl(setting.logoFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company logo could not be inlined for document rendering (falling back to the text mark): ${inlined}`,
);
return null;
}
return inlined;
} catch (err) {
this.logger.warn(
`Could not load company logo for PDF rendering: ${(err as Error).message}`,
);
return null;
}
}
/** Replace the logo image, storing it in MinIO via FilesService. */
async setLogo(
logoImageBase64: string,
updatedById?: string | null,
): Promise<LogoSettingView> {
const current = await this.get();
const previousFileId = current.logoFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
resource: "logo_settings",
code: "logo",
file: this.toUploadFile(logoImageBase64),
uploadedByUserId: updatedById ?? null,
});
await this.repository.update(current.id, {
logoFileId: fileRecord.id,
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company logo updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the logo (documents fall back to their text/mark). */
async clearLogo(updatedById?: string | null): Promise<LogoSettingView> {
const current = await this.get();
const previousFileId = current.logoFileId ?? null;
await this.repository.update(current.id, {
logoFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}
private toUploadFile(base64: string): Express.Multer.File {
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
const buffer = Buffer.from(raw, "base64");
return {
fieldname: "logo",
originalname: "company-logo.png",
encoding: "7bit",
mimetype: "image/png",
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: "",
filename: "",
path: "",
};
}
private async inlineImageUrl(url?: string | null): Promise<string | null> {
if (!url) return null;
if (url.startsWith("data:")) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString("base64")}`;
} catch {
return url;
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
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)));
});
}
}

View File

@@ -1,6 +1,9 @@
import { DataSource } from 'typeorm';
import { Logger } from '@nestjs/common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationsService } from './notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr,
@@ -42,3 +45,41 @@ export async function sendCompanyChannels(
}
}
}
/**
* Tell the customer their export carriage acceptance sheet is ready to
* download from the portal — the sheet itself is generated on demand by
* BookingsService.carriageAcceptanceSheet, never stored, so this is a
* "ready" notice + link, not an attachment (the email pipeline carries text
* only). Shared by every path that makes a booking's handover final: the
* warehouse gate on receive, and direct truck-to-train on load (that cargo
* never sees a warehouse, so its handover moment IS the load).
*/
export async function notifyCarriageAcceptanceReady(
dataSource: DataSource,
notifications: NotificationsService,
inbox: NotificationInboxService,
bookingId: string,
logger: Logger,
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await 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 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(dataSource, notifications, b.companyId, body);
} catch (err) {
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
}
}

View File

@@ -49,6 +49,7 @@ describe("PaymentService.confirmOtp", () => {
repo as never,
client as never,
billing as never,
{} as never,
);
return { service, repo, billing };
};
@@ -197,6 +198,7 @@ describe("PaymentService.markIntentSucceeded", () => {
repo as never,
{} as never,
billing as never,
{} as never,
);
return { service, repo, billing };
};

View File

@@ -13,6 +13,7 @@ import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import { LogoSettingsService } from "../logo-settings/logo-settings.service";
import * as fs from "fs";
import * as path from "path";
@@ -104,6 +105,7 @@ export class PaymentService {
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
private readonly logoSettings: LogoSettingsService,
) { }
async getAll(filters: {
@@ -657,6 +659,7 @@ export class PaymentService {
total: payment.amount.toString(),
currency: payment.currency,
reason: payment.reason,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
}

View File

@@ -45,6 +45,14 @@
margin: 0;
}
.header .doc-logo {
display: block;
margin: 0 auto 10px;
max-height: 48px;
max-width: 180px;
object-fit: contain;
}
.details-table {
width: 100%;
border-collapse: collapse;
@@ -126,6 +134,7 @@
<body>
<div class="receipt-box">
<div class="header">
{{#if logoImageUrl}}<img class="doc-logo" src="{{logoImageUrl}}" alt="Company logo" />{{/if}}
<h1>{{vendorName}}</h1>
<p>{{vendorAddress}}</p>
</div>

View File

@@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
{} as never, // yardFacilities
{} as never, // facilityHandling
{ emit: jest.fn() } as never, // events
{} as never, // notifications
{} as never, // inbox
);
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };

View File

@@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -52,6 +55,8 @@ export class BookingJourneyService {
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -76,6 +81,21 @@ export class BookingJourneyService {
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
// handover moment — the carriage acceptance sheet must go out to the
// customer right here, not on a receive event that will never fire.
if (
booking.tradeDirection === 'EXPORT' &&
booking.exportHandoverMode === DIRECT_TO_TRAIN
) {
await notifyCarriageAcceptanceReady(
this.dataSource,
this.notifications,
this.inbox,
booking.id,
this.logger,
);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {

View File

@@ -181,6 +181,7 @@ describe('TrainSchedulingService', () => {
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
} as never, // bookingJourneyService
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
);
const defaultFleetWagons = [

View File

@@ -177,6 +177,8 @@ import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
import { LogoSettingsService } from '../../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../../billing/documents/logo-markup.util';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
@@ -376,6 +378,7 @@ export class TrainSchedulingService {
private readonly bookingWindowGateway: BookingWindowGateway,
private readonly bookingJourneyService: BookingJourneyService,
private readonly bookingNotifier: BookingNotifierService,
private readonly logoSettings: LogoSettingsService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
// forwardRef: BookingBatchService injects this service back; @Optional so
@@ -3002,6 +3005,9 @@ export class TrainSchedulingService {
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
equatedLengthM: wagon.wagonType?.equatedLengthM ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
@@ -3022,7 +3028,7 @@ export class TrainSchedulingService {
const loadList = await this.generateImportLoadList(scheduleId, {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
const html = this.buildImportLoadListHtml(loadList, await this.logoSettings.getLogoImageUrl());
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
@@ -3042,7 +3048,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
}
const html = this.buildExportLoadListHtml(schedule);
const html = this.buildExportLoadListHtml(schedule, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
@@ -3114,6 +3122,7 @@ export class TrainSchedulingService {
positionLabel,
wagons,
unassignedBookings,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3157,6 +3166,7 @@ export class TrainSchedulingService {
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
logoImageUrl?: string | null;
},
): string {
const esc = (value: unknown) =>
@@ -3275,6 +3285,7 @@ export class TrainSchedulingService {
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -3289,6 +3300,7 @@ export class TrainSchedulingService {
<body>
<div class="top">
<div>
${logoMarkup(opts?.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
</div>
@@ -3468,7 +3480,10 @@ export class TrainSchedulingService {
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
private buildImportLoadListHtml(
loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>,
logoImageUrl?: string | null,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
@@ -3501,26 +3516,37 @@ export class TrainSchedulingService {
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(wagon.wagonType)}</td>
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
<td>${esc(loadList.origin)}</td>
<td>${esc(loadList.destination)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
<td colspan="7">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const sealNumbers = (allocation.containerItems ?? [])
.map((item) => item.sealNumber)
.filter(Boolean)
.join(', ');
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td>
<td></td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
@@ -3548,6 +3574,7 @@ export class TrainSchedulingService {
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.tile strong { font-size: 13px; }
${logoImageCss()}
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
@@ -3569,6 +3596,7 @@ export class TrainSchedulingService {
<div class="doc">
<div class="top">
<div>
${logoMarkup(logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Load List /<br />Marshalling Document</h1>
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
@@ -3609,15 +3637,22 @@ export class TrainSchedulingService {
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Wagon Type</th>
<th class="num">Tare</th>
<th class="num">Equated</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th>Seal No</th>
<th>Note</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
${allocationRows || '<tr><td colspan="14">No wagons on this train set.</td></tr>'}
</tbody>
</table>

View File

@@ -28,13 +28,18 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
import { LastMileService } from '../last-mile/last-mile.service';
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
sendCompanyChannels,
notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared,
} from '../notifications/notify-company.util';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -417,6 +422,7 @@ export class WarehouseInventoryService {
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
/**
@@ -3748,6 +3754,7 @@ export class WarehouseInventoryService {
reference,
issuedAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
bookingReference,
bookingStatus: row?.bookingStatus ?? null,
customerName: row?.customerName ?? null,
@@ -4219,6 +4226,7 @@ export class WarehouseInventoryService {
const html = this.buildGrnDocumentHtml({
grnNumber: row.grnNumber,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
bookingStatus: row.bookingStatus ?? null,
@@ -4803,6 +4811,7 @@ export class WarehouseInventoryService {
reference,
handedOverAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
@@ -5484,6 +5493,8 @@ export class WarehouseInventoryService {
zone: string | null;
inventoryStatus: string | null;
receiveSummary: string | null;
/** The one global company logo; null renders the plain text brand. */
logoImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5540,6 +5551,7 @@ export class WarehouseInventoryService {
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
${logoImageCss()}
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
@@ -5553,6 +5565,7 @@ export class WarehouseInventoryService {
<body>
<div class="top">
<div>
${logoMarkup(data.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Goods Received Note</h1>
<div class="subtitle">Warehouse receiving confirmation</div>
@@ -5610,6 +5623,8 @@ export class WarehouseInventoryService {
truckWeightTons?: number | 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 ?? '-')
@@ -5697,12 +5712,14 @@ 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()}
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
${logoMarkup(data.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
@@ -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()}
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(data.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Goods Handover Document</h1>
<div class="subtitle">EDR to customer warehouse handover</div>
@@ -6167,26 +6188,13 @@ export class WarehouseInventoryService {
* fires right after receive, not at marshalling.
*/
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
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: {

View File

@@ -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.

View File

@@ -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={<Navigate to="/dashboard/stamp-settings" replace />}
/>
{/* The ONE company logo, shown in the header of every generated document. */}
<Route
path="logo-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.logo.view}>
<LogoSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import { Truck } from "lucide-react";
import { SimpleGrid, Stack } from "@mantine/core";
import { Download, Truck } from "lucide-react";
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
@@ -16,18 +19,44 @@ export interface BookingMileServicesCardProps {
handoverSection?: ReactNode;
}
/** First / last mile addresses, plus the export handover control. Renders
* nothing when none of the three are present. */
/**
* First / last mile addresses, plus the export handover control and the
* stored last-mile contract reference (signed status + PDF download) for
* Truck & Machinery once a request on this booking is approved. Renders
* nothing when none of the three are present.
*/
export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
const { data: requestsResponse } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
queryFn: async () =>
(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 (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<Stack gap="md">
@@ -41,6 +70,32 @@ export function BookingMileServicesCard({
)}
</SimpleGrid>
)}
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
{handoverSection}
</Stack>
</SectionCard>

View File

@@ -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<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(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 (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company logo"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Logo attached"}
</Text>
<Text size="xs" c="dimmed">
Shown in the header of every generated document.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
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",
}}
>
<Stack gap={6} align="center">
<ImageIcon size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company logo
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_LOGO_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -9,6 +9,7 @@ import {
FileText,
Hammer,
History,
Image as ImageIcon,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -492,6 +493,12 @@ export const buildSidebarSections = (
icon: <Stamp />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Company logo",
href: "/dashboard/logo-settings",
icon: <ImageIcon />,
permission: FREIGHT_PERMS.settings.logo.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -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<string | null>(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 (
<BookingCard
key={b.id}
@@ -767,6 +800,24 @@ export function ScheduleWorkspacePanel({
</Button>
</Tooltip>
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
withArrow
>
<Button
size="compact-sm"
variant="light"
color="blue"
radius="md"
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
>
Truck to Train
</Button>
</Tooltip>
) : null}
{showUnload ? (
<Tooltip
label={

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { logoSettingsService } from "@/services/logoSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["logoSettings"];
export const useLogoSettingsQuery = () =>
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,
});
};

View File

@@ -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.

View File

@@ -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<string | null>(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 (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Company logo</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LogoUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company logo"
description="Shown in the header of every generated document."
/>
<div className="flex items-center gap-2">
<Button onClick={handleSave} disabled={!dirty || setLogo.isPending}>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.logoImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearLogo.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<LogoSettings> => {
const response = await client.get<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
set: async (logoImageBase64: string): Promise<LogoSettings> => {
const response = await client.put<ApiResponse<LogoSettings>>(BASE, {
logoImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<LogoSettings> => {
const response = await client.delete<ApiResponse<LogoSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -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`,

View File

@@ -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 (
<Group
justify="space-between"
align="center"
wrap="wrap"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
Last-mile contract
</Text>
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
{signedAt
? `Signed ${new Date(signedAt).toLocaleDateString()}${
signerDisplayName ? ` by ${signerDisplayName}` : ""
}`
: "Awaiting your signature"}
</Text>
</Stack>
<Group gap={8}>
<Button
size="xs"
variant="light"
onClick={() =>
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
}
>
{signedAt ? "View contract" : "View & sign"}
</Button>
<Button size="xs" variant="default" onClick={() => void download()}>
Download PDF
</Button>
</Group>
</Group>
);
}
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 && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
<Box>
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
{approvedRequest && (
<LastMileContractRow
bookingId={booking.id}
requestId={approvedRequest.id}
signedAt={approvedRequest.customerSignedAt}
signerDisplayName={approvedRequest.signerDisplayName}
/>
)}
</Box>
)}
</Stack>
</SectionCard>

View File

@@ -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<LastMileRequest[]> => {
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,

View File

@@ -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:');
});
});

View File

@@ -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<number, string> = {
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,
};
}

View File

@@ -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<string, unknown> {
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);