import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { ConflictException, GoneException, 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 { AuditSource, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation, } from '../database/entities'; import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service'; import { sha256CanonicalJson } from './canonical-json'; import type { CreateCompanySignatureInviteDto, PublicCompanyRefusalDto, PublicCompanySignatureDto, } from './dto/company-signature-invite.dto'; import { inspectInspectionSignatureFile, type UploadedInspectionSignatureFile, } from './inspection-signature-file'; const REMOTE_CONSENT_VERSION = 'F4-1'; const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.'; interface InviteContext { id: string; actId: string; tokenSha256: string; recipientEmail: string; recipientName: string | null; recipientDocumentType: string | null; recipientDocumentNumber: string | null; recipientPosition: string | null; status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED'; expiresAt: Date; sentAt: Date | null; usedAt: Date | null; createdBy: string; actCode: string; actStatus: string; lockedSha256: string | null; lockedAt: Date | null; inspectionCode: string; lockedSnapshot: Record | null; } @Injectable() export class CompanySignatureInviteService { private readonly signatureRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly smtp: SmtpDeliveryService, private readonly 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 create( actId: string, dto: CreateCompanySignatureInviteDto, principal: AuthPrincipal, request: RequestWithContext, ) { const token = randomBytes(32).toString('base64url'); const tokenSha256 = this.hashToken(token); const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000); const created = await this.dataSource.transaction(async (manager) => { await this.assertActorAssigned(manager, actId, principal); const [context] = await manager.query(` SELECT act.id, act.code, act.status, act.locked_sha256 AS "lockedSha256", responsible.full_name AS "fullName", responsible.document_type AS "documentType", responsible.document_number AS "documentNumber", responsible.position, responsible.email AS "responsibleEmail", visit.code AS "inspectionCode", ( SELECT profile.notification_email FROM inspection_act_assets link JOIN assets inventory ON inventory.id=link.asset_id JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id JOIN assets company ON company.id=COALESCE( inventory.operator_company_id, CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END ) LEFT JOIN organization_profiles profile ON profile.asset_id=company.id WHERE link.act_id=act.id AND link.included=true AND profile.notification_email IS NOT NULL ORDER BY company.id LIMIT 1 ) AS "companyEmail" FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id WHERE act.id=$1 FOR UPDATE OF act `, [actId]) as Array<{ id: string; code: string; status: string; lockedSha256: string | null; fullName: string | null; documentType: string | null; documentNumber: string | null; position: string | null; responsibleEmail: string | null; inspectionCode: string; companyEmail: string | null; }>; if (!context) { throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' }); } if (context.status !== 'LOCKED' || !context.lockedSha256) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED', message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA', }); } if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', message: 'La manifestación de la empresa ya fue registrada', }); } const recipientEmail = dto.recipientEmail ?? context.responsibleEmail?.toLowerCase() ?? context.companyEmail?.toLowerCase() ?? null; if (!recipientEmail) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED', message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario', }); } await manager.query(` UPDATE inspection_act_company_signature_invites SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1 AND status='PENDING' `, [actId, principal.userId]); const [invite] = await manager.query(` INSERT INTO inspection_act_company_signature_invites( act_id,token_sha256,recipient_email,recipient_name,recipient_document_type, recipient_document_number,recipient_position,expires_at,created_by ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail", expires_at AS "expiresAt",created_at AS "createdAt" `, [ actId, tokenSha256, recipientEmail, context.fullName, context.documentType, context.documentNumber, context.position, expiresAt, principal.userId, ]) as Array<{ id: string; actId: string; recipientEmail: string; expiresAt: Date; createdAt: Date; }>; await this.audit.record({ ...administrationAuditContext(principal, request), action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED', entityType: 'inspection_act_company_signature_invite', entityId: invite.id, afterData: { actId, actCode: context.code, recipientEmail, expiresAt, }, metadata: { tokenStoredAsHashOnly: true }, }, manager); return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode }; }); const publicBase = this.config.get('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null; const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null; let emailSent = false; let deliveryError: string | null = null; if (!publicUrl) { deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; } else if (!(await this.smtp.configured(principal.userId))) { deliveryError = 'SMTP no configurado'; } else { const body = [ `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, `Inspección: ${created.inspectionCode}.`, '', 'El enlace permite firmar en conformidad, firmar en disconformidad o registrar una negativa a firmar.', 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', '', `Enlace seguro: ${publicUrl}`, `Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`, ].join('\n'); try { await this.smtp.send({ to: created.recipientEmail, subject: `DH Inspección · Firma de ${created.actCode}`, text: body, attachment: { filename: `${created.actCode}-instrucciones.txt`, mimeType: 'text/plain', content: Buffer.from(body, 'utf8'), }, }, principal.userId); emailSent = true; await this.dataSource.query(` UPDATE inspection_act_company_signature_invites SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [created.id]); } catch (error) { deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email'; } } return { id: created.id, actId: created.actId, actCode: created.actCode, recipientEmail: created.recipientEmail, expiresAt: created.expiresAt, emailSent, deliveryError, publicUrl, }; } async view(token: string) { const invite = await this.resolveToken(this.dataSource.manager, token, false); const locked = invite.lockedSnapshot ?? {}; const act = this.record(locked.act); const findings = this.records(locked.findings).map((finding) => ({ id: finding.id, code: finding.code, title: finding.title, description: finding.description, legalBasis: finding.legalBasis ?? null, severity: finding.severity ?? null, isRecurrence: finding.isRecurrence === true, recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null, })); return { invitation: { id: invite.id, recipientEmail: invite.recipientEmail, expiresAt: invite.expiresAt, }, act: { code: invite.actCode, inspectionCode: invite.inspectionCode, lockedAt: invite.lockedAt, lockedSha256: invite.lockedSha256, urgency: act.urgency ?? null, summary: act.summary ?? null, observations: act.observations ?? null, }, responsibleDefaults: { fullName: invite.recipientName, documentType: invite.recipientDocumentType, documentNumber: invite.recipientDocumentNumber, position: invite.recipientPosition, }, findings, consent: REMOTE_COMPANY_CONSENT, allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'], }; } async sign( token: string, dto: PublicCompanySignatureDto, file: UploadedInspectionSignatureFile | undefined, request: RequestWithContext, ) { if (!dto.consentAccepted) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED', message: 'Debe aceptarse la constancia antes de firmar', }); } 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 { const result = await this.dataSource.transaction(async (manager) => { const invite = await this.resolveToken(manager, token, true); await this.assertNoCompanyOutcome(manager, invite.actId); const signedAt = new Date(); const manifestation = dto.manifestation === 'DISSENT' ? InspectionCompanySignatureManifestation.DISSENT : InspectionCompanySignatureManifestation.CONFORMITY; const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT ? dto.statement?.trim() ?? null : null; const payload = { invitationId: invite.id, actId: invite.actId, lockedSha256: invite.lockedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName: dto.fullName, documentType: dto.documentType, documentNumber: dto.documentNumber, position: dto.position, status: InspectionActSignatureStatus.SIGNED, companyManifestation: manifestation, companyStatement: statement, imageSha256, consentText: REMOTE_COMPANY_CONSENT, consentVersion: REMOTE_CONSENT_VERSION, signedAt: signedAt.toISOString(), source: InspectionActSignatureSource.WEB, }; 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, signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at ) VALUES( $1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13, $14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16 ) `, [ id, invite.actId, dto.fullName, dto.documentType, dto.documentNumber, dto.position, manifestation, statement, inspected.originalName, storedName, inspected.mimeType, file!.buffer.length, imageSha256, REMOTE_COMPANY_CONSENT, REMOTE_CONSENT_VERSION, signedAt, invite.lockedSha256, signaturePayloadSha256, invite.createdBy, ]); await manager.query(` UPDATE inspection_act_company_signature_invites SET status='USED',used_at=$2,updated_at=$2 WHERE id=$1 `, [invite.id, signedAt]); await this.audit.record({ action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED', entityType: 'inspection_act_signature', entityId: id, source: AuditSource.WEB, actorUserId: null, actorUsername: null, requestId: request.requestId, ip: request.ip ?? null, userAgent: request.get('user-agent') ?? null, afterData: payload, metadata: { invitationId: invite.id, recipientEmail: invite.recipientEmail, immutable: true, signaturePayloadSha256, }, }, manager); return { actCode: invite.actCode, signatureId: id, manifestation }; }); return { ok: true, ...result }; } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } async refuse( token: string, dto: PublicCompanyRefusalDto, request: RequestWithContext, ) { return this.dataSource.transaction(async (manager) => { const invite = await this.resolveToken(manager, token, true); await this.assertNoCompanyOutcome(manager, invite.actId); const createdAt = new Date(); const id = randomUUID(); const payload = { invitationId: invite.id, actId: invite.actId, lockedSha256: invite.lockedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName: dto.fullName, documentType: dto.documentType, documentNumber: dto.documentNumber, position: dto.position, status: InspectionActSignatureStatus.REFUSED, reason: dto.reason, source: InspectionActSignatureSource.WEB, createdAt: createdAt.toISOString(), }; 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,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at ) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11) `, [ id, invite.actId, dto.fullName, dto.documentType, dto.documentNumber, dto.position, dto.reason, invite.lockedSha256, signaturePayloadSha256, invite.createdBy, createdAt, ]); await manager.query(` UPDATE inspection_act_company_signature_invites SET status='USED',used_at=$2,updated_at=$2 WHERE id=$1 `, [invite.id, createdAt]); await this.audit.record({ action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED', entityType: 'inspection_act_signature', entityId: id, source: AuditSource.WEB, actorUserId: null, actorUsername: null, requestId: request.requestId, ip: request.ip ?? null, userAgent: request.get('user-agent') ?? null, afterData: payload, metadata: { invitationId: invite.id, recipientEmail: invite.recipientEmail, immutable: true, signaturePayloadSha256, }, }, manager); return { ok: true, actCode: invite.actCode, refusalId: id }; }); } private async resolveToken( manager: EntityManager, token: string, lock: boolean, ): Promise { if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite(); const tokenSha256 = this.hashToken(token); const [invite] = await manager.query(` SELECT invite.id, invite.act_id AS "actId", invite.token_sha256 AS "tokenSha256", invite.recipient_email AS "recipientEmail", invite.recipient_name AS "recipientName", invite.recipient_document_type AS "recipientDocumentType", invite.recipient_document_number AS "recipientDocumentNumber", invite.recipient_position AS "recipientPosition", invite.status, invite.expires_at AS "expiresAt", invite.sent_at AS "sentAt", invite.used_at AS "usedAt", invite.created_by AS "createdBy", act.code AS "actCode", act.status AS "actStatus", act.locked_sha256 AS "lockedSha256", act.locked_at AS "lockedAt", visit.code AS "inspectionCode", closure.prepared_snapshot AS "lockedSnapshot" FROM inspection_act_company_signature_invites invite JOIN inspection_acts act ON act.id=invite.act_id JOIN inspection_visits visit ON visit.id=act.visit_id JOIN inspection_act_closures closure ON closure.act_id=act.id WHERE invite.token_sha256=$1 ${lock ? 'FOR UPDATE OF invite,act' : ''} `, [tokenSha256]) as InviteContext[]; if (!invite) throw this.invalidInvite(); if (invite.status !== 'PENDING') { throw new GoneException({ code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE', message: invite.status === 'USED' ? 'Este enlace ya fue utilizado' : 'Este enlace ya no está vigente', }); } if (new Date(invite.expiresAt).getTime() <= Date.now()) { await manager.query(` UPDATE inspection_act_company_signature_invites SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status='PENDING' `, [invite.id]); throw new GoneException({ code: 'COMPANY_SIGNATURE_INVITE_EXPIRED', message: 'El enlace de firma venció. Solicitá una nueva invitación.', }); } if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE', message: 'El Acta ya no está disponible para manifestación remota', }); } return invite; } private async assertActorAssigned( manager: EntityManager, actId: string, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const [row] = await manager.query(` SELECT 1 FROM inspection_acts act JOIN inspection_visit_members member ON member.visit_id=act.visit_id WHERE act.id=$1 AND member.user_id=$2 AND member.included=true LIMIT 1 `, [actId, principal.userId]) as unknown[]; if (!row) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED', message: 'Sólo un Inspector asignado puede emitir la invitación', }); } } private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise { if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', message: 'La manifestación de la empresa ya fue registrada', }); } } 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 hashToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } private invalidInvite(): NotFoundException { return new NotFoundException({ code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND', message: 'El enlace de firma no es válido', }); } private record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } private records(value: unknown): Array> { return Array.isArray(value) ? value.map((item) => this.record(item)) : []; } private storageError(): InternalServerErrorException { return new InternalServerErrorException({ code: 'COMPANY_SIGNATURE_STORAGE_ERROR', message: 'No se pudo almacenar la firma remota', }); } }