import { randomUUID } from 'crypto'; import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types'; import { DocumentsRepository } from '../data/documents.repository'; import { OwnershipCostsService } from '../../ownership-costs/domain/ownership-costs.service'; import type { OwnershipCostType } from '../../ownership-costs/domain/ownership-costs.types'; import pool from '../../../core/config/database'; export class DocumentsService { private readonly repo = new DocumentsRepository(pool); private readonly ownershipCostsService = new OwnershipCostsService(pool); async createDocument(userId: string, body: CreateDocumentBody): Promise { await this.assertVehicleOwnership(userId, body.vehicleId); const id = randomUUID(); const doc = await this.repo.insert({ id, userId, vehicleId: body.vehicleId, documentType: body.documentType as DocumentType, title: body.title, notes: body.notes ?? null, details: body.details ?? null, issuedDate: body.issuedDate ?? null, expirationDate: body.expirationDate ?? null, emailNotifications: body.emailNotifications ?? false, scanForMaintenance: body.scanForMaintenance ?? false, }); // Auto-create ownership_cost when insurance/registration has cost data await this.autoCreateOwnershipCost(userId, doc, body); return doc; } /** * Auto-creates an ownership_cost record when an insurance or registration * document is created with cost data (premium or cost field in details). */ private async autoCreateOwnershipCost( userId: string, doc: DocumentRecord, body: CreateDocumentBody ): Promise { const costType = this.mapDocumentTypeToCostType(body.documentType); if (!costType) return; // Not a cost-linkable document type const costAmount = this.extractCostAmount(body); if (!costAmount || costAmount <= 0) return; // No valid cost data try { await this.ownershipCostsService.createCost(userId, { vehicleId: body.vehicleId, documentId: doc.id, costType, amount: costAmount, description: doc.title, periodStart: body.issuedDate, periodEnd: body.expirationDate, }); } catch (err) { // Log but don't fail document creation if cost creation fails console.error('Failed to auto-create ownership cost for document:', doc.id, err); } } /** * Maps document types to ownership cost types. * Returns null for document types that don't auto-create costs. */ private mapDocumentTypeToCostType(documentType: string): OwnershipCostType | null { const typeMap: Record = { 'insurance': 'insurance', 'registration': 'registration', }; return typeMap[documentType] || null; } /** * Extracts cost amount from document details. * Insurance uses 'premium', registration uses 'cost'. */ private extractCostAmount(body: CreateDocumentBody): number | null { if (!body.details) return null; const premium = body.details.premium; const cost = body.details.cost; if (typeof premium === 'number' && premium > 0) return premium; if (typeof cost === 'number' && cost > 0) return cost; return null; } async getDocument(userId: string, id: string): Promise { return this.repo.findById(id, userId); } async listDocuments(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }) { return this.repo.listByUser(userId, filters); } async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) { const existing = await this.repo.findById(id, userId); if (!existing) return null; if (patch && typeof patch === 'object') { const updated = await this.repo.updateMetadata(id, userId, patch as any); // Sync cost changes to linked ownership_cost if applicable if (updated && patch.details) { await this.syncOwnershipCost(userId, updated, patch); } return updated; } return existing; } /** * Syncs cost data changes to linked ownership_cost record. * If document has linked cost and details.premium/cost changed, update it. */ private async syncOwnershipCost( userId: string, doc: DocumentRecord, patch: UpdateDocumentBody ): Promise { const costType = this.mapDocumentTypeToCostType(doc.documentType); if (!costType) return; const newCostAmount = this.extractCostAmountFromDetails(patch.details); if (newCostAmount === null) return; // No cost in update try { // Find existing linked cost const linkedCosts = await this.ownershipCostsService.getCosts(userId, { documentId: doc.id }); if (linkedCosts.length > 0 && newCostAmount > 0) { // Update existing linked cost await this.ownershipCostsService.updateCost(userId, linkedCosts[0].id, { amount: newCostAmount, periodStart: patch.issuedDate ?? undefined, periodEnd: patch.expirationDate ?? undefined, }); } else if (linkedCosts.length === 0 && newCostAmount > 0) { // Create new cost if none exists await this.ownershipCostsService.createCost(userId, { vehicleId: doc.vehicleId, documentId: doc.id, costType, amount: newCostAmount, description: doc.title, periodStart: patch.issuedDate ?? doc.issuedDate ?? undefined, periodEnd: patch.expirationDate ?? doc.expirationDate ?? undefined, }); } } catch (err) { console.error('Failed to sync ownership cost for document:', doc.id, err); } } /** * Extracts cost amount from details object (for updates). */ private extractCostAmountFromDetails(details?: Record | null): number | null { if (!details) return null; const premium = details.premium; const cost = details.cost; if (typeof premium === 'number') return premium; if (typeof cost === 'number') return cost; return null; } async deleteDocument(userId: string, id: string): Promise { // Note: Linked ownership_cost records are CASCADE deleted via FK await this.repo.softDelete(id, userId); } private async assertVehicleOwnership(userId: string, vehicleId: string) { const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]); if (!res.rows[0]) { const err: any = new Error('Vehicle not found or not owned by user'); err.statusCode = 403; throw err; } } }