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