625 lines
23 KiB
TypeScript
625 lines
23 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}
|