From a178f64763bde81e1496d303bdad147545c3855b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:38:23 -0300 Subject: [PATCH] F4: implement secure remote company signature workflow --- .../company-signature-invite.service.ts | 624 ++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 api-v3/src/inspection-closing/company-signature-invite.service.ts diff --git a/api-v3/src/inspection-closing/company-signature-invite.service.ts b/api-v3/src/inspection-closing/company-signature-invite.service.ts new file mode 100644 index 0000000..16bc4ab --- /dev/null +++ b/api-v3/src/inspection-closing/company-signature-invite.service.ts @@ -0,0 +1,624 @@ +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())) { + 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 disidencia 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'), + }, + }); + 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 inventories = this.records(locked.inventories); + 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, + }, + inventories: inventories.map((inventory) => ({ + id: inventory.id, + code: inventory.code, + name: inventory.name, + typeName: inventory.typeName ?? inventory.typeCode ?? null, + })), + 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', + }); + } +}