fix(actas): move closure to reusable inspector signing
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m18s

This commit is contained in:
DH V2
2026-09-15 08:55:43 -03:00
parent a2ec846721
commit da7c1ddb55
26 changed files with 644 additions and 248 deletions
@@ -103,6 +103,14 @@ interface StoredSignature extends SignatureView {
storedName: string | null;
}
interface ReusableInspectorSignature {
originalName: string;
mimeType: 'image/png';
sizeBytes: number;
imageSha256: string;
imageData: Buffer;
}
interface ClosureRecord {
actId: string;
schemaVersion: string;
@@ -306,21 +314,24 @@ export class InspectionClosingService {
const lockedAt = new Date();
const [updated] = (await manager.query(`
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 AS "versionNumber"
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,
@@ -377,7 +388,7 @@ export class InspectionClosingService {
afterData: {
status: InspectionActStatus.LOCKED,
lockedSha256: preparedSha256,
urgency: act.urgency,
urgency: dto.urgency,
deadlineDays,
deadlineDayType,
deadlineBasis,
@@ -461,6 +472,13 @@ export class InspectionClosingService {
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',
@@ -532,22 +550,15 @@ export class InspectionClosingService {
request: RequestWithContext,
): Promise<InspectionClosureView> {
assertMobileInspector(principal);
const sealed = await this.dataSource.transaction(async (manager) => {
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);
const signatures = await this.loadSignatures(manager, actId);
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
throw new ConflictException({
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
message: 'Se requiere la firma del inspector para sellar el acta',
});
}
let signatures = await this.loadSignatures(manager, actId);
const companyOutcomes = signatures.filter((item) =>
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
);
@@ -557,6 +568,17 @@ export class InspectionClosingService {
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);
@@ -619,18 +641,21 @@ export class InspectionClosingService {
WHERE act_id=$1
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
const [updated] = (await manager.query(`
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 AS "versionNumber"
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 (
@@ -664,7 +689,11 @@ export class InspectionClosingService {
}, 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;
}
@@ -1171,6 +1200,82 @@ export class InspectionClosingService {
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
}
private async requireReusableInspectorSignature(
manager: EntityManager,
userId: string,
): Promise<ReusableInspectorSignature> {
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<StoredSignature> {
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
if (!row) throw signatureNotFound();