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,