import { createHash, randomUUID } from 'node:crypto'; import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { BadRequestException, ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { AuditAction, InspectionActStatus, InspectionCommunicationDirection, InspectionCommunicationType, InspectionEvidenceKind, InspectionEvidencePurpose, InspectionEvidenceSource, InspectionFindingStatus, InspectionVisitStatus, } from '../database/entities'; import type { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto'; import type { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto'; import { inspectInspectionEvidenceFile, type UploadedInspectionEvidenceFile, } from './inspection-evidence-file'; interface FindingContext { id: string; status: InspectionFindingStatus; actId: string; actStatus: InspectionActStatus; visitId: string; visitStatus: InspectionVisitStatus; } interface CommunicationLink { id: string; type: InspectionCommunicationType; direction: InspectionCommunicationDirection; subject: string; } export interface InspectionCommunicationView { id: string; findingId: string; direction: string; channel: string; type: string; occurredAt: Date; subject: string; details: string | null; contactName: string | null; contactEmail: string | null; createdBy: string | null; createdByUsername: string | null; attachmentCount: number; createdAt: Date; } export interface InspectionEvidenceView { id: string; findingId: string; communicationId: string | null; verificationVisitId: string | null; communication: CommunicationLink | null; kind: InspectionEvidenceKind; purpose: InspectionEvidencePurpose; originalName: string; mimeType: string; sizeBytes: number; sha256: string; title: string | null; description: string | null; capturedAt: Date | null; latitude: number | null; longitude: number | null; accuracyM: number | null; deviceLabel: string | null; source: InspectionEvidenceSource; uploadedBy: string | null; uploadedByUsername: string | null; createdAt: Date; } interface StoredInspectionEvidence extends InspectionEvidenceView { storedName: string; } function findingNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_FINDING_NOT_FOUND', message: 'Hallazgo de inspección no encontrado', }); } function evidenceNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_EVIDENCE_NOT_FOUND', message: 'Evidencia de inspección no encontrada', }); } @Injectable() export class InspectionEvidenceService { private readonly storageRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, config: ConfigService, ) { const configured = config.get('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings'; if (!isAbsolute(configured)) { throw new Error('INSPECTION_EVIDENCE_ROOT must be an absolute path'); } this.storageRoot = resolve(configured); if (this.storageRoot === parse(this.storageRoot).root) { throw new Error('INSPECTION_EVIDENCE_ROOT cannot be the filesystem root'); } } async listEvidence(findingId: string): Promise<{ data: InspectionEvidenceView[] }> { await this.requireFinding(this.dataSource.manager, findingId); const rows = await this.dataSource.query( `${this.evidenceSelect()} WHERE evidence.finding_id = $1 ORDER BY evidence.created_at DESC, evidence.id`, [findingId], ) as StoredInspectionEvidence[]; return { data: rows.map(({ storedName: _storedName, ...evidence }) => evidence), }; } async uploadEvidence( findingId: string, dto: CreateInspectionEvidenceDto, file: UploadedInspectionEvidenceFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if ([InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)) { assertMobileInspector(principal); } this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); if (dto.operationId) { const [existing] = await this.dataSource.query( `${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`, [dto.operationId], ) as StoredInspectionEvidence[]; if (existing) { if (existing.findingId !== findingId) { throw new ConflictException({ code: 'INSPECTION_EVIDENCE_OPERATION_REUSED', message: 'La operación offline ya fue utilizada para otra evidencia', }); } const { storedName: _storedName, ...view } = existing; return view; } } const inspected = inspectInspectionEvidenceFile(file, dto.kind); this.validatePurpose(dto, inspected.mimeType); const id = randomUUID(); const storedName = `${id}${inspected.extension}`; const filePath = resolve(this.storageRoot, storedName); const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); const source = principal.transport === 'bearer' ? InspectionEvidenceSource.ANDROID : InspectionEvidenceSource.WEB; await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); try { return await this.dataSource.transaction(async (manager) => { const context = await this.lockFindingContext(manager, findingId); this.assertFindingOpen(context); if (dto.purpose === InspectionEvidencePurpose.OBSERVATION) { this.assertObservationEditable(context); await this.assertActorAssigned(manager, context.visitId, principal); if (dto.verificationVisitId) { throw new BadRequestException({ code: 'INSPECTION_OBSERVATION_VERIFICATION_VISIT_NOT_ALLOWED', message: 'La evidencia inicial no debe indicar una visita de verificación', }); } } if (dto.purpose === InspectionEvidencePurpose.VERIFICATION) { if (!dto.verificationVisitId) { throw new BadRequestException({ code: 'INSPECTION_VERIFICATION_VISIT_REQUIRED', message: 'La foto de verificación debe indicar la visita en curso', }); } await this.assertVerificationEditable( manager, findingId, dto.verificationVisitId, principal, ); } else if (dto.verificationVisitId) { throw new BadRequestException({ code: 'INSPECTION_VERIFICATION_VISIT_PURPOSE_INVALID', message: 'La visita de verificación sólo corresponde a evidencia de verificación', }); } const communication = dto.communicationId ? await this.requireCommunication(manager, findingId, dto.communicationId) : null; this.validateCommunicationPurpose(dto.purpose, communication); await manager.query(` INSERT INTO inspection_finding_evidence ( id, finding_id, communication_id, verification_visit_id, kind, purpose, original_name, stored_name, mime_type, size_bytes, sha256, title, description, captured_at, latitude, longitude, accuracy_m, device_label, source, uploaded_by, client_operation_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 ) `, [ id, findingId, dto.communicationId ?? null, dto.verificationVisitId ?? null, dto.kind, dto.purpose, inspected.originalName, storedName, inspected.mimeType, file!.buffer.length, sha256, dto.title?.trim() || null, dto.description?.trim() || null, dto.capturedAt ? new Date(dto.capturedAt) : null, dto.latitude ?? null, dto.longitude ?? null, dto.accuracyM ?? null, dto.deviceLabel?.trim() || null, source, principal.userId, dto.operationId ?? null, ]); const created = await this.loadEvidence(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_EVIDENCE_UPLOADED, entityType: 'inspection_finding_evidence', entityId: id, afterData: this.evidenceAuditView(created), metadata: { findingId, actId: context.actId, visitId: dto.verificationVisitId ?? context.visitId, sourceVisitId: context.visitId, verificationVisitId: dto.verificationVisitId ?? null, communicationId: dto.communicationId ?? null, }, }, manager); const { storedName: _storedName, ...view } = created; return view; }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } async listCommunications( findingId: string, ): Promise<{ data: InspectionCommunicationView[] }> { await this.requireFinding(this.dataSource.manager, findingId); return { data: await this.dataSource.query( `${this.communicationSelect('WHERE communication.finding_id = $1')} ORDER BY communication.occurred_at DESC, communication.created_at DESC`, [findingId], ) as InspectionCommunicationView[], }; } async createCommunication( findingId: string, dto: CreateInspectionCommunicationDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if ( dto.type === InspectionCommunicationType.COMPANY_RESPONSE && dto.direction !== InspectionCommunicationDirection.INBOUND ) { throw new BadRequestException({ code: 'COMPANY_RESPONSE_MUST_BE_INBOUND', message: 'Una respuesta de la empresa debe registrarse como comunicación recibida', }); } return this.dataSource.transaction(async (manager) => { const context = await this.lockFindingContext(manager, findingId); this.assertFindingOpen(context); const id = randomUUID(); await manager.query(` INSERT INTO inspection_finding_communications ( id, finding_id, direction, channel, type, occurred_at, subject, details, contact_name, contact_email, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `, [ id, findingId, dto.direction, dto.channel, dto.type, new Date(dto.occurredAt), dto.subject, dto.details ?? null, dto.contactName ?? null, dto.contactEmail?.toLowerCase() ?? null, principal.userId, ]); const created = await this.loadCommunication(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_COMMUNICATION_CREATED, entityType: 'inspection_finding_communication', entityId: id, afterData: created as unknown as Record, metadata: { findingId, actId: context.actId, visitId: context.visitId, immutable: true, }, }, manager); return created; }); } async content(evidenceId: string): Promise<{ filePath: string; evidence: StoredInspectionEvidence; }> { const evidence = await this.loadEvidence(this.dataSource.manager, evidenceId); const filePath = resolve(this.storageRoot, evidence.storedName); if (!filePath.startsWith(`${this.storageRoot}/`)) { throw new InternalServerErrorException({ code: 'INVALID_INSPECTION_EVIDENCE_STORAGE_PATH', message: 'Ruta de almacenamiento inválida', }); } try { const fileStat = await stat(filePath); if (!fileStat.isFile() || fileStat.size !== evidence.sizeBytes) { throw new Error('size mismatch'); } } catch { throw new InternalServerErrorException({ code: 'INSPECTION_EVIDENCE_FILE_MISSING', message: 'El archivo físico no está disponible', }); } return { filePath, evidence }; } private validateCoordinates( latitude: number | null | undefined, longitude: number | null | undefined, accuracyM: number | null | undefined, ): void { const hasLatitude = latitude !== null && latitude !== undefined; const hasLongitude = longitude !== null && longitude !== undefined; if (hasLatitude !== hasLongitude) { throw new BadRequestException({ code: 'INVALID_INSPECTION_EVIDENCE_COORDINATES', message: 'Latitud y longitud deben informarse juntas', }); } if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) { throw new BadRequestException({ code: 'INSPECTION_EVIDENCE_ACCURACY_WITHOUT_COORDINATES', message: 'La precisión requiere coordenadas', }); } } private validatePurpose(dto: CreateInspectionEvidenceDto, mimeType: string): void { if ( dto.kind === InspectionEvidenceKind.PHOTO && ![InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose) ) { throw new BadRequestException({ code: 'INSPECTION_EVIDENCE_PHOTO_PURPOSE_INVALID', message: 'Las fotografías se registran como evidencia inicial o de verificación', }); } if (dto.purpose === InspectionEvidencePurpose.VERIFICATION && dto.kind !== InspectionEvidenceKind.PHOTO) { throw new BadRequestException({ code: 'INSPECTION_VERIFICATION_PHOTO_REQUIRED', message: 'La evidencia de verificación debe ser una fotografía', }); } if ( dto.purpose === InspectionEvidencePurpose.COMPANY_RESPONSE && (dto.kind !== InspectionEvidenceKind.DOCUMENT || mimeType !== 'application/pdf') ) { throw new BadRequestException({ code: 'COMPANY_RESPONSE_PDF_REQUIRED', message: 'La respuesta de la empresa debe adjuntarse como PDF', }); } if ( dto.purpose === InspectionEvidencePurpose.OTHER_DOCUMENT && dto.kind !== InspectionEvidenceKind.DOCUMENT ) { throw new BadRequestException({ code: 'INSPECTION_OTHER_DOCUMENT_PDF_REQUIRED', message: 'Otros documentos deben adjuntarse como PDF', }); } } private validateCommunicationPurpose( purpose: InspectionEvidencePurpose, communication: CommunicationLink | null, ): void { if ( purpose === InspectionEvidencePurpose.COMPANY_RESPONSE && (!communication || communication.type !== InspectionCommunicationType.COMPANY_RESPONSE || communication.direction !== InspectionCommunicationDirection.INBOUND) ) { throw new BadRequestException({ code: 'COMPANY_RESPONSE_COMMUNICATION_REQUIRED', message: 'El PDF debe vincularse con una respuesta de empresa registrada', }); } if ( purpose === InspectionEvidencePurpose.COMMUNICATION_ATTACHMENT && !communication ) { throw new BadRequestException({ code: 'COMMUNICATION_ATTACHMENT_LINK_REQUIRED', message: 'El documento debe vincularse con una comunicación', }); } if (purpose === InspectionEvidencePurpose.VERIFICATION && communication) { throw new BadRequestException({ code: 'VERIFICATION_EVIDENCE_COMMUNICATION_NOT_ALLOWED', message: 'La foto de verificación se vincula con la visita, no con una comunicación', }); } } private assertFindingOpen(context: FindingContext): void { if (context.status !== InspectionFindingStatus.OPEN) { throw new ConflictException({ code: 'INSPECTION_FINDING_NOT_OPEN', message: 'No se pueden agregar registros a un hallazgo cerrado', }); } } private assertObservationEditable(context: FindingContext): void { if ( context.actStatus !== InspectionActStatus.DRAFT || context.visitStatus !== InspectionVisitStatus.IN_PROGRESS ) { throw new ConflictException({ code: 'INSPECTION_OBSERVATION_EVIDENCE_LOCKED', message: 'La evidencia de campo se carga durante la visita y con el acta en borrador', }); } } private async assertVerificationEditable( manager: EntityManager, findingId: string, verificationVisitId: string, principal: AuthPrincipal, ): Promise { const [row] = await manager.query(` SELECT visit.id, visit.status FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.finding_id = $1 AND verification_link.visit_id = $2 `, [findingId, verificationVisitId]) as Array<{ id: string; status: InspectionVisitStatus }>; if (!row) { throw new BadRequestException({ code: 'INSPECTION_VERIFICATION_LINK_INVALID', message: 'El hallazgo no pertenece a la visita de verificación indicada', }); } if (row.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_VERIFICATION_EVIDENCE_LOCKED', message: 'La evidencia de verificación sólo se carga durante la visita en curso', }); } await this.assertActorAssigned(manager, verificationVisitId, principal); } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const rows = await manager.query(` SELECT 1 FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true `, [visitId, principal.userId]) as unknown[]; if (rows.length === 0) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La visita no está asignada al usuario actual', }); } } private async requireFinding(manager: EntityManager, id: string): Promise { const rows = await manager.query( 'SELECT 1 FROM inspection_findings WHERE id = $1', [id], ) as unknown[]; if (rows.length === 0) throw findingNotFound(); } private async lockFindingContext( manager: EntityManager, id: string, ): Promise { const [context] = await manager.query(` SELECT finding.id, finding.status, act.id AS "actId", act.status AS "actStatus", visit.id AS "visitId", visit.status AS "visitStatus" FROM inspection_findings finding INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE finding.id = $1 FOR UPDATE OF finding `, [id]) as FindingContext[]; if (!context) throw findingNotFound(); return context; } private async requireCommunication( manager: EntityManager, findingId: string, communicationId: string, ): Promise { const [communication] = await manager.query(` SELECT id, type, direction, subject FROM inspection_finding_communications WHERE id = $1 AND finding_id = $2 `, [communicationId, findingId]) as CommunicationLink[]; if (!communication) { throw new BadRequestException({ code: 'INSPECTION_COMMUNICATION_INVALID', message: 'La comunicación seleccionada no pertenece al hallazgo', }); } return communication; } private evidenceSelect(): string { return ` SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId", evidence.verification_visit_id AS "verificationVisitId", CASE WHEN communication.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', communication.id, 'type', communication.type, 'direction', communication.direction, 'subject', communication.subject ) END AS communication, evidence.kind, evidence.purpose, evidence.original_name AS "originalName", evidence.stored_name AS "storedName", evidence.mime_type AS "mimeType", evidence.size_bytes::integer AS "sizeBytes", evidence.sha256, evidence.title, evidence.description, evidence.captured_at AS "capturedAt", evidence.latitude::double precision AS latitude, evidence.longitude::double precision AS longitude, evidence.accuracy_m::double precision AS "accuracyM", evidence.device_label AS "deviceLabel", evidence.source, evidence.uploaded_by AS "uploadedBy", uploader.username AS "uploadedByUsername", evidence.created_at AS "createdAt" FROM inspection_finding_evidence evidence LEFT JOIN inspection_finding_communications communication ON communication.id = evidence.communication_id LEFT JOIN users uploader ON uploader.id = evidence.uploaded_by `; } private async loadEvidence( manager: EntityManager, id: string, ): Promise { const [evidence] = await manager.query( `${this.evidenceSelect()} WHERE evidence.id = $1`, [id], ) as StoredInspectionEvidence[]; if (!evidence) throw evidenceNotFound(); return evidence; } private communicationSelect(where: string): string { return ` SELECT communication.id, communication.finding_id AS "findingId", communication.direction, communication.channel, communication.type, communication.occurred_at AS "occurredAt", communication.subject, communication.details, communication.contact_name AS "contactName", communication.contact_email AS "contactEmail", communication.created_by AS "createdBy", creator.username AS "createdByUsername", COUNT(evidence.id)::integer AS "attachmentCount", communication.created_at AS "createdAt" FROM inspection_finding_communications communication LEFT JOIN users creator ON creator.id = communication.created_by LEFT JOIN inspection_finding_evidence evidence ON evidence.communication_id = communication.id ${where} GROUP BY communication.id, creator.username `; } private async loadCommunication( manager: EntityManager, id: string, ): Promise { const [communication] = await manager.query( this.communicationSelect('WHERE communication.id = $1'), [id], ) as InspectionCommunicationView[]; if (!communication) { throw new NotFoundException({ code: 'INSPECTION_COMMUNICATION_NOT_FOUND', message: 'Comunicación no encontrada', }); } return communication; } private evidenceAuditView( evidence: StoredInspectionEvidence, ): Record { return { findingId: evidence.findingId, communicationId: evidence.communicationId, verificationVisitId: evidence.verificationVisitId, kind: evidence.kind, purpose: evidence.purpose, originalName: evidence.originalName, mimeType: evidence.mimeType, sizeBytes: evidence.sizeBytes, sha256: evidence.sha256, capturedAt: evidence.capturedAt, latitude: evidence.latitude, longitude: evidence.longitude, accuracyM: evidence.accuracyM, deviceLabel: evidence.deviceLabel, source: evidence.source, uploadedBy: evidence.uploadedBy, immutable: true, }; } }