import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Repository } from 'typeorm'; import { ComplianceRepository } from './compliance.repository'; import { ComplianceRecord, ComplianceStatus, ComplianceType, } from './entities/compliance-record.entity'; import { CreateComplianceRecordDto, UpdateComplianceRecordDto, } from './dto/create-compliance-record.dto'; import { Vehicle } from '../vehicles/entities/vehicle.entity'; import { Driver } from '../drivers/entities/driver.entity'; const DUE_SOON_DAYS = 30; const MS_PER_DAY = 24 * 60 * 60 * 1000; export type AlertSeverity = 'OVERDUE' | 'DUE_SOON'; export interface ComplianceAlert { vehicleId: string; vehiclePlate?: string; kind: string; label: string; expiryDate: string; daysUntil: number; severity: AlertSeverity; } @Injectable() export class ComplianceService { constructor( private readonly complianceRepository: ComplianceRepository, @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, @InjectRepository(Driver) private readonly driverRepo: Repository, ) {} async create(dto: CreateComplianceRecordDto): Promise { return this.complianceRepository.create({ ...dto, status: dto.status ?? this.deriveStatus(dto.expiryDate), }); } async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) { return this.complianceRepository.findWithFilters(filter); } async findById(id: string): Promise { const record = await this.complianceRepository.findById(id); if (!record) { throw new NotFoundException(`Compliance record ${id} not found`); } return record; } async update(id: string, dto: UpdateComplianceRecordDto): Promise { await this.findById(id); const nextExpiry = dto.expiryDate; const updated = await this.complianceRepository.update(id, { ...dto, // Re-derive status when expiry changes and the caller didn't set it explicitly. status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined), }); return updated!; } async remove(id: string): Promise { await this.findById(id); await this.complianceRepository.softDelete(id); } /** * Flat list of compliance items that are overdue or due within 30 days. * Combines the compliance_records table with the vehicle expiry columns * (insurance / registration / next inspection) and assigned-driver license * expiry. `new Date()` is fine here — this is the NestJS API runtime. */ async getAlerts(): Promise { const now = new Date(); const alerts: ComplianceAlert[] = []; const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } }); const vehicleById = new Map(vehicles.map((v) => [v.id, v])); const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined; // 1. Compliance records const records = await this.complianceRepository.findWithFilters(); for (const record of records) { const computed = this.computeSeverity(record.expiryDate, now); if (!computed) continue; const vehicle = vehicleById.get(record.vehicleId); alerts.push({ vehicleId: record.vehicleId, vehiclePlate: plateOf(vehicle), kind: record.type, label: record.documentNumber ? `${record.type} · ${record.documentNumber}` : record.type, expiryDate: record.expiryDate, daysUntil: computed.daysUntil, severity: computed.severity, }); } // 2. Vehicle-level expiry columns const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [ { field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' }, { field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' }, { field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' }, ]; for (const vehicle of vehicles) { for (const { field, kind, label } of vehicleFields) { const value = vehicle[field] as string | undefined; if (!value) continue; const computed = this.computeSeverity(value, now); if (!computed) continue; alerts.push({ vehicleId: vehicle.id, vehiclePlate: plateOf(vehicle), kind, label, expiryDate: value, daysUntil: computed.daysUntil, severity: computed.severity, }); } } // 3. Assigned-driver license expiry const driverIds = [ ...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)), ]; if (driverIds.length > 0) { const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } }); const driverById = new Map(drivers.map((d) => [d.id, d])); for (const vehicle of vehicles) { if (!vehicle.assignedDriverId) continue; const driver = driverById.get(vehicle.assignedDriverId); if (!driver?.licenseExpiryDate) continue; const expiry = driver.licenseExpiryDate instanceof Date ? driver.licenseExpiryDate.toISOString().slice(0, 10) : String(driver.licenseExpiryDate); const computed = this.computeSeverity(expiry, now); if (!computed) continue; alerts.push({ vehicleId: vehicle.id, vehiclePlate: plateOf(vehicle), kind: 'DRIVER_LICENSE', label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), expiryDate: expiry, daysUntil: computed.daysUntil, severity: computed.severity, }); } } return alerts.sort((a, b) => a.daysUntil - b.daysUntil); } private computeSeverity( expiryDate: string, now: Date, ): { daysUntil: number; severity: AlertSeverity } | null { const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY); if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' }; if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' }; return null; } private deriveStatus(expiryDate: string): ComplianceStatus { const daysUntil = Math.ceil( (new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY, ); if (daysUntil < 0) return ComplianceStatus.EXPIRED; if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING; return ComplianceStatus.VALID; } }