enhance booking and contract notification systems

- Added detailed logging for socket connection events in useBookingWindowSocket.
- Introduced new notification types for contract status and schedule updates.
- Updated notification visuals to include new icons for contract status.
- Enhanced notification href resolution for contract status and schedule updates.
- Implemented booking lifecycle notifier service for customer and staff notifications.
- Created contract notifier service for managing contract lifecycle notifications.
- Added end-to-end tests for booking window socket functionality.
This commit is contained in:
Marshal
2026-07-06 21:03:08 +00:00
parent 8bd00ea78a
commit 05b13e84a8
38 changed files with 1450 additions and 95 deletions

View File

@@ -93,6 +93,7 @@
"@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8",
"jest": "^29.7.0",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",

View File

@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
// Subpath imports (not the package root) so ts-jest can resolve them when this
// file lands in a spec's compile graph via the notification recipients chain.
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -40,6 +44,21 @@ export class BackofficeService {
private readonly dataSource: DataSource,
) {}
/**
* IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector.
*/
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({
where: { isCurrent: true },
});
return [
...new Set(
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
),
];
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -0,0 +1,307 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
* and operation flow. Every customer event fans out over SMS + email (direct)
* and a persisted in-app notification deep-linking to the booking detail page;
* staff events land in the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a booking transition.
*
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
* displaced) are handled separately by {@link BookingNotifierService} in
* train-scheduling.
*/
@Injectable()
export class BookingLifecycleNotifierService {
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the booking's company contact; log-only on failure. */
private async notifyContact(
b: Booking,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/booking-requests/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → booking is under approval. */
accepted(b: Booking): void {
const msg =
`Your booking ${b.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(b, msg, 'ACCEPTED');
this.inApp(b, 'Booking accepted', msg);
}
/** All approval steps complete → contract generated, ready for customer to sign. */
approved(b: Booking): void {
const msg =
`Your booking ${b.reference} has been approved. ` +
`Please review and sign your contract from the portal.`;
void this.notifyContact(b, msg, 'APPROVED');
this.inApp(b, 'Booking approved', msg);
}
/** Staff rejected the booking (intake or approval step). */
rejected(b: Booking, reason: string): void {
const msg =
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(b, msg, 'REJECTED');
this.inApp(b, 'Booking rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(b: Booking, note: string): void {
const msg =
`Changes were requested on your booking ${b.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
this.inApp(b, 'Booking changes requested', msg);
}
/** A clearance document was queried and needs the customer to re-upload. */
documentQueried(b: Booking, fileKey: string, note: string): void {
const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
this.inApp(b, 'Document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/** Clearance finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Clearance for booking ${b.reference} is complete. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY');
this.inApp(b, 'Clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
this.inApp(b, 'Operation request needs changes', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
}
/** Shipment started → in transit. */
inTransit(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
void this.notifyContact(b, msg, 'IN TRANSIT');
this.inApp(b, 'Shipment in transit', msg);
}
/** Shipment delivered → completed. */
completed(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
void this.notifyContact(b, msg, 'COMPLETED');
this.inApp(b, 'Shipment delivered', msg);
}
/** Booking cancelled. */
cancelled(b: Booking, reason: string): void {
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
void this.notifyContact(b, msg, 'CANCELLED');
this.inApp(b, 'Booking cancelled', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
dutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'DUTY ADVISED');
this.inApp(b, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL advised the post-arrival additional duty round (import). */
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
this.inApp(b, 'Additional duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
this.inApp(b, 'Final invoice paid', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking submitted',
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Customer signed booking contract',
`The contract for booking ${this.ref(b)} was signed by the customer.`,
);
}
/** Customer requested operation (picked a shipment day). */
operationRequestedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Operation requested',
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
);
}
/** Customer uploaded clearance documents — review is next. */
clearanceDocsUploadedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =
round === 'final'
? 'final invoice'
: round === 'second'
? 'additional duty & tax'
: 'duty & tax';
this.inAppStaff(
b,
'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
}

View File

@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
}

View File

@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository };
}
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository };
}
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, filesService };
}

View File

@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
* domestic bookings wait for their booking-day window cycle (no immediate
* batch enqueue at accept time).
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
pickExportSchedule: jest.fn(),
acceptExportBooking: jest.fn(),
};
const invoiceService = {
ensureInvoiceForBooking: jest
.fn()
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
const service = new BookingTransitionService(
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, bookingBatchService };
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// Import/domestic train bookings are batched by the window cycle later —
// never enqueued directly at accept time.
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
});
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,7 @@ import {
Inject,
Injectable,
Logger,
Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
@@ -53,7 +56,8 @@ export class BookingTransitionService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -124,6 +128,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -204,6 +211,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -233,7 +243,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.changesRequested(fresh, note);
return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
@@ -284,7 +296,9 @@ export class BookingTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.accepted(fresh);
return fresh;
}
async staffReject(
@@ -305,7 +319,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async approveStep(
@@ -394,7 +410,9 @@ export class BookingTransitionService {
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
}
return this.bookingsService.findById(bookingId);
@@ -435,7 +453,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async customerSign(bookingId: string): Promise<Booking> {
@@ -446,7 +466,9 @@ export class BookingTransitionService {
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.customerSignedToStaff(fresh);
return fresh;
}
async startTransit(bookingId: string): Promise<Booking> {
@@ -456,7 +478,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.inTransit(fresh);
return fresh;
}
async complete(bookingId: string): Promise<Booking> {
@@ -467,7 +491,28 @@ export class BookingTransitionService {
status: "COMPLETED",
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
// (storage billing does not apply to every shipment). All doc-trigger /
// best-effort — a booking without milestone rows is untouched.
if (this.milestoneService) {
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch {
/* tracking must never block completion */
}
}
try {
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
} catch {
/* no such milestone row (export / non-customs) — fine */
}
}
return fresh;
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
@@ -491,7 +536,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.cancelled(fresh, reason);
return fresh;
}
/**
@@ -723,7 +770,9 @@ export class BookingTransitionService {
} as never);
}
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
}
/**
@@ -824,6 +873,9 @@ export class BookingTransitionService {
}
const updated = await this.bookingsService.findById(bookingId);
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
@@ -912,7 +964,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
}
/**
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
}
/**
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationChangesRequested(fresh, options.note);
return fresh;
}
// ACCEPT — enter the batch holding pool.
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
return this.bookingsService.findById(booking.id);
const roadFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
await this.bookingsRepository.update(booking.id, {
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
return this.bookingsService.findById(booking.id);
const trainFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
async enrichBookingResponse(booking: Booking): Promise<

View File

@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingLifecycleNotifierService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
CustomerTruckService,
ContainerReceiptService,
],

View File

@@ -1388,6 +1388,17 @@ export class BookingsService {
);
}
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.
if (booking.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
}
return booking;
}

View File

@@ -85,6 +85,13 @@ function makeService(overrides?: {
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
{
dutyAdvised: jest.fn(),
clearanceReady: jest.fn(),
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
} as never, // notifier
);
return {
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService();
await service.adviseDuty('b-general', {
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
});
await service.adviseDuty(
'b-general',
{
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
},
undefined,
// The duty notice attachment is now mandatory when duty applies.
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
);
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
'b-general',

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
@@ -100,6 +101,7 @@ export class BookingClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -414,6 +416,7 @@ export class BookingClearanceService {
},
userId,
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
return this.bookingsService.findById(bookingId);
@@ -441,6 +444,7 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}

View File

@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
import { ContractNotifierService } from './contract-notifier.service';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
@@ -26,6 +27,7 @@ export class BookingRequestService {
private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
@@ -107,7 +109,7 @@ export class BookingRequestService {
};
const reference = await this.generateReference();
return this.repo.create({
const request = await this.repo.create({
reference,
contractId,
requestedByUserId: userId ?? null,
@@ -117,6 +119,8 @@ export class BookingRequestService {
requestedLines,
notes: dto.notes ?? null,
} as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
return request;
}
listForContract(contractId: string): Promise<BookingRequest[]> {

View File

@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
contractsRepository as never,
milestoneService as never,
bookingsRepository as never,
{ clearanceReady: jest.fn() } as never, // notifier
);
return { service, milestoneService, contractsRepository, bookingsRepository };
}

View File

@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: BookingLifecycleNotifierService,
) {}
boundaryMilestone(tradeDirection: string): string {
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
// Tell the customer clearance is done and operation can be requested. Load
// failure only skips the notice — the status change above already committed.
try {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (booking) this.notifier.clearanceReady(booking);
} catch {
/* notification is best-effort */
}
}
resolvePhase(

View File

@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
@@ -118,6 +119,7 @@ export class ContractClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -543,7 +545,9 @@ export class ContractClearanceService {
await this.workflowService.onDocumentReviewReopened(contractId);
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.clearanceDocsUploadedToStaff(updated);
return updated;
}
private async assertRequiredInputsPresent(
@@ -674,6 +678,7 @@ export class ContractClearanceService {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
},
userId,
);
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
}
return this.contractsService.findById(contractId);
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
});
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.dutySlipUploadedToStaff(updated);
return updated;
}
async uploadTransitPermit(
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
await this.workflowService.markReadyForBooking(contractId);
}
this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId);
}

View File

@@ -0,0 +1,245 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the contract lifecycle. Every customer
* event fans out over three channels: SMS + email (direct, via
* {@link NotificationsService}) and a persisted in-app notification (via
* {@link NotificationInboxService}) that deep-links to the contract detail page.
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a contract transition.
*/
@Injectable()
export class ContractNotifierService {
private readonly logger = new Logger(ContractNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(c: Contract): string {
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the contract's company contact; log-only on failure. */
private async notifyContact(
c: Contract,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the contract's company. */
private inApp(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!c.companyId) return; // government/unlinked contracts have no portal users
void this.inbox.notify({
recipients: { companyId: c.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title,
body,
link: `/contracts/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/contract-requests/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → contract is under approval. */
accepted(c: Contract): void {
const msg =
`Your contract ${c.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(c, msg, 'ACCEPTED');
this.inApp(c, 'Contract accepted', msg);
}
/** All approval steps complete → contract approved. */
approved(c: Contract): void {
const msg =
`Your contract ${c.reference} has been approved. ` +
`The final document will be prepared for signing.`;
void this.notifyContact(c, msg, 'APPROVED');
this.inApp(c, 'Contract approved', msg);
}
/** Fully executed (all parties signed) → contract active, customer can book. */
signedActive(c: Contract): void {
const msg =
`Your contract ${c.reference} has been signed and is now active. ` +
`You can start booking shipments from the portal.`;
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
this.inApp(c, 'Contract active', msg);
}
/** Staff rejected the contract. */
rejected(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(c, msg, 'REJECTED');
this.inApp(c, 'Contract rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =
`Changes were requested on your contract ${c.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
this.inApp(c, 'Contract changes requested', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
dutyAdvised(c: Contract, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(c, msg, 'DUTY ADVISED');
this.inApp(c, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
link: `/contracts/${c.id}/clearance`,
});
}
/** A clearance document was queried — customer must re-upload it. */
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
const msg =
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
this.inApp(c, 'Clearance document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
link: `/contracts/${c.id}/clearance`,
});
}
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
preClearanceFinalized(c: Contract): void {
const msg =
`Pre-clearance for contract ${c.reference} is complete. ` +
`Your shipment is proceeding to document collection in Djibouti.`;
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
this.inApp(c, 'Pre-clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
link: `/contracts/${c.id}/clearance`,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a contract for review. */
submittedToStaff(c: Contract): void {
this.inAppStaff(
c,
'New contract submitted',
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the contract — staff counter-sign is next. */
customerSignedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Customer signed contract',
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
{ link: `/dashboard/contract-requests/${c.id}/view` },
);
}
/** Customer uploaded clearance documents — GL review is next. */
clearanceDocsUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer filed a shipment request under a GENERAL customs contract. */
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
this.inAppStaff(
c,
'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
{
link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef },
},
);
}
}

View File

@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
@@ -66,6 +67,7 @@ export class ContractTransitionService {
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -79,7 +81,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
}
/** Confirm a price change before submit (mirrors booking confirm-submit). */
@@ -93,7 +97,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
}
/**
@@ -130,7 +136,9 @@ export class ContractTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
/**
@@ -218,7 +226,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.changesRequested(updated, note);
return updated;
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
@@ -235,7 +245,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.rejected(updated, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
@@ -297,7 +309,11 @@ export class ContractTransitionService {
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
}
return updated;
}
/**
@@ -535,7 +551,9 @@ export class ContractTransitionService {
customerSignedAt: new Date(),
} as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.customerSignedToStaff(updated);
return updated;
}
return this.counterSign(contractId, dto, options);
@@ -605,7 +623,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.signedActive(updated);
return updated;
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */

View File

@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
MinioModule,
SignaturesModule,
OtpModule,
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
ContractNotifierService,
ContractTransitionService,
ContractClearanceService,
ClearanceWorkflowService,

View File

@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import {
@@ -53,6 +54,7 @@ export class GlOperationsService {
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private get bookings() {
@@ -64,7 +66,11 @@ export class GlOperationsService {
}
private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } });
// company is loaded so customer notifications have a phone/email to target.
const booking = await this.bookings.findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
@@ -447,6 +453,7 @@ export class GlOperationsService {
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice could not be created.');
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
return summary;
}
@@ -455,7 +462,7 @@ export class GlOperationsService {
bookingId: string,
file: Express.Multer.File,
): Promise<{ uploaded: boolean }> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
const invoice = await this.billingService.findInvoice(
@@ -482,6 +489,7 @@ export class GlOperationsService {
code: 'final_invoice_slip',
file,
});
this.notifier.dutySlipUploadedToStaff(booking, 'final');
return { uploaded: true };
}
@@ -490,7 +498,7 @@ export class GlOperationsService {
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
@@ -507,6 +515,7 @@ export class GlOperationsService {
);
}
await this.billingService.markInvoiceAsPaid(invoice.id);
this.notifier.finalInvoicePaid(booking);
}
void userId;
@@ -575,6 +584,7 @@ export class GlOperationsService {
},
userId,
);
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
return { advised: true, skipped: false };
}
@@ -605,6 +615,7 @@ export class GlOperationsService {
booking.tradeDirection ?? 'IMPORT',
);
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
this.notifier.dutySlipUploadedToStaff(booking, 'second');
return { milestoneCompleted: true };
}

View File

@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
}
}
if (recipients.allBackoffice) {
try {
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
);
}
}
return [...ids];
}
}

View File

@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
{
rescheduled: jest.fn(),
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
);
});

View File

@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
displacedBookingIds: dto.displacedBookingIds,
});
// Notify affected customers (SMS + email). Best-effort — a notification
// failure must never fail the reschedule, so each send is fire-and-forget
// inside the notifier. Government pre-empt already notifies via the batch
// displaced() path, so skip removed-from-train notices for that trigger.
// Use the new departure date when the reschedule moved it (the in-memory
// `schedule` still holds the pre-update date).
const effectiveDeparture = dto.newDepartureDate
? new Date(dto.newDepartureDate)
: schedule.scheduledDepartureDate;
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
return { plan, schedule: assignResult };
}
/**
* Fan out reschedule notifications: bookings that stayed on the train hear the
* new departure date; bookings dropped off the train (staff reschedule, not a
* government pre-empt) hear they were removed. Loads each booking with its
* company so the notifier has a phone/email to reach.
*/
private async notifyRescheduleOutcome(
dto: ExecuteRescheduleDto,
newDeparture: Date | null,
): Promise<void> {
const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
if (newDeparture) {
for (const bookingId of dto.finalBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
if (isMaintenance) {
this.notifier.maintenanceMoved(booking, newDeparture);
} else {
this.notifier.rescheduled(booking, newDeparture);
}
}
}
// Government pre-empt displacements are already announced by the batch
// displaced() notice — don't double-notify. Staff reschedules are not.
if (!isGovPreempt) {
for (const bookingId of dto.displacedBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
this.notifier.removedFromTrain(booking);
}
}
}
private async loadBookingForNotify(bookingId: string): Promise<Booking | null> {
try {
return await this.bookingsRepository.findByIdWithFiles(bookingId);
} catch {
return null;
}
}
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,

View File

@@ -1517,6 +1517,12 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
]);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -1548,6 +1554,15 @@ export class BookingBatchService implements OnModuleInit {
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.
if (reason === 'paid') {
void this.completeTrackingMilestones(booking.id, [
'WAGON_REQUESTED',
'FREIGHT_PAYMENT_PENDING',
'FREIGHT_PAYMENT_SETTLED',
]);
}
}
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
@@ -1559,6 +1574,27 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Complete customer-tracking milestones on lifecycle events via the
* doc-trigger path — a silent no-op for bookings without milestone rows
* (non-customs bookings). Never blocks the batch action.
*/
private async completeTrackingMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService) return;
for (const code of codes) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch (err) {
this.logger.warn(
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
);
}
}
}
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train

View File

@@ -1,13 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {}
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
@@ -41,11 +51,34 @@ export class BookingNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
this.inApp(b, 'Payment window open', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/**
@@ -66,6 +99,9 @@ export class BookingNotifierService {
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
@@ -73,11 +109,13 @@ export class BookingNotifierService {
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
}
expired(b: Booking): void {
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED');
this.inApp(b, 'Payment window expired', msg);
}
scheduleFull(b: Booking): void {
@@ -100,5 +138,42 @@ export class BookingNotifierService {
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
this.inApp(b, 'Booking displaced', msg);
}
/**
* Staff rescheduled the train carrying this booking to a new departure date.
* The booking stays on the train — only the date moved.
*/
rescheduled(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
void this.notifyContact(b, msg, 'RESCHEDULED');
this.inApp(b, 'Booking rescheduled', msg);
}
/**
* Booking was removed from its train during a staff reschedule (not a government
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
*/
removedFromTrain(b: Booking): void {
const msg =
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
`Please rebook or select a new schedule from the portal.`;
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
this.inApp(b, 'Removed from train', msg);
}
/**
* The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved.
*/
maintenanceMoved(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg =
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
`New departure date: ${when}.`;
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
this.inApp(b, 'Train maintenance reschedule', msg);
}
}

View File

@@ -0,0 +1,109 @@
import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { io, type Socket } from 'socket.io-client';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingWindowGateway } from './booking-window.gateway';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* End-to-end proof the booking-window socket works: boots a real Nest app with
* the gateway, connects a real socket.io client to the namespace, emits a phase
* change, and asserts the client receives the exact payload. If this passes,
* any "no live update" report is environmental (stale server process, wrong
* checkout running, client not connecting) — not the gateway.
*/
describe('BookingWindowGateway (e2e)', () => {
let app: INestApplication;
let gateway: BookingWindowGateway;
let client: Socket;
let baseUrl: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
// Accept any token — auth plumbing is covered by the real WsAuthService.
{ provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
],
}).compile();
app = moduleRef.createNestApplication();
await app.listen(0);
const address = app.getHttpServer().address() as { port: number };
baseUrl = `http://127.0.0.1:${address.port}`;
gateway = app.get(BookingWindowGateway);
});
afterAll(async () => {
client?.disconnect();
await app?.close();
});
it('authenticated client receives the phase event with the schedule state', async () => {
client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'any' },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => resolve());
client.on('connect_error', (err) => reject(err));
});
const received = new Promise<Record<string, unknown>>((resolve) => {
client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
});
gateway.emitPhase({
id: 'sched-1',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
direction: 'IMPORT',
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: new Date('2026-07-06T16:15:00Z'),
windowClosesAt: new Date('2026-07-06T16:18:00Z'),
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
} as unknown as TrainSchedule);
const payload = await received;
expect(payload).toMatchObject({
scheduleId: 'sched-1',
phase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: '2026-07-06T16:15:00.000Z',
});
});
it('rejects a client whose token does not resolve to a user', async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
{ provide: WsAuthService, useValue: { resolveUserId: async () => null } },
],
}).compile();
const rejectingApp = moduleRef.createNestApplication();
await rejectingApp.listen(0);
const addr = rejectingApp.getHttpServer().address() as { port: number };
const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'bad' },
transports: ['websocket'],
reconnection: false,
});
const outcome = await new Promise<string>((resolve) => {
rejected.on('disconnect', () => resolve('disconnected'));
rejected.on('connect_error', () => resolve('rejected'));
// The server accepts the transport then drops it in handleConnection.
setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
});
rejected.disconnect();
await rejectingApp.close();
expect(outcome).not.toBe('still-connected');
});
});

View File

@@ -41,6 +41,9 @@ export class BookingWindowGateway implements OnGatewayConnection {
return;
}
socket.data.userId = userId;
// Log at info so "is anyone actually connected?" is answerable from the
// API log when diagnosing missing live updates.
this.logger.log(`Booking-window client connected (user ${userId})`);
}
/** Push a schedule's current window state to every connected client. */

View File

@@ -2,12 +2,17 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import {
NotificationAudience,
NotificationType,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
@@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly gateway: BookingWindowGateway,
) {}
@@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try {
const rows: Array<{ phone: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
const rows: Array<{
company_id: string;
phone: string | null;
email: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const closes = schedule.windowClosesAt
@@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
const seenPhone = new Set<string>();
const seenEmail = new Set<string>();
const seenCompany = new Set<string>();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
@@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
// In-app inbox item for every portal user of each eligible company,
// deep-linking to the new-booking page.
if (r.company_id && !seenCompany.has(r.company_id)) {
seenCompany.add(r.company_id);
void this.inbox.notify({
recipients: { companyId: r.company_id },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Booking window open',
body: msg,
link: '/bookings/new',
data: { trainScheduleId: schedule.id },
});
}
}
this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
`Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(

View File

@@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { ContractsModule } from '../contracts/contracts.module';
@Module({
@@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module';
forwardRef(() => BookingsModule),
BillingModule,
NotificationsModule,
NotificationInboxModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
@@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module';
BookingSplitService,
IntercityService,
],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
exports: [
TrainSchedulingService,
BookingBatchService,
BookingWindowService,
BookingNotifierService,
],
})
export class TrainSchedulingModule {}

View File

@@ -12,6 +12,7 @@ import {
Injectable,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -274,9 +276,45 @@ export class TrainSchedulingService {
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
) {}
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
* bookings without milestone rows (non-customs bookings), so this is safe to
* call for every direction and flow. Never blocks the operational action.
*/
private async completeMilestonesForScheduleBookings(
scheduleId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService || codes.length === 0) return;
try {
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
`SELECT tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL`,
[scheduleId],
);
for (const { booking_id } of rows) {
for (const code of codes) {
await this.milestoneService.completeByDocTrigger(
{ bookingId: booking_id },
code,
);
}
}
} catch (err) {
this.logger.warn(
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* Push a schedule's current booking-window state over the socket so the
* portal home card and backoffice GL/batch views update in real time —
@@ -1121,26 +1159,32 @@ export class TrainSchedulingService {
{ country: schedule.destinationCountry },
);
if (direction === 'IMPORT') {
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the destination yard.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
result,
};
}
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the Djibouti port.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
result,
};
}
@@ -1443,6 +1487,20 @@ export class TrainSchedulingService {
// Dispatch closed the window — drop it from portal/GL cards right away.
void this.emitWindowState(scheduleId);
// Customer tracking: cargo is on the departing train — loading milestones
// plus the direction's "departed" handoff milestone.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
// doc-trigger path no-ops it for import bookings.
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
schedule.direction === 'IMPORT'
? 'DEPARTED_FROM_DJIBOUTI'
: 'DEPARTED_TO_DJIBOUTI',
]);
}
return this.getTrainScheduleById(scheduleId);
}
@@ -1599,6 +1657,13 @@ export class TrainSchedulingService {
LoadingStatus.Loaded,
);
}
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
// is the export-side "cargo reached origin yard" step that precedes it).
void this.completeMilestonesForScheduleBookings(scheduleId, [
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
]);
return this.getTrainScheduleById(scheduleId);
}
@@ -2417,6 +2482,13 @@ export class TrainSchedulingService {
}
});
// Customer tracking: the train reached the corridor's far end.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
]);
}
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });

View File

@@ -35,6 +35,18 @@ export function useBookingWindowSocket(enabled: boolean = true) {
withCredentials: true,
});
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
socket.on("disconnect", (reason) =>
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {

View File

@@ -1,6 +1,6 @@
import { NotificationType } from "@edr/types";
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
const ICON_SIZE = 17;
@@ -19,6 +19,8 @@ export function resolveNotificationVisual(
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
case NotificationType.CLEARANCE_REVIEW:
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
case NotificationType.CONTRACT_STATUS:
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
default:
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
}
@@ -39,14 +41,32 @@ export function resolveNotificationHref(
if (item.link) return item.link;
const data = item.data ?? {};
switch (item.type) {
case NotificationType.REQUEST_SUBMITTED:
case NotificationType.REQUEST_SUBMITTED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contract-requests/${contractId}`;
return "/dashboard/booking-requests";
}
case NotificationType.PAYMENT_RECEIVED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const id = asId(data.customerId);
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
}
case NotificationType.CLEARANCE_REVIEW:
case NotificationType.CLEARANCE_REVIEW: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
return "/dashboard/arrival-queue";
}
case NotificationType.CONTRACT_STATUS: {
const contractId = asId(data.contractId);
return contractId
? `/dashboard/contract-requests/${contractId}`
: "/dashboard/contract-requests";
}
default:
return null;
}

View File

@@ -40,6 +40,18 @@ export function useBookingWindowSocket(enabled: boolean) {
withCredentials: true,
});
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
socket.on("disconnect", (reason) =>
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {

View File

@@ -3,6 +3,8 @@ import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import {
BadgeCheck,
Bell,
CalendarClock,
FileSignature,
FileWarning,
Package,
Receipt,
@@ -25,6 +27,10 @@ export function resolveNotificationVisual(
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
case NotificationType.BOOKING_STATUS:
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
case NotificationType.CONTRACT_STATUS:
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
case NotificationType.SCHEDULE_UPDATE:
return { icon: <CalendarClock size={ICON_SIZE} />, color: "cyan" };
case NotificationType.INVOICE_ISSUED:
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
default:
@@ -55,8 +61,18 @@ export function resolveNotificationHref(
const id = asId(data.bookingId);
return id ? `/bookings/${id}` : null;
}
case NotificationType.CONTRACT_STATUS: {
const id = asId(data.contractId);
return id ? `/contracts/${id}` : "/contracts";
}
case NotificationType.SCHEDULE_UPDATE: {
const id = asId(data.bookingId);
return id ? `/bookings/${id}` : "/bookings/new";
}
case NotificationType.CLEARANCE_DECISION:
case NotificationType.DOCUMENT_ACTION: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/bookings/${bookingId}`;
const id = asId(data.contractId);
return id ? `/contracts/${id}` : "/contracts";
}

View File

@@ -3,7 +3,7 @@ import { Check, MoveRight } from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
@@ -65,7 +65,18 @@ export function StatusHero({
children?: React.ReactNode;
}) {
const status = booking.status;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const stage = resolveStage(booking);
// The Arrival stage has no booking status of its own — it lights up from the
// train's ARRIVED state, so the headline is overridden here.
const cfg =
stage === ARRIVAL_STAGE
? {
title: "Train arrived at destination",
description:
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
stage,
}
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
const negative = isNegative(status);
const draft = isDraftLike(status);
@@ -107,7 +118,7 @@ export function StatusHero({
{children ?? (
<ProgressTracker
current={cfg.stage}
current={stage}
tone={draft ? "ink" : "green"}
negative={negative}
/>
@@ -139,7 +150,7 @@ function ProgressTracker({
} as React.CSSProperties
}
>
<div className="flex items-start" style={{ minWidth: 440 }}>
<div className="flex items-start" style={{ minWidth: 520 }}>
{PROGRESS_STAGES.map((stage, idx) => {
const state =
idx < current ? "done" : idx === current ? "active" : "idle";

View File

@@ -1,6 +1,7 @@
import {
ClipboardCheck,
FileText,
MapPin,
PackageCheck,
ShieldCheck,
Ship,
@@ -52,6 +53,14 @@ export const PROGRESS_STAGES = [
icon: Train,
statuses: ["EXPIRED", "IN_TRANSIT"],
},
{
// No booking status maps here: the booking stays IN_TRANSIT until
// delivery, so this stage lights up from the assigned train's own status
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
label: "Arrival",
icon: MapPin,
statuses: [],
},
{
label: "Complete",
icon: PackageCheck,
@@ -59,6 +68,30 @@ export const PROGRESS_STAGES = [
},
];
/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */
export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
(s) => s.label === "Arrival",
);
/**
* Stage for a booking, factoring in the assigned train's operational status:
* a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its
* train has ARRIVED the tracker advances to the Arrival stage.
*/
export function resolveStage(booking: {
status: string;
trainScheduleStatus?: string | null;
}): number {
const base = STATUS_MAP[booking.status]?.stage ?? 0;
if (
booking.status === "IN_TRANSIT" &&
booking.trainScheduleStatus === "ARRIVED"
) {
return ARRIVAL_STAGE;
}
return base;
}
export const STATUS_MAP: Record<
string,
{ title: string; description: string; stage: number }
@@ -209,7 +242,7 @@ export const STATUS_MAP: Record<
title: "Contract closed",
description:
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
stage: 7,
stage: 8,
},
PRICE_CHANGED_PENDING_CONFIRM: {
title: "Price changed — confirm to proceed",
@@ -235,12 +268,12 @@ export const STATUS_MAP: Record<
COMPLETED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 7,
stage: 8,
},
DELIVERED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 7,
stage: 8,
},
REJECTED: {
title: "Booking rejected",

View File

@@ -438,6 +438,13 @@ export interface IBooking extends BaseEntity {
contractReference?: string | null;
trainId?: string | null;
status: BookingStatus;
/**
* Operational status of the assigned train (DRAFT/SCHEDULED/DISPATCHED/
* ARRIVED), joined on booking detail. The booking status stays IN_TRANSIT
* from dispatch until delivery, so the portal stepper reads arrival from
* here.
*/
trainScheduleStatus?: TrainScheduleStatus | string | null;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
bookingType?: BookingType;
/** General contracts only: when ordering closes (null until active / for one-time). */

View File

@@ -33,6 +33,8 @@ export enum NotificationType {
CLEARANCE_DECISION = "CLEARANCE_DECISION",
DOCUMENT_ACTION = "DOCUMENT_ACTION",
BOOKING_STATUS = "BOOKING_STATUS",
CONTRACT_STATUS = "CONTRACT_STATUS",
SCHEDULE_UPDATE = "SCHEDULE_UPDATE",
INVOICE_ISSUED = "INVOICE_ISSUED",
// Backoffice-facing (staff)
REQUEST_SUBMITTED = "REQUEST_SUBMITTED",
@@ -87,6 +89,8 @@ export interface NotificationRecipients {
companyProfileId?: string;
/** Backoffice: all current employees of this organization. */
organizationId?: string;
/** Backoffice: every current employee across all organizations. */
allBackoffice?: boolean;
}
/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */

3
pnpm-lock.yaml generated
View File

@@ -193,6 +193,9 @@ importers:
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
supertest:
specifier: ^7.0.0
version: 7.2.2