import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull } from 'typeorm'; import { BookingHandover } 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 at delivery (after exit). */ @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): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; const body = `Your 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; } /** * EDR last-mile: generate a handover at delivery (after exit). One per EDR * truck (by plate) or per booking. Idempotent by (booking, plate). */ async ensureAtDelivery( bookingId: string, opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); const existing = await repo.findOne({ where: { bookingId, truckPlate: opts.truckPlate ?? IsNull(), truckAssignmentId: opts.truckAssignmentId ?? IsNull(), }, }); if (existing) return existing; const reference = await this.generateReference(bookingId, m); return repo.save( repo.create({ bookingId, truckAssignmentId: opts.truckAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, generatedAt: new Date(), deliveredAt: new Date(), }), ); } /** * 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 all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, userId?: string | null, signerName?: string | null, ): Promise { await this.dataSource .getRepository(BookingHandover) .update( { bookingId, signedAt: IsNull() }, { signedAt: new Date(), signedByUserId: userId ?? null, signerName: signerName?.trim() || 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')}`; } }