F4: implement secure remote company signature workflow
This commit is contained in:
@@ -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<string, unknown> | 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<string>('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<string>('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<InviteContext> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
private records(value: unknown): Array<Record<string, unknown>> {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user