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, InspectionAct, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation, InspectionActStatus, InspectionActUploadMode, InspectionActVersionEvent, InspectionResponsibleAttendanceStatus, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; import { sha256CanonicalJson } from './canonical-json'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { inspectInspectionSignatureFile, type UploadedInspectionSignatureFile, } from './inspection-signature-file'; const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; const CONSENT_VERSION = 'D5-1'; const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.'; const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.'; interface ResponsibleView { actId: string; attendanceStatus: InspectionResponsibleAttendanceStatus; fullName: string | null; documentType: string | null; documentNumber: string | null; position: string | null; email: string | null; phone: string | null; absenceReason: string | null; updatedBy: string | null; createdAt: Date; updatedAt: Date; } interface SignatureView { id: string; actId: string; signerType: InspectionActSignerType; signerUserId: string | null; signerName: string; documentType: string | null; documentNumber: string | null; position: string | null; status: InspectionActSignatureStatus; reason: string | null; companyManifestation: InspectionCompanySignatureManifestation | null; companyStatement: string | null; mimeType: string | null; sizeBytes: number | null; imageSha256: string | null; consentText: string | null; consentVersion: string | null; consentAcceptedAt: Date | null; clientSignedAt: Date | null; signedAt: Date | null; latitude: number | null; longitude: number | null; accuracyM: number | null; deviceLabel: string | null; source: InspectionActSignatureSource; preparedSha256: string; signaturePayloadSha256: string; uploadedBy: string; uploadedByUsername: string; createdAt: Date; } interface StoredSignature extends SignatureView { originalName: string | null; storedName: string | null; } interface ClosureRecord { actId: string; schemaVersion: string; preparedSnapshot: Record; preparedSha256: string; preparedAt: Date; preparedBy: string; finalSnapshot: Record | null; finalSha256: string | null; deviceClosedAt: Date | null; serverClosedAt: Date | null; uploadMode: InspectionActUploadMode | null; closedBy: string | null; } export interface InspectionClosureView { act: { id: string; code: string; status: InspectionActStatus; visitId: string; currentVersion: number; closedAt: Date | null; closedBy: string | null; closureSha256: string | null; }; visit: { id: string; code: string; status: InspectionVisitStatus; actualClosedAt: Date | null; }; responsible: ResponsibleView | null; closure: null | { schemaVersion: string; preparedSha256: string; preparedAt: Date; preparedBy: string; finalSha256: string | null; deviceClosedAt: Date | null; serverClosedAt: Date | null; uploadMode: InspectionActUploadMode | null; closedBy: string | null; isCurrent: boolean; }; signatures: SignatureView[]; consents: { version: string; inspector: string; company: string; }; } function actNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } function signatureNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_SIGNATURE_NOT_FOUND', message: 'Firma de inspección no encontrada', }); } @Injectable() export class InspectionClosingService { private readonly signatureRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly reports: InspectionReportsService, config: ConfigService, ) { const configured = config.get('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures'; if (!isAbsolute(configured)) { throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path'); } this.signatureRoot = resolve(configured); if (this.signatureRoot === parse(this.signatureRoot).root) { throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root'); } } async get(actId: string): Promise { return this.loadView(this.dataSource.manager, actId); } async upsertResponsible( actId: string, dto: UpsertInspectionResponsibleDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertDraftInProgress(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); const before = await this.loadResponsible(manager, actId); const present = dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT; await manager.query(` INSERT INTO inspection_act_responsibles ( act_id, attendance_status, full_name, document_type, document_number, position, email, phone, absence_reason, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (act_id) DO UPDATE SET attendance_status = EXCLUDED.attendance_status, full_name = EXCLUDED.full_name, document_type = EXCLUDED.document_type, document_number = EXCLUDED.document_number, position = EXCLUDED.position, email = EXCLUDED.email, phone = EXCLUDED.phone, absence_reason = EXCLUDED.absence_reason, updated_by = EXCLUDED.updated_by `, [ actId, dto.attendanceStatus, present ? dto.fullName : null, present ? dto.documentType : null, present ? dto.documentNumber : null, present ? dto.position : null, dto.email?.toLowerCase() ?? null, dto.phone ?? null, present ? null : dto.absenceReason, principal.userId, ]); const after = await this.loadResponsible(manager, actId); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_RESPONSIBLE_UPDATED, entityType: 'inspection_act_responsible', entityId: actId, beforeData: before as unknown as Record | null, afterData: after as unknown as Record, metadata: { actId, visitId: visit.id }, }, manager); return this.loadView(manager, actId); }); } async prepare( actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertDraftInProgress(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); await this.requireResponsible(manager, actId); const [count] = (await manager.query(` SELECT COUNT(*)::integer AS total FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' `, [actId])) as Array<{ total: number }>; const [verification] = (await manager.query(` SELECT COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed FROM inspection_finding_verification_visits verification_link WHERE verification_link.visit_id = $1 `, [visit.id])) as Array<{ total: number; completed: number }>; const verificationTotal = Number(verification?.total ?? 0); const verificationCompleted = Number(verification?.completed ?? 0); if (verificationTotal > 0 && verificationCompleted !== verificationTotal) { throw new ConflictException({ code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta', }); } if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) { throw new ConflictException({ code: 'INSPECTION_ACT_FINDING_REQUIRED', message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse', }); } const signatures = await this.signatureCount(manager, actId); if (signatures > 0) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', message: 'El acta ya tiene firmas y no puede volver a prepararse', }); } const [updated] = (await manager.query(` UPDATE inspection_acts SET status = 'READY', current_version = current_version + 1, updated_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING current_version AS "versionNumber" `, [actId, principal.userId])) as Array<{ versionNumber: number }>; const preparedAt = new Date(); const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt); const preparedSha256 = sha256CanonicalJson(preparedSnapshot); await manager.query(` INSERT INTO inspection_act_closures ( act_id, schema_version, prepared_snapshot, prepared_sha256, prepared_at, prepared_by ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (act_id) DO UPDATE SET schema_version = EXCLUDED.schema_version, prepared_snapshot = EXCLUDED.prepared_snapshot, prepared_sha256 = EXCLUDED.prepared_sha256, prepared_at = EXCLUDED.prepared_at, prepared_by = EXCLUDED.prepared_by `, [ actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, preparedAt, principal.userId, ]); await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [ actId, Number(updated.versionNumber), InspectionActVersionEvent.READY, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_READY, entityType: 'inspection_act', entityId: actId, afterData: { status: InspectionActStatus.READY, preparedSha256, schemaVersion: CLOSURE_SCHEMA_VERSION, }, metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } async reopen( actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_READY', message: 'Sólo puede reabrirse un acta preparada de una visita en curso', }); } await this.assertActorAssigned(manager, visit.id, principal, true); if (await this.signatureCount(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN', message: 'El acta ya tiene firmas y no puede volver a borrador', }); } const closure = await this.requireClosure(manager, actId); const [updated] = (await manager.query(` UPDATE inspection_acts SET status = 'DRAFT', current_version = current_version + 1, updated_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING current_version AS "versionNumber" `, [actId, principal.userId])) as Array<{ versionNumber: number }>; const snapshot = { actId, actCode: act.code, status: InspectionActStatus.DRAFT, reopenedAt: new Date().toISOString(), previousPreparedSha256: closure.preparedSha256, }; await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [ actId, Number(updated.versionNumber), InspectionActVersionEvent.REOPENED, snapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_REOPENED, entityType: 'inspection_act', entityId: actId, afterData: snapshot, metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } async signInspector( actId: string, dto: CreateInspectionSignatureDto, file: UploadedInspectionSignatureFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); const identity = { signerType: InspectionActSignerType.INSPECTOR, signerUserId: principal.userId, signerName: `${principal.firstName} ${principal.lastName}`.trim(), documentType: null, documentNumber: null, position: 'Inspector/a', consentText: INSPECTOR_CONSENT, auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, } as const; return this.createSignedSignature(actId, dto, file, identity, principal, request, true); } async signCompany( actId: string, dto: CreateCompanySignatureDto, file: UploadedInspectionSignatureFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.createSignedSignature(actId, dto, file, null, principal, request, false); } async recordCompanyOutcome( actId: string, dto: CreateCompanyOutcomeDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertReadyForCompanyOutcome(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); const responsible = await this.requireResponsible(manager, actId); if ( dto.status === InspectionActSignatureStatus.REFUSED && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT ) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE', message: 'La negativa a firmar requiere que el responsable esté presente', }); } if ( dto.status === InspectionActSignatureStatus.ABSENT && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT ) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_ABSENCE_MISMATCH', message: 'El resultado ausente requiere que el responsable se haya registrado como ausente', }); } const closure = await this.requireClosure(manager, actId); if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', message: 'La recepción de la empresa ya fue registrada y es inmutable', }); } const source = this.signatureSource(principal); const createdAt = new Date(); const signerName = responsible.fullName ?? 'Responsable de la empresa no presente'; const payload = { actId, preparedSha256: closure.preparedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, documentType: responsible.documentType, documentNumber: responsible.documentNumber, position: responsible.position, status: dto.status, reason: dto.reason, source, uploadedBy: principal.userId, createdAt: createdAt.toISOString(), }; const signaturePayloadSha256 = sha256CanonicalJson(payload); const id = randomUUID(); await manager.query(` INSERT INTO inspection_act_signatures ( id, act_id, signer_type, signer_user_id, signer_name, document_type, document_number, position, status, reason, source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at ) VALUES ( $1, $2, $3, NULL, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) `, [ id, actId, InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, responsible.documentType, responsible.documentNumber, responsible.position, dto.status, dto.reason, source, closure.preparedSha256, signaturePayloadSha256, principal.userId, createdAt, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_COMPANY_OUTCOME_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 }, }, manager); return this.loadView(manager, actId); }); } async close( actId: string, dto: CloseInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); const closed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertReadyForFinalClosure(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const signatures = await this.loadSignatures(manager, actId); if (!signatures.some((item) => ( item.signerType === InspectionActSignerType.INSPECTOR && item.status === InspectionActSignatureStatus.SIGNED ))) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', message: 'Se requiere al menos una firma de inspector para cerrar el acta', }); } if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa', }); } const serverClosedAt = new Date(); const deviceClosedAt = new Date(dto.clientClosedAt); if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_ACT_INVALID_DEVICE_TIME', message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', }); } const signatureSnapshot = signatures.map((item) => ({ id: item.id, signerType: item.signerType, signerUserId: item.signerUserId, signerName: item.signerName, documentType: item.documentType, documentNumber: item.documentNumber, position: item.position, status: item.status, reason: item.reason, companyManifestation: item.companyManifestation, companyStatement: item.companyStatement, imageSha256: item.imageSha256, consentText: item.consentText, consentVersion: item.consentVersion, consentAcceptedAt: item.consentAcceptedAt, clientSignedAt: item.clientSignedAt, signedAt: item.signedAt, latitude: item.latitude, longitude: item.longitude, accuracyM: item.accuracyM, deviceLabel: item.deviceLabel, source: item.source, preparedSha256: item.preparedSha256, signaturePayloadSha256: item.signaturePayloadSha256, uploadedBy: item.uploadedBy, createdAt: item.createdAt, })); const finalSnapshot = { schemaVersion: CLOSURE_SCHEMA_VERSION, preparedSha256: closure.preparedSha256, preparedSnapshot: closure.preparedSnapshot, signatures: signatureSnapshot, closure: { deviceClosedAt: deviceClosedAt.toISOString(), serverClosedAt: serverClosedAt.toISOString(), uploadMode: dto.uploadMode, closedBy: principal.userId, closedByUsername: principal.username, }, }; const finalSha256 = sha256CanonicalJson(finalSnapshot); await manager.query(` UPDATE inspection_act_closures SET final_snapshot = $2, final_sha256 = $3, device_closed_at = $4, server_closed_at = $5, upload_mode = $6, closed_by = $7 WHERE act_id = $1 `, [ actId, finalSnapshot, finalSha256, deviceClosedAt, serverClosedAt, dto.uploadMode, principal.userId, ]); const [updated] = (await manager.query(` UPDATE inspection_acts SET status = 'CLOSED', closed_at = $2, closed_by = $3, closure_sha256 = $4, current_version = current_version + 1, updated_by = $3, updated_at = $2 WHERE id = $1 RETURNING current_version AS "versionNumber" `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [ actId, Number(updated.versionNumber), InspectionActVersionEvent.CLOSED, finalSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_CLOSED, entityType: 'inspection_act', entityId: actId, afterData: { status: InspectionActStatus.CLOSED, closureSha256: finalSha256, serverClosedAt, uploadMode: dto.uploadMode, }, metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber), findingsRemainOpen: true, visitRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); }); await this.reports.ensureWordForAct(actId); return closed; } async signatureContent(signatureId: string): Promise<{ filePath: string; signature: StoredSignature; }> { const signature = await this.loadStoredSignature(this.dataSource.manager, signatureId); if (signature.status !== InspectionActSignatureStatus.SIGNED || !signature.storedName) { throw signatureNotFound(); } const filePath = resolve(this.signatureRoot, signature.storedName); if (!filePath.startsWith(`${this.signatureRoot}/`)) { throw new InternalServerErrorException({ code: 'INVALID_INSPECTION_SIGNATURE_STORAGE_PATH', message: 'Ruta de almacenamiento de firma inválida', }); } try { const fileStat = await stat(filePath); if (!fileStat.isFile() || fileStat.size !== signature.sizeBytes) throw new Error('size mismatch'); } catch { throw new InternalServerErrorException({ code: 'INSPECTION_SIGNATURE_FILE_MISSING', message: 'El archivo físico de la firma no está disponible', }); } return { filePath, signature }; } private async createSignedSignature( actId: string, dto: CreateInspectionSignatureDto | CreateCompanySignatureDto, file: UploadedInspectionSignatureFile | undefined, fixedIdentity: null | { signerType: InspectionActSignerType; signerUserId: string; signerName: string; documentType: null; documentNumber: null; position: string; consentText: string; auditAction: AuditAction; }, principal: AuthPrincipal, request: RequestWithContext, requireSelfAssignment: boolean, ): Promise { this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); const inspected = inspectInspectionSignatureFile(file); const id = randomUUID(); const storedName = `${id}.png`; const filePath = resolve(this.signatureRoot, storedName); const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex'); await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); try { return await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); if (fixedIdentity) this.assertReadyInProgress(act, visit); else this.assertReadyForCompanyOutcome(act, visit); await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); const closure = await this.requireClosure(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new ConflictException({ code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT', message: 'No puede registrarse firma de empresa cuando el responsable está ausente', }); } const identity = fixedIdentity ?? { signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerUserId: null, signerName: responsible!.fullName!, documentType: responsible!.documentType, documentNumber: responsible!.documentNumber, position: responsible!.position, consentText: COMPANY_CONSENT, auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, }; if (identity.signerType === InspectionActSignerType.INSPECTOR) { const [existing] = (await manager.query(` SELECT 1 FROM inspection_act_signatures WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2 `, [actId, identity.signerUserId])) as unknown[]; if (existing) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED', message: 'El inspector actual ya firmó esta acta', }); } } else if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', message: 'La recepción de la empresa ya fue registrada y es inmutable', }); } const source = this.signatureSource(principal); const signedAt = new Date(); const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; const companyDto = fixedIdentity ? null : dto as CreateCompanySignatureDto; const companyManifestation = fixedIdentity ? null : companyDto?.manifestation ?? InspectionCompanySignatureManifestation.CONFORMITY; const companyStatement = companyManifestation === InspectionCompanySignatureManifestation.DISSENT ? companyDto?.statement?.trim() ?? null : null; const payload = { actId, preparedSha256: closure.preparedSha256, signerType: identity.signerType, signerUserId: identity.signerUserId, signerName: identity.signerName, documentType: identity.documentType, documentNumber: identity.documentNumber, position: identity.position, status: InspectionActSignatureStatus.SIGNED, companyManifestation, companyStatement, imageSha256, consentText: identity.consentText, consentVersion: CONSENT_VERSION, consentAcceptedAt: signedAt.toISOString(), clientSignedAt: clientSignedAt?.toISOString() ?? null, signedAt: signedAt.toISOString(), latitude: dto.latitude ?? null, longitude: dto.longitude ?? null, accuracyM: dto.accuracyM ?? null, deviceLabel: dto.deviceLabel?.trim() || null, source, uploadedBy: principal.userId, }; const signaturePayloadSha256 = sha256CanonicalJson(payload); await manager.query(` INSERT INTO inspection_act_signatures ( id, act_id, signer_type, signer_user_id, signer_name, document_type, document_number, position, status, company_manifestation, company_statement, original_name, stored_name, mime_type, size_bytes, image_sha256, consent_text, consent_version, consent_accepted_at, client_signed_at, signed_at, latitude, longitude, accuracy_m, device_label, source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, 'SIGNED', $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $20 ) `, [ id, actId, identity.signerType, identity.signerUserId, identity.signerName, identity.documentType, identity.documentNumber, identity.position, companyManifestation, companyStatement, inspected.originalName, storedName, inspected.mimeType, file!.buffer.length, imageSha256, identity.consentText, CONSENT_VERSION, signedAt, clientSignedAt, signedAt, dto.latitude ?? null, dto.longitude ?? null, dto.accuracyM ?? null, dto.deviceLabel?.trim() || null, source, closure.preparedSha256, signaturePayloadSha256, principal.userId, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: identity.auditAction, entityType: 'inspection_act_signature', entityId: id, afterData: payload, metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256, }, }, manager); return this.loadView(manager, actId); }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } private async buildPreparedSnapshot( manager: EntityManager, actId: string, preparedAt: Date, ): Promise> { const [act] = (await manager.query(` SELECT act.id, act.code, act.act_year AS "actYear", act.act_number AS "actNumber", act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.observations, act.current_version AS "currentVersion", act.created_at AS "createdAt", act.updated_at AS "updatedAt", JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, 'title', visit.title, 'objective', visit.objective, 'status', visit.status, 'scopeAssetId', visit.scope_asset_id, 'leadInspectorUserId', visit.lead_inspector_user_id, 'plannedStartAt', visit.planned_start_at, 'plannedEndAt', visit.planned_end_at, 'actualStartedAt', visit.actual_started_at, 'instructions', visit.instructions ) AS visit FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE act.id = $1 `, [actId])) as Array>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); const team = await manager.query(` SELECT member.user_id AS "userId", user_account.username, user_account.first_name AS "firstName", user_account.last_name AS "lastName", user_account.email, (visit.lead_inspector_user_id = member.user_id) AS "isLead" FROM inspection_visit_members member INNER JOIN inspection_visits visit ON visit.id = member.visit_id INNER JOIN users user_account ON user_account.id = member.user_id WHERE member.visit_id = $1 AND member.included = true ORDER BY "isLead" DESC, user_account.username, member.user_id `, [(act.visit as { id: string }).id]) as Array>; const assets = await manager.query(` SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset.description, asset.parent_id AS "parentId", CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name ) END AS "operatorCompany", CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name ) END AS "operationalArea", asset.information_status AS "informationStatus", asset.current_version AS "currentVersion", asset.data_origin AS "dataOrigin", asset.source_name AS "sourceName", asset.source_reference AS "sourceReference", asset.source_observed_at AS "sourceObservedAt", asset_type.id AS "typeId", asset_type.code AS "typeCode", asset_type.name AS "typeName", COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'definitionId', definition.id, 'code', definition.code, 'name', definition.name, 'dataType', definition.data_type, 'value', attribute_value.value ) ORDER BY definition.sort_order, definition.code, definition.id) FROM asset_attribute_values attribute_value INNER JOIN asset_attribute_definitions definition ON definition.id = attribute_value.definition_id WHERE attribute_value.asset_id = asset.id ), '[]'::jsonb) AS attributes, CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'type', geometry.geometry_type, 'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb, 'source', geometry.source, 'accuracyM', geometry.accuracy_m, 'capturedAt', geometry.captured_at, 'deviceLabel', geometry.device_label ) END AS geometry FROM inspection_act_assets link INNER JOIN assets asset ON asset.id = link.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id WHERE link.act_id = $1 AND link.included = true ORDER BY asset.code, asset.id `, [actId]) as Array>; const findings = await manager.query(` SELECT finding.id, finding.finding_number AS "findingNumber", finding.code, finding.status, finding.asset_id AS "assetId", finding.catalog_item_id AS "catalogItemId", finding.title, finding.description, finding.legal_basis AS "legalBasis", finding.glossary, finding.catalog_revision AS "catalogRevision", finding.suggested_severity AS "suggestedSeverity", finding.severity, finding.correction_due_on AS "correctionDueOn", finding.company_response AS "companyResponse", finding.company_response_received_on AS "companyResponseReceivedOn", finding.company_committed_correction_on AS "companyCommittedCorrectionOn", finding.next_control_on AS "nextControlOn", finding.current_version AS "currentVersion", JSONB_BUILD_OBJECT( 'code', catalog.code, 'sourceNumber', catalog.source_number, 'title', catalog.title, 'revision', catalog.revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code, 'categoryName', category.name ) AS catalog, COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', evidence.id, 'communicationId', evidence.communication_id, 'kind', evidence.kind, 'purpose', evidence.purpose, 'originalName', evidence.original_name, 'mimeType', evidence.mime_type, 'sizeBytes', evidence.size_bytes, 'sha256', evidence.sha256, 'title', evidence.title, 'description', evidence.description, 'capturedAt', evidence.captured_at, 'latitude', evidence.latitude, 'longitude', evidence.longitude, 'accuracyM', evidence.accuracy_m, 'deviceLabel', evidence.device_label, 'source', evidence.source, 'uploadedBy', evidence.uploaded_by, 'createdAt', evidence.created_at ) ORDER BY evidence.created_at, evidence.id) FROM inspection_finding_evidence evidence WHERE evidence.finding_id = finding.id ), '[]'::jsonb) AS evidence, COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', communication.id, 'direction', communication.direction, 'channel', communication.channel, 'type', communication.type, 'occurredAt', communication.occurred_at, 'subject', communication.subject, 'details', communication.details, 'contactName', communication.contact_name, 'contactEmail', communication.contact_email, 'createdBy', communication.created_by, 'createdAt', communication.created_at ) ORDER BY communication.occurred_at, communication.id) FROM inspection_finding_communications communication WHERE communication.finding_id = finding.id ), '[]'::jsonb) AS communications FROM inspection_findings finding LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_categories category ON category.id = catalog.category_id WHERE finding.act_id = $1 AND finding.status <> 'VOIDED' ORDER BY finding.finding_number, finding.id `, [actId]) as Array>; const verificationResults = await manager.query(` SELECT verification_link.finding_id AS "findingId", finding.code AS "findingCode", finding.title AS "findingTitle", finding.description AS "findingDescription", finding.asset_id AS "assetId", asset.code AS "assetCode", asset.name AS "assetName", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "resultNotes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', evidence.id, 'kind', evidence.kind, 'purpose', evidence.purpose, 'originalName', evidence.original_name, 'mimeType', evidence.mime_type, 'sizeBytes', evidence.size_bytes, 'sha256', evidence.sha256, 'title', evidence.title, 'description', evidence.description, 'capturedAt', evidence.captured_at, 'latitude', evidence.latitude, 'longitude', evidence.longitude, 'accuracyM', evidence.accuracy_m, 'deviceLabel', evidence.device_label, 'source', evidence.source, 'uploadedBy', evidence.uploaded_by, 'createdAt', evidence.created_at ) ORDER BY evidence.created_at, evidence.id) FROM inspection_finding_evidence evidence WHERE evidence.finding_id = finding.id AND evidence.verification_visit_id = $1 AND evidence.purpose = 'VERIFICATION' ), '[]'::jsonb) AS evidence FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id INNER JOIN assets asset ON asset.id = finding.asset_id WHERE verification_link.visit_id = $1 ORDER BY finding.code `, [(act.visit as { id: string }).id]) as Array>; return { schemaVersion: CLOSURE_SCHEMA_VERSION, preparedAt: preparedAt.toISOString(), act, responsible, team, assets, findings, verificationResults, }; } private async loadView(manager: EntityManager, actId: string): Promise { const [context] = (await manager.query(` SELECT act.id, act.code, act.status, act.visit_id AS "visitId", act.current_version AS "currentVersion", act.closed_at AS "closedAt", act.closed_by AS "closedBy", act.closure_sha256 AS "closureSha256", visit.code AS "visitCode", visit.status AS "visitStatus", visit.actual_closed_at AS "visitActualClosedAt" FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE act.id = $1 `, [actId])) as Array<{ id: string; code: string; status: InspectionActStatus; visitId: string; currentVersion: number; closedAt: Date | null; closedBy: string | null; closureSha256: string | null; visitCode: string; visitStatus: InspectionVisitStatus; visitActualClosedAt: Date | null; }>; if (!context) throw actNotFound(); const responsible = await this.loadResponsible(manager, actId); const closure = await this.loadClosure(manager, actId); const signatures = await this.loadSignatures(manager, actId); return { act: { id: context.id, code: context.code, status: context.status, visitId: context.visitId, currentVersion: Number(context.currentVersion), closedAt: context.closedAt, closedBy: context.closedBy, closureSha256: context.closureSha256, }, visit: { id: context.visitId, code: context.visitCode, status: context.visitStatus, actualClosedAt: context.visitActualClosedAt, }, responsible, closure: closure ? { schemaVersion: closure.schemaVersion, preparedSha256: closure.preparedSha256, preparedAt: closure.preparedAt, preparedBy: closure.preparedBy, finalSha256: closure.finalSha256, deviceClosedAt: closure.deviceClosedAt, serverClosedAt: closure.serverClosedAt, uploadMode: closure.uploadMode, closedBy: closure.closedBy, isCurrent: context.status === InspectionActStatus.READY || context.status === InspectionActStatus.CLOSED, } : null, signatures, consents: { version: CONSENT_VERSION, inspector: INSPECTOR_CONSENT, company: COMPANY_CONSENT, }, }; } private async loadResponsible(manager: EntityManager, actId: string): Promise { const [row] = (await manager.query(` SELECT act_id AS "actId", attendance_status AS "attendanceStatus", full_name AS "fullName", document_type AS "documentType", document_number AS "documentNumber", position, email, phone, absence_reason AS "absenceReason", updated_by AS "updatedBy", created_at AS "createdAt", updated_at AS "updatedAt" FROM inspection_act_responsibles WHERE act_id = $1 `, [actId])) as ResponsibleView[]; return row ?? null; } private async requireResponsible(manager: EntityManager, actId: string): Promise { const responsible = await this.loadResponsible(manager, actId); if (!responsible) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', message: 'Debe identificarse al responsable presente o documentar su ausencia', }); } return responsible; } private async loadClosure(manager: EntityManager, actId: string): Promise { const [row] = (await manager.query(` SELECT act_id AS "actId", schema_version AS "schemaVersion", prepared_snapshot AS "preparedSnapshot", prepared_sha256 AS "preparedSha256", prepared_at AS "preparedAt", prepared_by AS "preparedBy", final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256", device_closed_at AS "deviceClosedAt", server_closed_at AS "serverClosedAt", upload_mode AS "uploadMode", closed_by AS "closedBy" FROM inspection_act_closures WHERE act_id = $1 `, [actId])) as ClosureRecord[]; return row ?? null; } private async requireClosure(manager: EntityManager, actId: string): Promise { const closure = await this.loadClosure(manager, actId); if (!closure) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_PREPARED', message: 'El acta todavía no fue preparada para firmas', }); } return closure; } private signatureSelect(): string { return ` SELECT signature.id, signature.act_id AS "actId", signature.signer_type AS "signerType", signature.signer_user_id AS "signerUserId", signature.signer_name AS "signerName", signature.document_type AS "documentType", signature.document_number AS "documentNumber", signature.position, signature.status, signature.reason, signature.company_manifestation AS "companyManifestation", signature.company_statement AS "companyStatement", signature.original_name AS "originalName", signature.stored_name AS "storedName", signature.mime_type AS "mimeType", signature.size_bytes::integer AS "sizeBytes", signature.image_sha256 AS "imageSha256", signature.consent_text AS "consentText", signature.consent_version AS "consentVersion", signature.consent_accepted_at AS "consentAcceptedAt", signature.client_signed_at AS "clientSignedAt", signature.signed_at AS "signedAt", signature.latitude::double precision AS latitude, signature.longitude::double precision AS longitude, signature.accuracy_m::double precision AS "accuracyM", signature.device_label AS "deviceLabel", signature.source, signature.prepared_sha256 AS "preparedSha256", signature.signature_payload_sha256 AS "signaturePayloadSha256", signature.uploaded_by AS "uploadedBy", uploader.username AS "uploadedByUsername", signature.created_at AS "createdAt" FROM inspection_act_signatures signature INNER JOIN users uploader ON uploader.id = signature.uploaded_by `; } private async loadSignatures(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` ${this.signatureSelect()} WHERE signature.act_id = $1 ORDER BY signature.signer_type, signature.created_at, signature.id `, [actId]) as StoredSignature[]; return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); } private async loadStoredSignature(manager: EntityManager, id: string): Promise { const [row] = await manager.query(` ${this.signatureSelect()} WHERE signature.id = $1 `, [id]) as StoredSignature[]; if (!row) throw signatureNotFound(); return row; } private async signatureCount(manager: EntityManager, actId: string): Promise { const [row] = (await manager.query(` SELECT COUNT(*)::integer AS total FROM inspection_act_signatures WHERE act_id = $1 `, [actId])) as Array<{ total: number }>; return Number(row?.total ?? 0); } private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` SELECT 1 FROM inspection_act_signatures WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE' LIMIT 1 `, [actId]) as unknown[]; return rows.length > 0; } private async lockContext( manager: EntityManager, actId: string, ): Promise<{ act: InspectionAct; visit: InspectionVisit }> { const act = await manager.getRepository(InspectionAct) .createQueryBuilder('act') .where('act.id = :actId', { actId }) .setLock('pessimistic_write') .getOne(); if (!act) throw actNotFound(); const visit = await manager.getRepository(InspectionVisit) .createQueryBuilder('visit') .where('visit.id = :visitId', { visitId: act.visitId }) .setLock('pessimistic_write') .getOne(); if (!visit) throw actNotFound(); return { act, visit }; } private assertDraftInProgress(act: InspectionAct, visit: InspectionVisit): void { if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_EDITABLE', message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso', }); } } private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void { if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_READY', message: 'El acta debe estar preparada y la visita en curso', }); } } private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void { if (act.status !== InspectionActStatus.READY || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY', message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre', }); } } private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void { if (act.status !== InspectionActStatus.READY || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_READY', message: 'El acta debe estar preparada para completar su cierre definitivo', }); } } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, allowManager: boolean, ): Promise { if (allowManager && 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: 'Sólo un integrante asignado a la visita puede realizar esta firma', }); } } private signatureSource(principal: AuthPrincipal): InspectionActSignatureSource { return principal.transport === 'bearer' ? InspectionActSignatureSource.ANDROID : InspectionActSignatureSource.WEB; } private validateCoordinates( latitude: number | undefined, longitude: number | undefined, accuracyM: number | undefined, ): void { const hasLatitude = latitude !== undefined; const hasLongitude = longitude !== undefined; if (hasLatitude !== hasLongitude) { throw new BadRequestException({ code: 'INVALID_INSPECTION_SIGNATURE_COORDINATES', message: 'Latitud y longitud deben informarse juntas', }); } if (accuracyM !== undefined && !hasLatitude) { throw new BadRequestException({ code: 'INSPECTION_SIGNATURE_ACCURACY_WITHOUT_COORDINATES', message: 'La precisión requiere coordenadas', }); } } }