import { BadRequestException, ConflictException, 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 { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileRepository } from './first-mile.repository'; type FirstMileListFilter = { status?: FirstMileStatus; bookingId?: string; vehicleId?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string; }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ 'status', 'advancedPayment', 'remainingPayment', 'createdAt', ]; @Injectable() export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, ) {} /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`); } return this.acceptEligibleBooking(booking); } async acceptBookingByReference(bookingReference: string): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, take: 1, }); if (!booking) { throw new NotFoundException(`Booking ${bookingReference} not found`); } return this.acceptEligibleBooking(booking); } /** * Shared accept path: validates payment + first-mile eligibility, rejects an * already-assigned booking, then creates the first-mile record. Throws a * meaningful HTTP error instead of returning null so the client can surface * why an accept was refused. */ private async acceptEligibleBooking(booking: { id: string; reference?: string; paymentStatus?: string | null; tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): Promise { const label = booking.reference ?? booking.id; if (booking.paymentStatus !== 'PAID') { throw new BadRequestException(`Booking ${label} is not paid`); } if (!this.bookingRequestsFirstMile(booking)) { throw new BadRequestException(`Booking ${label} does not require a first mile`); } const existing = await this.findByBookingId(booking.id); if (existing) { throw new ConflictException(`Booking ${label} already has a first-mile assignment`); } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; 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 FirstMile) ? (filter.sortBy as keyof FirstMile) : '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.firstMileRepository.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.firstMileRepository.findById(id, { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, vehicle: true, }, }); if (!record) { throw new NotFoundException(`First-mile record ${id} not found`); } return record; } async create(dto: CreateFirstMileDto): Promise { const existing = await this.findByBookingId(dto.bookingId); if (existing) { return existing; } return this.firstMileRepository.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, }); } private async findByBookingId(bookingId: string): Promise { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, vehicle: true, }, take: 1, }); return records[0] ?? null; } private bookingRequestsFirstMile(booking: { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. return Boolean( booking.tradeDirection === 'EXPORT' || booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile, ); } async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); const updated = await this.firstMileRepository.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(`First-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: FirstMile): 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; } const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).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?.firstMilePickupAddress, destinationYard: booking?.originYard?.label, }); 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.firstMileRepository.softDelete(id); } }