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 { AuditAction, InspectionAct, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType, InspectionActUploadMode, InspectionActVersionEvent, InspectionResponsibleAttendanceStatus, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { sha256CanonicalJson } from './canonical-json'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import type { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-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-LIFECYCLE-V4'; const CONSENT_VERSION = 'F4-1'; const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; 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 ReusableInspectorSignature { originalName: string; mimeType: 'image/png'; sizeBytes: number; imageSha256: string; imageData: Buffer; } 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; } interface DeadlinePolicy { urgentDays: number; urgentDayType: InspectionDeadlineDayType; nonUrgentDays: number; nonUrgentDayType: InspectionDeadlineDayType; } export interface InspectionClosureView { act: { id: string; code: string; status: InspectionActStatus; visitId: string; urgency: InspectionActUrgency | null; deadlineDays: number | null; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; closedAt: Date | null; closedBy: string | null; closureSha256: string | null; }; visit: { id: string; code: string; status: InspectionVisitStatus; actualClosedAt: Date | null; }; responsible: ResponsibleView | null; representativeSuggestion: 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, updated_at=CURRENT_TIMESTAMP `, [ 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, inspectionId: visit.id }, }, manager); return this.loadView(manager, actId); }); } /** Finaliza el contenido: desde este punto el Acta queda inmutable. */ async prepare( actId: string, dto: PrepareInspectionActDto, 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); if (await this.signatureCount(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', message: 'El acta ya tiene constancias de firma y no puede volver a finalizarse', }); } const policy = await this.deadlinePolicy(manager); const deadlineDays = dto.urgency === InspectionActUrgency.URGENT ? policy.urgentDays : policy.nonUrgentDays; const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT ? policy.urgentDayType : policy.nonUrgentDayType; const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT ? InspectionDeadlineBasis.ACT_DATE : InspectionDeadlineBasis.GEDO_DATE; const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT ? act.occurredAt : null; const deadlineAt = deadlineBaseAt ? await this.calculateDeadline(manager, deadlineBaseAt, deadlineDays, deadlineDayType) : null; const lockedAt = new Date(); const [updated] = (await manager.query(` WITH updated AS ( UPDATE inspection_acts SET status='LOCKED', urgency=$2, deadline_days=$3, deadline_day_type=$4, deadline_basis=$5, deadline_base_at=$6, deadline_at=$7, locked_at=$8, locked_by=$9, current_version=current_version+1, updated_by=$9, updated_at=$8 WHERE id=$1 RETURNING current_version ) SELECT current_version AS "versionNumber" FROM updated `, [ actId, dto.urgency, deadlineDays, deadlineDayType, deadlineBasis, deadlineBaseAt, deadlineAt, lockedAt, principal.userId, ])) as Array<{ versionNumber: number }>; const preparedSnapshot = await this.buildLockedSnapshot(manager, actId, lockedAt); const preparedSha256 = sha256CanonicalJson(preparedSnapshot); await manager.query(` UPDATE inspection_acts SET locked_sha256=$2 WHERE id=$1 `, [actId, preparedSha256]); 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, final_snapshot=NULL, final_sha256=NULL, device_closed_at=NULL, server_closed_at=NULL, upload_mode=NULL, closed_by=NULL `, [actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, lockedAt, 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.LOCKED, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_LOCKED, entityType: 'inspection_act', entityId: actId, afterData: { status: InspectionActStatus.LOCKED, lockedSha256: preparedSha256, urgency: dto.urgency, deadlineDays, deadlineDayType, deadlineBasis, deadlineBaseAt, deadlineAt, }, metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } /** * F4 elimina la reapertura normal: Finalizar Acta significa inmovilizar su contenido. * Una eventual rectificación administrativa debe ser otro acto auditado, no volver a DRAFT. */ async reopen( _actId: string, _principal: AuthPrincipal, _request: RequestWithContext, ): Promise { throw new ConflictException({ code: 'INSPECTION_ACT_LOCK_IS_IMMUTABLE', message: 'Un acta finalizada es inmutable y no puede volver a borrador', }); } async signInspector( actId: string, dto: CreateInspectionSignatureDto, file: UploadedInspectionSignatureFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.createSignedSignature( actId, dto, file, { signerType: InspectionActSignerType.INSPECTOR, signerUserId: principal.userId, signerName: `${principal.firstName} ${principal.lastName}`.trim(), documentType: null, documentNumber: null, position: 'Inspector/a', consentText: INSPECTOR_CONSENT, }, principal, request, ); } 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); } 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.assertLockedForManifestation(act); 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 identificar al responsable que se negó', }); } if (dto.status === InspectionActSignatureStatus.ABSENT && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE', message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente', }); } if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } const closure = await this.requireClosure(manager, actId); const createdAt = new Date(); const source = this.signatureSource(principal); const signerName = responsible.fullName ?? 'Responsable no presente'; const payload = { actId, lockedSha256: closure.preparedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, documentType: responsible.documentType, documentNumber: responsible.documentNumber, position: responsible.position, status: dto.status, reason: dto.reason, source, recordedBy: 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, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); } /** * Endpoint legacy /close conservado durante la transición de contratos. * En F4 su semántica es SELLAR, no volver editable ni cerrar la Inspección. */ async close( actId: string, dto: CloseInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); let generatedInspectorSignaturePath: string | null = null; let sealed: InspectionClosureView; try { sealed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); let signatures = await this.loadSignatures(manager, actId); const companyOutcomes = signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, ); if (companyOutcomes.length !== 1) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa', }); } const inspectorSigned = signatures.some((item) => item.signerType === InspectionActSignerType.INSPECTOR && item.status === InspectionActSignatureStatus.SIGNED, ); if (!inspectorSigned) { const created = await this.createInspectorSignatureFromProfile( manager, actId, closure.preparedSha256, principal, request, ); generatedInspectorSignaturePath = created.filePath; signatures = await this.loadSignatures(manager, actId); } const serverSealedAt = new Date(); const deviceSealedAt = new Date(dto.clientClosedAt); if (deviceSealedAt.getTime() > serverSealedAt.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, lockedSha256: closure.preparedSha256, lockedSnapshot: closure.preparedSnapshot, signatures: signatureSnapshot, seal: { deviceSealedAt: deviceSealedAt.toISOString(), serverSealedAt: serverSealedAt.toISOString(), uploadMode: dto.uploadMode, sealedBy: principal.userId, sealedByUsername: 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, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); const [updated] = (await manager.query(` WITH updated AS ( UPDATE inspection_acts SET status='SEALED', sealed_at=$2, sealed_by=$3, 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 ) SELECT current_version AS "versionNumber" FROM updated `, [actId, serverSealedAt, 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.SEALED, finalSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_SEALED, entityType: 'inspection_act', entityId: actId, afterData: { status: InspectionActStatus.SEALED, closureSha256: finalSha256, sealedAt: serverSealedAt, }, metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber), findingsRemainIndependent: true, inspectionRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); }); } catch (error) { if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined); throw error; } await this.reports.ensureWordForAct(actId); return sealed; } 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; }, principal: AuthPrincipal, request: RequestWithContext, ): 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); this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); 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 si no se identificó al responsable presente', }); } const identity = fixedIdentity ?? { signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerUserId: null, signerName: responsible!.fullName!, documentType: responsible!.documentType, documentNumber: responsible!.documentNumber, position: responsible!.position, consentText: COMPANY_CONSENT, }; 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 LIMIT 1 `, [actId, identity.signerUserId]) as unknown[]; if (existing.length) { 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 manifestació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, lockedSha256: 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: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } private async buildLockedSnapshot( manager: EntityManager, actId: string, lockedAt: Date, ): Promise> { const [act] = await manager.query(` SELECT JSONB_BUILD_OBJECT( 'id',act.id, 'code',act.code, 'status',act.status, 'occurredAt',act.occurred_at, 'title',act.title, 'summary',act.summary, 'observations',act.observations, 'urgency',act.urgency, 'deadlineDays',act.deadline_days, 'deadlineDayType',act.deadline_day_type, 'deadlineBasis',act.deadline_basis, 'deadlineBaseAt',act.deadline_base_at, 'deadlineAt',act.deadline_at, 'inspection',JSONB_BUILD_OBJECT( 'id',visit.id, 'code',visit.code, 'status',visit.status, 'operationalAreaId',visit.operational_area_id, 'operatorCompanyId',visit.operator_company_id, 'leadInspectorUserId',visit.lead_inspector_user_id, 'actualStartedAt',visit.actual_started_at ) ) AS act FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id WHERE act.id=$1 `, [actId]) as Array<{ act: Record }>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); const inventories = await manager.query(` SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", type.code AS "typeCode",type.name AS "typeName" FROM inspection_act_assets link JOIN assets asset ON asset.id=link.asset_id JOIN asset_types type ON type.id=asset.asset_type_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.severity, finding.is_recurrence AS "isRecurrence", finding.recurrence_of_finding_id AS "recurrenceOfFindingId", finding.correction_due_on AS "correctionDueOn", finding.current_version AS "currentVersion" FROM inspection_findings finding WHERE finding.act_id=$1 AND finding.status<>'VOIDED' ORDER BY finding.finding_number,finding.id `, [actId]) as Array>; return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, responsible, inventories, findings, }; } private async deadlinePolicy(manager: EntityManager): Promise { const [row] = await manager.query(` SELECT urgent_days AS "urgentDays", urgent_day_type AS "urgentDayType", non_urgent_days AS "nonUrgentDays", non_urgent_day_type AS "nonUrgentDayType" FROM inspection_deadline_policies ORDER BY created_at LIMIT 1 `) as DeadlinePolicy[]; return row ?? { urgentDays: 5, urgentDayType: InspectionDeadlineDayType.BUSINESS, nonUrgentDays: 10, nonUrgentDayType: InspectionDeadlineDayType.BUSINESS, }; } private async calculateDeadline( manager: EntityManager, baseAt: Date, days: number, dayType: InspectionDeadlineDayType, ): Promise { if (dayType === InspectionDeadlineDayType.CALENDAR) { const [row] = await manager.query(` SELECT ( (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + time '23:59:59' ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at `, [baseAt, days]) as Array<{ due_at: Date }>; return row.due_at; } const [row] = await manager.query(` WITH candidates AS ( SELECT day::date AS day, COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business FROM generate_series( (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, interval '1 day' ) day LEFT JOIN inspection_business_calendar_days override ON override.date=day::date ), ranked AS ( SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position FROM candidates WHERE business=true ) SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at FROM ranked WHERE position=$2 LIMIT 1 `, [baseAt, days]) as Array<{ due_at: Date }>; if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); return row.due_at; } 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.urgency, act.deadline_days AS "deadlineDays", act.deadline_day_type AS "deadlineDayType", act.deadline_basis AS "deadlineBasis", act.deadline_base_at AS "deadlineBaseAt", act.deadline_at AS "deadlineAt", act.locked_at AS "lockedAt", act.locked_sha256 AS "lockedSha256", act.sealed_at AS "sealedAt", 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 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; urgency: InspectionActUrgency | null; deadlineDays: number | null; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; lockedSha256: string | null; sealedAt: Date | null; 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 representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(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, urgency: context.urgency, deadlineDays: context.deadlineDays, deadlineDayType: context.deadlineDayType, deadlineBasis: context.deadlineBasis, deadlineBaseAt: context.deadlineBaseAt, deadlineAt: context.deadlineAt, lockedAt: context.lockedAt, lockedSha256: context.lockedSha256, sealedAt: context.sealedAt, 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, representativeSuggestion, 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: [InspectionActStatus.LOCKED, InspectionActStatus.SEALED].includes(context.status), } : 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 loadResponsibleSuggestion(manager: EntityManager, actId: string): Promise { const [row] = await manager.query(` SELECT responsible.act_id AS "actId",responsible.attendance_status AS "attendanceStatus", responsible.full_name AS "fullName",responsible.document_type AS "documentType", responsible.document_number AS "documentNumber",responsible.position,responsible.email,responsible.phone, responsible.absence_reason AS "absenceReason",responsible.updated_by AS "updatedBy", responsible.created_at AS "createdAt",responsible.updated_at AS "updatedAt" FROM inspection_acts current_act JOIN inspection_acts previous_act ON previous_act.visit_id=current_act.visit_id AND previous_act.id<>current_act.id JOIN inspection_act_responsibles responsible ON responsible.act_id=previous_act.id WHERE current_act.id=$1 AND responsible.attendance_status='PRESENT' ORDER BY responsible.updated_at DESC,previous_act.created_at DESC LIMIT 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 representante de la empresa o documentar su ausencia antes de cerrar el Acta', }); } 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_LOCKED', message: 'El acta todavía no fue finalizada y bloqueada', }); } 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 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 requireReusableInspectorSignature( manager: EntityManager, userId: string, ): Promise { const [row] = await manager.query(` SELECT original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", image_sha256 AS "imageSha256", image_data AS "imageData" FROM user_signature_profiles WHERE user_id=$1 `, [userId]) as ReusableInspectorSignature[]; if (!row) { throw new ConflictException({ code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED', message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard', }); } return row; } private async createInspectorSignatureFromProfile( manager: EntityManager, actId: string, preparedSha256: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise<{ filePath: string }> { const profile = await this.requireReusableInspectorSignature(manager, principal.userId); const id = randomUUID(); const storedName = `${id}.png`; const filePath = resolve(this.signatureRoot, storedName); await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 }); const signedAt = new Date(); const source = this.signatureSource(principal); const signerName = `${principal.firstName} ${principal.lastName}`.trim(); const payload = { actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR, signerUserId: principal.userId, signerName, position: 'Inspector/a', status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256, consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION, consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(), source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId, }; const signaturePayloadSha256 = sha256CanonicalJson(payload); try { await manager.query(` INSERT INTO inspection_act_signatures ( id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status, 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,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED', $5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12 ) `, [ id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType, profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt, 'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' }, }, manager); return { filePath }; } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } 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 acta sólo puede editarse mientras está en borrador y la inspección está en curso', }); } } private assertLockedForManifestation(act: InspectionAct): void { if (act.status !== InspectionActStatus.LOCKED) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_LOCKED', message: 'El acta debe estar finalizada y bloqueada antes de firmarse o sellarse', }); } } private async assertActorAssigned( manager: EntityManager, inspectionId: 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 `, [inspectionId, principal.userId]) as unknown[]; if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'Sólo un inspector asignado puede realizar esta operación', }); } } 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', }); } } }