feat(f6.2): add per-act representative signing and user smtp
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
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 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s

This commit is contained in:
2026-09-14 15:53:55 -03:00
parent 103ecf2fae
commit 7fe59bccd2
46 changed files with 907 additions and 154 deletions
@@ -28,10 +28,9 @@ export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
email!: string;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateSelfProfileDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
}
@@ -0,0 +1,65 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { SmtpSecurityMode } from '../../../database/entities';
export enum UserSmtpMode {
SYSTEM = 'SYSTEM',
CUSTOM = 'CUSTOM',
}
const trimmed = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class UpdateUserSmtpSettingsDto {
@IsEnum(UserSmtpMode)
mode!: UserSmtpMode;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@Transform(trimmed)
@IsString()
@MinLength(1)
@MaxLength(255)
host?: string;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsEnum(SmtpSecurityMode)
securityMode?: SmtpSecurityMode;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(512)
password?: string | null;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(200)
fromName?: string | null;
@IsOptional()
@IsBoolean()
enabled = true;
}
@@ -10,23 +10,33 @@ import {
Query,
Req,
} from '@nestjs/common';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
constructor(
private readonly users: UsersService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
@Get()
@RequirePermissions('users.read')
@@ -34,6 +44,73 @@ export class UsersController {
return this.users.list(query);
}
@Get('self/profile')
selfProfile(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfProfile(principal.userId);
}
@Patch('self/profile')
updateSelfProfile(
@Body() dto: UpdateSelfProfileDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
}
@Put('self/smtp')
async updateSelfSmtp(
@Body() dto: UpdateUserSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const before = await this.smtp.publicUserSettings(principal.userId);
const after = await this.smtp.saveUserSettings(principal.userId, dto);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_SETTINGS_UPDATED,
entityType: 'user_smtp_settings',
entityId: principal.userId,
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, scope: 'SELF' },
});
return after;
}
@Post('self/smtp/test')
async testSelfSmtp(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const profile = await this.users.getSelfProfile(principal.userId);
if (!profile.email) throw new Error('El usuario no tiene email configurado');
const sent = await this.smtp.send({
to: profile.email,
subject: 'DH Inspección · Prueba de correo personal',
text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-prueba-correo.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'),
},
}, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_TEST_SENT,
entityType: 'user_smtp_settings',
entityId: principal.userId,
afterData: { recipient: profile.email, messageId: sent.messageId },
metadata: { scope: 'SELF' },
});
return { ok: true, recipient: profile.email, messageId: sent.messageId };
}
@Post()
@RequirePermissions('users.create')
create(
@@ -29,6 +29,7 @@ import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
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';
export interface UserRoleView {
@@ -56,6 +57,7 @@ export interface AdministrativeUserView {
createdAt: Date;
updatedAt: Date;
roles: UserRoleView[];
smtpMode: 'SYSTEM' | 'CUSTOM';
}
interface UserViewRow extends AdministrativeUserView {
@@ -76,10 +78,10 @@ function roleSelectionInvalid(): BadRequestException {
});
}
function inspectorEmailRequired(): BadRequestException {
function userEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
code: 'USER_EMAIL_REQUIRED',
message: 'Cada usuario de Hidrocarburos debe tener un email válido',
});
}
@@ -144,6 +146,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
@@ -185,6 +188,24 @@ export class UsersService {
);
}
async getSelfProfile(userId: string): Promise<AdministrativeUserView> {
return this.getById(userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (!dto.email?.trim()) throw userEmailRequired();
return this.update(
principal.userId,
{ email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle },
principal,
request,
);
}
async create(
dto: CreateUserDto,
principal: AuthPrincipal,
@@ -195,10 +216,10 @@ export class UsersService {
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
this.assertInspectorHasEmail(roles, dto.email ?? null);
if (!dto.email?.trim()) throw userEmailRequired();
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
email: dto.email.trim().toLowerCase(),
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
@@ -268,15 +289,13 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.email !== undefined && !dto.email) throw userEmailRequired();
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
user.email = dto.email!.trim().toLowerCase();
}
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
@@ -286,6 +305,13 @@ export class UsersService {
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
if (dto.email !== undefined && user.email) {
await manager.query(`
UPDATE user_smtp_settings
SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP
WHERE user_id=$1 AND mode='CUSTOM'
`, [id, user.email]);
}
const updated = await this.loadUserView(manager, id);
await this.audit.record(
@@ -414,7 +440,6 @@ export class UsersService {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -453,12 +478,6 @@ export class UsersService {
return roles;
}
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
@@ -511,6 +530,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(