import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { NotificationsService } from '../notifications/notifications.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileRepository } from './last-mile.repository'; type LastMileListFilter = { status?: LastMileStatus; bookingId?: string; vehicleId?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string; }; const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'status', 'advancedPayment', 'remainingPayment', 'createdAt', ]; @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, ) {} async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { return null; } if (booking.paymentStatus !== 'PAID') { return null; } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async acceptBookingByReference(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { return null; } if (booking.paymentStatus !== 'PAID') { return null; } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async findAll(filter: LastMileListFilter = {}): Promise<{ data: LastMile[]; meta: { total: number; page: number; pageSize: number; totalPages: number }; }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof LastMile) ? (filter.sortBy as keyof LastMile) : 'createdAt'; const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; if (filter.bookingId) where.bookingId = filter.bookingId; if (filter.vehicleId) where.vehicleId = filter.vehicleId; const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, vehicle: true, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, }; } async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, vehicle: true, }, }); if (!record) { throw new NotFoundException(`Last-mile record ${id} not found`); } return record; } async create(dto: CreateLastMileDto): Promise { return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, }); } async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), }); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); } // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } return updated; } private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); return; } const driver = await this.driversService.findById(vehicle.assignedDriverId); if (!driver.phoneNumber) { this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); return; } type BookingWithYards = { reference?: string; lastMileDeliveryAddress?: string | null; destinationYard?: { label?: string } | null; }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; await this.notificationsService.notifyDriverVehicleAssignment({ driverPhone: driver.phoneNumber, driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, bookingReference: booking?.reference ?? record.bookingId, pickupAddress: booking?.destinationYard?.label, destinationYard: booking?.lastMileDeliveryAddress, }); this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } } async remove(id: string): Promise { await this.findById(id); await this.lastMileRepository.softDelete(id); } }