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
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user