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
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
@@ -9,7 +10,12 @@ import {
Put,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
@@ -19,6 +25,10 @@ import type {
} from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
@@ -58,6 +68,45 @@ export class UsersController {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/signature')
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfSignature(principal.userId);
}
@Get('self/signature/content')
async selfSignatureContent(
@CurrentAuth() principal: AuthPrincipal,
@Res() response: Response,
): Promise<void> {
const signature = await this.users.getSelfSignatureContent(principal.userId);
response.setHeader('Content-Type', 'image/png');
response.setHeader('Content-Length', String(signature.buffer.length));
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('ETag', `"${signature.sha256}"`);
response.setHeader('X-Content-Type-Options', 'nosniff');
response.send(signature.buffer);
}
@Put('self/signature')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
updateSelfSignature(
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfSignature(file, principal, request);
}
@Delete('self/signature')
deleteSelfSignature(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.deleteSelfSignature(principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -31,6 +33,10 @@ import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
export interface UserRoleView {
id: string;
@@ -38,6 +44,14 @@ export interface UserRoleView {
name: string;
}
export interface UserReusableSignatureView {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: Date | null;
}
export interface AdministrativeUserView {
id: string;
username: string;
@@ -192,6 +206,87 @@ export class UsersService {
return this.getById(userId);
}
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
const [row] = (await this.dataSource.query(`
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
}>;
return row ? { configured: true, ...row } : {
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
};
}
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
const [row] = (await this.dataSource.query(`
SELECT image_data AS buffer, image_sha256 AS sha256
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
if (!row) throw new NotFoundException({
code: 'USER_SIGNATURE_NOT_CONFIGURED',
message: 'Todavía no cargaste tu firma de inspector',
});
return row;
}
async updateSelfSignature(
file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
});
const inspected = inspectInspectionSignatureFile(file);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query(`
INSERT INTO user_signature_profiles (
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
) VALUES ($1,$2,$3,$4,$5,$6,$1)
ON CONFLICT (user_id) DO UPDATE SET
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async deleteSelfSignature(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: false },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F67UserReusableSignature1790124600000 implements MigrationInterface {
name = 'F67UserReusableSignature1790124600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE user_signature_profiles (
user_id uuid PRIMARY KEY,
original_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
size_bytes integer NOT NULL,
image_sha256 char(64) NOT NULL,
image_data bytea NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'),
CONSTRAINT chk_user_signature_profiles_size CHECK (
size_bytes BETWEEN 1 AND 1048576
AND OCTET_LENGTH(image_data)=size_bytes
),
CONSTRAINT chk_user_signature_profiles_sha CHECK (
image_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE user_signature_profiles
TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE user_signature_profiles');
}
}
@@ -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();
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-6';
export const API_PHASE = 'F6.2';
export const API_VERSION = '0.29.0-7';
export const API_PHASE = 'F6.7';