mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,9 +386,13 @@ 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(
|
||||
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
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user