import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; /** * Import handover records. A booking has one handover per truck (single truck ⇒ * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. * - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its * exit paper), signed by the customer in the portal per truck; once every * handover is signed the delivery auto-completes (inventory / cargo / * booking → delivered). */ @Injectable() export class HandoverService { private readonly logger = new Logger(HandoverService.name); constructor( private readonly dataSource: DataSource, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, ) {} /** Tell the customer a handover is ready and needs their signature. */ private async notifySignNeeded( bookingId: string, reference: string, opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {}, ): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''; const body = opts.mileType === 'EDR_LAST_MILE' ? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.` : `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; await this.inbox.notify({ recipients: { companyId: b.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.DOCUMENT_ACTION, title: 'Handover — signature needed', body, link: `/bookings/${bookingId}`, data: { bookingId, reference }, }); await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); } catch (err) { this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); } } list(bookingId: string): Promise { return this.dataSource.getRepository(BookingHandover).find({ where: { bookingId }, order: { generatedAt: 'ASC' }, }); } /** * Ask the customer to sign the booking's handover. Ensures a handover exists * (creates a booking-level self-haul one if none yet), then fires the * sign-needed notification (in-app + SMS + email). Idempotent to re-send. */ async requestSignature( bookingId: string, ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> { const repo = this.dataSource.getRepository(BookingHandover); const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); if (existing.length === 0) { // No handover yet (truck not arrived): create a booking-level one so the // customer has something to sign. ensureForArrivedTruck notifies on create. const created = await this.ensureForArrivedTruck(bookingId, {}); return { notified: true, reference: created.reference, alreadySigned: false }; } const unsigned = existing.find((h) => !h.signedAt); if (!unsigned) { return { notified: false, reference: existing[0].reference, alreadySigned: true }; } await this.notifySignNeeded(bookingId, unsigned.reference); return { notified: true, reference: unsigned.reference, alreadySigned: false }; } /** * Self-haul: ensure a handover exists for a customer truck that just arrived. * Idempotent — one per (booking, truck). Runs inside the caller's transaction * when a manager is supplied. */ async ensureForArrivedTruck( bookingId: string, opts: { truckAssignmentId?: string | null; truckPlate?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); const existing = await repo.findOne({ where: { bookingId, truckAssignmentId: opts.truckAssignmentId ?? IsNull(), }, }); if (existing) return existing; const reference = await this.generateReference(bookingId, m); const saved = await repo.save( repo.create({ bookingId, truckAssignmentId: opts.truckAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'SELF_HAUL', reference, generatedAt: new Date(), }), ); this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); void this.notifySignNeeded(bookingId, reference); return saved; } /** Find an existing EDR handover by assignment, else by plate, else booking-level. */ private async findEdrHandover( repo: Repository, bookingId: string, opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, ): Promise { if (opts.edrAssignmentId) { const byAssignment = await repo.findOne({ where: { bookingId, edrAssignmentId: opts.edrAssignmentId }, }); if (byAssignment) return byAssignment; } if (opts.truckPlate) { return repo.findOne({ where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate }, }); } return repo.findOne({ where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: IsNull(), edrAssignmentId: IsNull(), }, }); } /** * EDR last-mile: generate the handover when the EDR truck exits the warehouse * (alongside its exit paper) and ask the customer to sign it from the portal. * One per truck (multiple trucks ⇒ one each) or booking-level when the truck * cannot be resolved. Idempotent by (booking, assignment) / (booking, plate). */ async ensureForDepartedEdrTruck( bookingId: string, opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) return existing; const reference = await this.generateReference(bookingId, m); const saved = await repo.save( repo.create({ bookingId, edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, generatedAt: new Date(), }), ); this.logger.log( `EDR handover ${reference} generated on truck exit for booking ${bookingId}` + (opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''), ); void this.notifySignNeeded(bookingId, reference, { mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate, }); return saved; } /** * EDR last-mile: ensure a handover exists at delivery and stamp delivered_at. * Normally the handover was already generated on truck exit — this only fills * the delivery timestamp; a handover is created here only for legacy flows * where the exit was recorded before this feature existed. */ async ensureAtDelivery( bookingId: string, opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) { if (!existing.deliveredAt) { existing.deliveredAt = new Date(); await repo.save(existing); } return existing; } const reference = await this.generateReference(bookingId, m); const saved = await repo.save( repo.create({ bookingId, edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, generatedAt: new Date(), deliveredAt: new Date(), }), ); void this.notifySignNeeded(bookingId, reference, { mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate, }); return saved; } /** Re-send the sign notification for every unsigned handover on the booking. */ async notifyUnsignedForBooking(bookingId: string): Promise { const unsigned = await this.dataSource.getRepository(BookingHandover).find({ where: { bookingId, signedAt: IsNull() }, order: { generatedAt: 'ASC' }, }); for (const h of unsigned) { await this.notifySignNeeded(bookingId, h.reference, { mileType: h.mileType, truckPlate: h.truckPlate, }); } } /** * Reminder loop: until a self-haul handover is signed, re-send the sign * notification (in-app + SMS + email) every 5 minutes. One reminder per * booking per tick, newest unsigned handover's reference. Stops the moment * signForBooking() stamps signed_at. * * NB: runs in every API instance — keep a single instance in dev or the * customer is reminded once per instance per tick. */ @Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' }) async remindUnsignedHandovers(): Promise { try { const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query( `SELECT DISTINCT ON (booking_id) booking_id AS "bookingId", reference FROM freight.booking_handovers WHERE signed_at IS NULL AND deleted_at IS NULL AND mile_type = 'SELF_HAUL' ORDER BY booking_id, generated_at DESC`, ); if (!rows.length) return; this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`); for (const row of rows) { await this.notifySignNeeded(row.bookingId, row.reference); } } catch (err) { this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`); } } /** * Sign one handover (EDR last-mile: the customer signs per truck). Returns the * fresh handover; idempotent — an already-signed handover is returned as-is. */ async sign( handoverId: string, userId?: string | null, signerName?: string | null, signatureImageUrl?: string | null, ): Promise { const repo = this.dataSource.getRepository(BookingHandover); const handover = await repo.findOne({ where: { id: handoverId } }); if (!handover) { throw new NotFoundException(`Handover ${handoverId} not found`); } if (handover.signedAt) return handover; handover.signedAt = new Date(); handover.signedByUserId = userId ?? null; handover.signerName = signerName?.trim() || null; handover.signatureImageUrl = signatureImageUrl ?? null; return repo.save(handover); } /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, userId?: string | null, signerName?: string | null, signatureImageUrl?: string | null, ): Promise { await this.dataSource .getRepository(BookingHandover) .update( { bookingId, signedAt: IsNull() }, { signedAt: new Date(), signedByUserId: userId ?? null, signerName: signerName?.trim() || null, signatureImageUrl: signatureImageUrl ?? null, }, ); } /** True when every handover on the booking is signed (and at least one exists). */ async isFullySigned(bookingId: string): Promise { const repo = this.dataSource.getRepository(BookingHandover); const [total, unsigned] = await Promise.all([ repo.count({ where: { bookingId } }), repo.count({ where: { bookingId, signedAt: IsNull() } }), ]); return total > 0 && unsigned === 0; } private async generateReference(bookingId: string, manager: EntityManager): Promise { const [booking] = await manager.query( `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, ''); const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } }); return `HND-${ref}-${String(count + 1).padStart(2, '0')}`; } }