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
@@ -99,8 +99,10 @@ function lines(snapshot: Record<string, unknown>): string[] {
`Fecha: ${date(act.occurredAt)}`,
`Urgencia: ${urgencyLabel(act.urgency)}`,
`Plazo: ${deadlineText}`,
`Responsable empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`,
`Representante de la empresa: ${text(responsible.fullName)}`,
`DNI: ${text(responsible.documentNumber)}`,
`Cargo / funcion: ${text(responsible.position)}`,
`Email: ${text(responsible.email)}`,
'',
'RESUMEN',
...wrap(text(act.summary)),
@@ -136,7 +138,7 @@ function lines(snapshot: Record<string, unknown>): string[] {
out.push('Manifestacion de empresa: pendiente.');
} else if (text(companySignature.status, '') === 'SIGNED') {
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
@@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService {
email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(`
SELECT email FROM users WHERE id=$1 AND is_active=true
SELECT email FROM users WHERE id=$1 AND status='ACTIVE'
`, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
} else {
@@ -305,7 +305,14 @@ export class InspectionDocumentDeliveryService {
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
return;
}
if (!await this.smtp.configured()) {
const [sender] = await this.dataSource.query(`
SELECT visit.lead_inspector_user_id AS "userId"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE act.id=$1
`, [row.actId]) as Array<{ userId: string | null }>;
const senderUserId = sender?.userId ?? undefined;
if (!await this.smtp.configured(senderUserId)) {
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
return;
}
@@ -348,7 +355,7 @@ export class InspectionDocumentDeliveryService {
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
});
}, senderUserId);
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
@@ -147,8 +147,10 @@ function documentXml(input: ReportWordInput): string {
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
labelValue('Resumen del Acta', text(snapshot.act.summary)),
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
labelValue('Cargo', text(snapshot.responsible.position)),
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
labelValue('DNI', text(snapshot.responsible.documentNumber)),
labelValue('Cargo / función', text(snapshot.responsible.position)),
labelValue('Email', text(snapshot.responsible.email)),
paragraph('Inventario inspeccionado', 'Heading1'),
inventoryRows.length
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
@@ -11,7 +11,7 @@ interface MailInput { to:string; subject:string; text:string; attachment:MailAtt
interface Reply { code:number; text:string; }
export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT';
source: 'USER' | 'DATABASE' | 'ENVIRONMENT';
host: string;
port: number;
securityMode: SmtpSecurityMode;
@@ -47,10 +47,10 @@ export class SmtpDeliveryService {
private readonly config:ConfigService,
){}
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());}
async configured(userId?:string):Promise<boolean>{return Boolean(await this.resolveSettings(userId));}
async fromAddress():Promise<string|null>{
const settings=await this.resolveSettings();
async fromAddress(userId?:string):Promise<string|null>{
const settings=await this.resolveSettings(userId);
if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
}
@@ -73,6 +73,56 @@ export class SmtpDeliveryService {
}:{source:'NONE',enabled:false};
}
async publicUserSettings(userId:string){
const [user]=await this.dataSource.query(`
SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1
`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user)throw new Error('Usuario no encontrado');
const [row]=await this.dataSource.query(`
SELECT mode,host,port,security_mode AS "securityMode",username,
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
from_email AS "fromEmail",enabled,updated_at AS "updatedAt"
FROM user_smtp_settings WHERE user_id=$1
`,[userId]) as Array<Record<string,unknown>>;
return {
mode:row?.mode??'SYSTEM',email:user.email,generalConfigured:Boolean(await this.resolveSystemSettings()),
custom:row?{
host:row.host??'',port:row.port??587,securityMode:row.securityMode??'STARTTLS',
username:row.username??'',hasPassword:Boolean(row.hasPassword),
fromName:row.fromName??`${user.firstName} ${user.lastName}`,fromEmail:row.fromEmail??user.email,
enabled:row.enabled!==false,updatedAt:row.updatedAt??null,
}:null,
};
}
async saveUserSettings(userId:string,input:{
mode:'SYSTEM'|'CUSTOM';host?:string;port?:number;securityMode?:SmtpSecurityMode;
username?:string|null;password?:string|null;fromName?:string|null;enabled?:boolean;
}){
const [user]=await this.dataSource.query(`SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user?.email)throw new Error('El usuario debe tener un email configurado');
const [existing]=await this.dataSource.query(`SELECT password_enc AS "passwordEnc" FROM user_smtp_settings WHERE user_id=$1`,[userId]) as Array<{passwordEnc:string|null}>;
if(input.mode==='SYSTEM'){
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,updated_by) VALUES($1,'SYSTEM',$1)
ON CONFLICT(user_id) DO UPDATE SET mode='SYSTEM',updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId]);
return this.publicUserSettings(userId);
}
if(!input.host||!input.port||!input.securityMode)throw new Error('La configuración SMTP propia está incompleta');
const passwordEnc=input.password===undefined?existing?.passwordEnc??null:input.password?this.encryptSecret(input.password):null;
const fromName=input.fromName?.trim()||`${user.firstName} ${user.lastName}`.trim();
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
VALUES($1,'CUSTOM',$2,$3,$4,$5,$6,$7,$8,$8,$9,$1)
ON CONFLICT(user_id) DO UPDATE SET mode='CUSTOM',host=EXCLUDED.host,port=EXCLUDED.port,
security_mode=EXCLUDED.security_mode,username=EXCLUDED.username,password_enc=EXCLUDED.password_enc,
from_name=EXCLUDED.from_name,from_email=EXCLUDED.from_email,reply_to=EXCLUDED.reply_to,
enabled=EXCLUDED.enabled,updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId,input.host,input.port,input.securityMode,input.username?.trim()||null,passwordEnc,fromName,user.email,input.enabled!==false]);
return this.publicUserSettings(userId);
}
async saveSettings(input:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
@@ -96,8 +146,8 @@ export class SmtpDeliveryService {
return this.publicSettings();
}
async send(input:MailInput):Promise<{messageId:string}>{
const settings=await this.resolveSettings();
async send(input:MailInput,userId?:string):Promise<{messageId:string}>{
const settings=await this.resolveSettings(userId);
if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode,
@@ -137,7 +187,33 @@ export class SmtpDeliveryService {
return {messageId:match?.[1]??randomUUID()};
}
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{
private async resolveSettings(userId?:string):Promise<EffectiveSmtpSettings|null>{
if(userId){
const [row]=await this.dataSource.query(`
SELECT settings.mode,settings.host,settings.port,settings.security_mode AS "securityMode",
settings.username,settings.password_enc AS "passwordEnc",settings.from_name AS "fromName",
settings.enabled,user_account.email AS "userEmail"
FROM users user_account
LEFT JOIN user_smtp_settings settings ON settings.user_id=user_account.id
WHERE user_account.id=$1
`,[userId]) as Array<{
mode:'SYSTEM'|'CUSTOM'|null;host:string|null;port:number|null;securityMode:SmtpSecurityMode|null;
username:string|null;passwordEnc:string|null;fromName:string|null;enabled:boolean|null;userEmail:string;
}>;
if(row?.mode==='CUSTOM'&&row.enabled!==false&&row.host&&row.port&&row.securityMode&&row.userEmail){
return {
source:'USER',host:row.host,port:Number(row.port),securityMode:row.securityMode,
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
fromName:row.fromName,fromEmail:row.userEmail,replyTo:row.userEmail,
};
}
const general=await this.resolveSystemSettings();
return general&&row?.userEmail?{...general,replyTo:row.userEmail}:general;
}
return this.resolveSystemSettings();
}
private async resolveSystemSettings():Promise<EffectiveSmtpSettings|null>{
const [row]=await this.dataSource.query(`
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled