chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,17 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import type { DeliveryRow } from './inspection-document-delivery.service';
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
@Controller('document-delivery')
export class DocumentDeliveryController {
constructor(private readonly delivery:InspectionDocumentDeliveryService){}
@Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();}
@Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);}
@Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();}
@Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise<DeliveryRow>{return this.delivery.retry(id,principal,request);}
@Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);}
}
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class ApproveInspectionReportDto {
@IsOptional()
@IsString()
@MaxLength(1000)
note?: string;
}
@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class CreateInspectionReportRevisionDto {
@IsString()
@Length(5, 1000)
changeSummary!: string;
}
@@ -0,0 +1,49 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, IsUUID, Matches, Max, MaxLength, Min } from 'class-validator';
export class ListInspectionReportsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(2000)
@Max(2100)
year?: number;
@IsOptional()
@IsUUID('4')
companyId?: string;
@IsOptional()
@IsUUID('4')
areaId?: string;
@IsOptional()
@IsUUID('4')
inspectorId?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dateFrom?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dateTo?: string;
}
@@ -0,0 +1,9 @@
import { Equals, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
export class SignFinalInspectionReportDto {
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
@Equals(true)
confirmation!: boolean;
}
@@ -0,0 +1,2 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; }
@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto';
function asRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function asArray(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.map(asRecord) : []; }
function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; }
function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; }
function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); }
function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); }
function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; }
function lines(snapshot: Record<string, unknown>): string[] {
const prepared=asRecord(asRecord(snapshot).preparedSnapshot);
const act=asRecord(prepared.act);
const visit=asRecord(act.visit);
const responsible=asRecord(prepared.responsible);
const team=asArray(prepared.team);
const assets=asArray(prepared.assets);
const findings=asArray(prepared.findings);
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
const out:string[]=[
'ACTA DE INSPECCION',
'Documento automatico - diseno institucional pendiente de definicion',
'',
`Acta: ${text(act.code)}`,
`Inspeccion: ${text(visit.code)}`,
`Fecha: ${date(act.occurredAt)}`,
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`,
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`,
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
`Responsable empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`,
'',
'OBJETIVO', ...wrap(text(visit.objective)), '',
'RESUMEN', ...wrap(text(act.summary)), '',
'OBSERVACIONES', ...wrap(text(act.observations)), '',
'ELEMENTOS INSPECCIONADOS',
];
if(!assets.length) out.push('-');
for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`));
out.push('', 'HALLAZGOS');
if(!findings.length) out.push('Sin hallazgos registrados.');
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
return out;
}
function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); }
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
const all=lines(snapshot);
const chunks:Array<string[]>=[];
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56));
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']);
const pageCount=chunks.length;
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2);
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2);
const objects:Buffer[]=[];
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`));
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
chunks.forEach((chunk,index)=>{
const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n');
const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1');
objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`));
objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')])));
});
const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary');
const offsets:number[]=[0]; let pos=header.length;
for(const obj of objects){ offsets.push(pos); pos+=obj.length; }
const xrefOffset=pos;
const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join('');
const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]);
return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') };
}
@@ -0,0 +1,27 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import { ConfigService } from '@nestjs/config';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { buildInspectionActPdf } from './inspection-act-pdf-builder';
@Injectable()
export class InspectionActPdfService {
private readonly root:string;
constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get<string>('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); }
async ensure(actId:string):Promise<void>{
const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record<string,unknown>;status:string|null;storedName:string|null}>;
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'});
if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} }
await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]);
try{
const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600});
await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]);
}catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); }
}
async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; }
private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});}
}
@@ -0,0 +1,40 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditService } from '../audit/audit.service';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditAction, AuditSource } from '../database/entities';
import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
export interface DeliveryRow { id:string; actId:string; reportId:string|null; documentKind:'ACT_PDF'|'REPORT_WORD'; recipientKind:'COMPANY'|'OFFICE'|'DIRECTOR'; recipientAssetId:string|null; recipientEmail:string|null; status:string; attempts:number; actCode:string; reportCode:string|null; }
@Injectable()
export class InspectionDocumentDeliveryService {
constructor(private readonly dataSource:DataSource, private readonly pdf:InspectionActPdfService, private readonly word:InspectionReportWordService, private readonly smtp:SmtpDeliveryService, private readonly audit:AuditService, private readonly config:ConfigService){}
async settings(){ const [row]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail",updated_at AS "updatedAt" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null;updatedAt:Date}>; return {...row,smtpConfigured:this.smtp.configured(),mailFrom:this.config.get<string>('MAIL_FROM')??null}; }
async updateSettings(dto:UpdateDocumentDeliverySettingsDto,principal:AuthPrincipal,request:RequestWithContext){ const before=await this.settings(); await this.dataSource.query(`UPDATE institutional_delivery_settings SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1`,[dto.officeEmail??null,dto.directorEmail??null,principal.userId]); const after=await this.settings(); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,entityType:'institutional_delivery_settings',entityId:'1',beforeData:before,afterData:after}); return after; }
async list(){ const data=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",recipient.name AS "recipientAssetName" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id ORDER BY d.created_at DESC LIMIT 200`); return {data}; }
async dispatchForAct(actId:string):Promise<void>{ await this.pdf.ensure(actId).catch(()=>undefined); const [report]=await this.dataSource.query(`SELECT id FROM inspection_reports WHERE act_id=$1`,[actId]) as Array<{id:string}>; if(report)await this.word.ensure(report.id); await this.ensureRows(actId,report?.id??null); const rows=await this.rowsForAct(actId); for(const row of rows)await this.attempt(row).catch(()=>undefined); }
async retry(id:string,principal:AuthPrincipal,request:RequestWithContext):Promise<DeliveryRow>{ const row=await this.load(id); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,entityType:'inspection_document_delivery',entityId:id,metadata:{actId:row.actId,documentKind:row.documentKind,recipientKind:row.recipientKind}}); await this.refreshRecipient(row); await this.attempt(await this.load(id)); return this.load(id); }
async retryPending(principal:AuthPrincipal,request:RequestWithContext){ const rows=await this.dataSource.query(`SELECT id FROM inspection_document_deliveries WHERE status<>'SENT' ORDER BY created_at ASC LIMIT 100`) as Array<{id:string}>; for(const item of rows)await this.retry(item.id,principal,request).catch(()=>undefined); return {processed:rows.length}; }
private async ensureRows(actId:string,reportId:string|null){ const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; const companies=await this.dataSource.query(`SELECT DISTINCT company.id,profile.notification_email AS email FROM inspection_act_assets link JOIN assets asset ON asset.id=link.asset_id JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id JOIN assets company ON company.id=COALESCE(asset.operator_company_id,CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END) LEFT JOIN organization_profiles profile ON profile.asset_id=company.id WHERE link.act_id=$1 AND link.included=true`,[actId]) as Array<{id:string;email:string|null}>;
for(const company of companies)await this.upsertRow(actId,reportId,'ACT_PDF','COMPANY',company.id,company.email);
await this.upsertRow(actId,reportId,'ACT_PDF','OFFICE',null,settings?.officeEmail??null);
if(reportId)await this.upsertRow(actId,reportId,'REPORT_WORD','DIRECTOR',null,settings?.directorEmail??null);
}
private async upsertRow(actId:string,reportId:string|null,documentKind:string,recipientKind:string,recipientAssetId:string|null,recipientEmail:string|null){ await this.dataSource.query(`INSERT INTO inspection_document_deliveries (act_id,report_id,document_kind,recipient_kind,recipient_asset_id,recipient_key,recipient_email,status) VALUES ($1,$2,$3,$4,$5,COALESCE($5::uuid,'00000000-0000-0000-0000-000000000000'::uuid),$6,$7) ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),recipient_email=COALESCE(inspection_document_deliveries.recipient_email,EXCLUDED.recipient_email),updated_at=CURRENT_TIMESTAMP`,[actId,reportId,documentKind,recipientKind,recipientAssetId,recipientEmail,recipientEmail?'PENDING':'WAITING_RECIPIENT']); }
private async rowsForAct(actId:string){ return this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.act_id=$1 ORDER BY d.created_at`,[actId]) as Promise<DeliveryRow[]>; }
private async load(id:string){ const [row]=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.id=$1`,[id]) as DeliveryRow[]; if(!row)throw new NotFoundException({code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada'}); return row; }
private async refreshRecipient(row:DeliveryRow){ let email:string|null=null; if(row.recipientKind==='COMPANY'&&row.recipientAssetId){ const [company]=await this.dataSource.query(`SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1`,[row.recipientAssetId]) as Array<{email:string|null}>; email=company?.email??null; } else { const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; email=row.recipientKind==='OFFICE'?settings?.officeEmail??null:settings?.directorEmail??null; } await this.dataSource.query(`UPDATE inspection_document_deliveries SET recipient_email=$2,status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status<>'SENT'`,[row.id,email]); }
private async attempt(row:DeliveryRow){ if(row.status==='SENT')return; if(!row.recipientEmail){await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');return;} if(!this.smtp.configured()){await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');return;} let attachment:{filename:string;mimeType:string;content:Buffer}; try{ if(row.documentKind==='ACT_PDF'){await this.pdf.ensure(row.actId); const file=await this.pdf.content(row.actId); attachment={filename:file.originalName,mimeType:file.mimeType,content:file.buffer};}else{if(!row.reportId)throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file=await this.word.content(row.reportId); const {readFile}=await import('node:fs/promises'); attachment={filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath)};}}catch(error){await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error?error.message:'Documento no disponible');return;}
await this.dataSource.query(`UPDATE inspection_document_deliveries SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id]); try{ const label=row.documentKind==='ACT_PDF'?`Acta ${row.actCode}`:`Informe ${row.reportCode??''}`; const sent=await this.smtp.send({to:row.recipientEmail,subject:`DH Inspección · ${label}`,text:row.documentKind==='ACT_PDF'?`Se adjunta el acta cerrada e inmutable ${row.actCode}.`:`Se adjunta el informe Word automático ${row.reportCode??''} para revisión del Director de Hidrocarburos.`,attachment}); await this.dataSource.query(`UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id,sent.messageId]); await this.audit.record({action:AuditAction.DOCUMENT_DELIVERY_SENT,entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,afterData:{recipientKind:row.recipientKind,documentKind:row.documentKind,status:'SENT'},metadata:{actId:row.actId,reportId:row.reportId}}); }catch(error){await this.setStatus(row.id,'FAILED',error instanceof Error?error.message:'Error de entrega');}
}
private async setStatus(id:string,status:string,error:string){await this.dataSource.query(`UPDATE inspection_document_deliveries SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[id,status,error.slice(0,500)]);}
}
@@ -0,0 +1,87 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
MAX_INSPECTION_REPORT_REVISION_BYTES,
type UploadedInspectionReportRevisionFile,
} from './inspection-report-revision-file';
import {
InspectionReportReviewService,
type InspectionReportReviewView,
} from './inspection-report-review.service';
@Controller('inspection-reports/:reportId/review')
export class InspectionReportReviewController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise<InspectionReportReviewView> {
return this.review.get(reportId);
}
@Post('revisions')
@RequirePermissions('inspection_reports.revise')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } }))
revision(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportRevisionDto,
@UploadedFile() file: UploadedInspectionReportRevisionFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.createRevision(reportId, dto, file, principal, request);
}
@Post('approve')
@RequirePermissions('inspection_reports.review')
approve(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: ApproveInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.approve(reportId, dto, principal, request);
}
@Post('sign-final')
@RequirePermissions('inspection_reports.sign_final')
signFinal(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: SignFinalInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.signFinal(reportId, dto, principal, request);
}
}
@Controller('inspection-report-revisions')
export class InspectionReportRevisionContentController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get(':revisionId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.review.revisionContent(revisionId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -0,0 +1,433 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
INSPECTION_REPORT_WORD_MIME,
inspectInspectionReportRevisionUpload,
type UploadedInspectionReportRevisionFile,
validateInspectionReportRevisionContainer,
} from './inspection-report-revision-file';
import { InspectionReportWordService } from './inspection-report-word.service';
export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD';
export interface InspectionReportReviewPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
export interface InspectionReportRevisionView {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
changeSummary: string | null;
createdAt: Date;
createdBy: InspectionReportReviewPerson;
}
export interface InspectionReportSignatureView {
id: string;
revisionId: string;
signedAt: Date;
confirmationText: string;
signatureSha256: string;
signedBy: InspectionReportReviewPerson;
}
export interface InspectionReportReviewView {
reportId: string;
reportCode: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
approvedBy: InspectionReportReviewPerson | null;
signature: InspectionReportSignatureView | null;
revisions: InspectionReportRevisionView[];
}
interface LockedReport {
id: string;
code: string;
status: string;
frozenSha256: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
}
interface StoredRevisionRow {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
storedName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
}
const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.';
@Injectable()
export class InspectionReportReviewService {
private readonly revisionRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly word: InspectionReportWordService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path');
this.revisionRoot = resolve(configured);
if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root');
}
async get(reportId: string): Promise<InspectionReportReviewView> {
await this.word.ensure(reportId);
await this.ensureAutomaticRevision(reportId);
return this.loadView(reportId);
}
async createRevision(
reportId: string,
dto: CreateInspectionReportRevisionDto,
file: UploadedInspectionReportRevisionFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
inspectInspectionReportRevisionUpload(file);
await this.assertDirector(principal.userId, this.dataSource.manager);
await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 });
const storedName = `${reportId}-${randomUUID()}.docx`;
const filePath = resolve(this.revisionRoot, storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
await validateInspectionReportRevisionContainer(filePath);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const created = await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const nextRevision = report.currentRevisionNumber + 1;
const [row] = await manager.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by
) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9)
RETURNING id
`, [
report.id,
nextRevision,
this.cleanOriginalName(file!.originalname),
storedName,
INSPECTION_REPORT_WORD_MIME,
file!.buffer.length,
sha256,
dto.changeSummary.trim(),
principal.userId,
]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, nextRevision]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_REVISION_ADDED,
entityType: 'inspection_report_revision',
entityId: row.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionNumber: nextRevision,
sha256,
source: 'DIRECTOR_UPLOAD',
},
}, manager);
return row.id;
});
if (!created) throw this.storageError();
} catch (error) {
await rm(filePath, { force: true }).catch(() => undefined);
throw error;
}
return this.loadView(reportId);
}
async approve(
reportId: string,
dto: ApproveInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE report_id = $1 AND revision_number = $2
`, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' });
const approvedAt = new Date();
await manager.query(`
UPDATE inspection_reports
SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3,
approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_APPROVED,
entityType: 'inspection_report',
entityId: report.id,
afterData: {
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
approvedAt: approvedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async signFinal(
reportId: string,
dto: SignFinalInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' });
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' });
if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) {
throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' });
}
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE id = $1 AND report_id = $2
`, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' });
const signedAt = new Date();
const payload = {
schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1',
reportId: report.id,
reportCode: report.code,
reportFrozenSha256: report.frozenSha256,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signedBy: principal.userId,
signedByUsername: principal.username,
signedAt: signedAt.toISOString(),
confirmationText: FINAL_CONFIRMATION_TEXT,
};
const signatureSha256 = sha256CanonicalJson(payload);
const [signature] = await manager.query(`
INSERT INTO inspection_report_signatures (
report_id, revision_id, signed_by, signed_at, confirmation_text,
signature_payload, signature_sha256
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id
`, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, signedAt]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_SIGNED,
entityType: 'inspection_report_signature',
entityId: signature.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signatureSha256,
signedAt: signedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [revision] = await this.dataSource.query(`
SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source,
original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType",
size_bytes::integer AS "sizeBytes", sha256
FROM inspection_report_revisions WHERE id = $1
`, [revisionId]) as StoredRevisionRow[];
if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' });
if (revision.source === 'AUTO') return this.word.content(revision.reportId);
const filePath = resolve(this.revisionRoot, revision.storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== revision.sha256) throw this.storageError();
return { filePath, originalName: revision.originalName, mimeType: revision.mimeType };
}
private async ensureAutomaticRevision(reportId: string): Promise<void> {
await this.dataSource.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
)
SELECT
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
COALESCE(word_generated_at, generated_at)
FROM inspection_reports
WHERE id = $1
AND word_status = 'READY'
AND word_original_name IS NOT NULL
AND word_stored_name IS NOT NULL
AND word_mime_type = $2
AND word_size_bytes > 0
AND word_sha256 ~ '^[0-9a-f]{64}$'
ON CONFLICT (report_id, revision_number) DO NOTHING
`, [reportId, INSPECTION_REPORT_WORD_MIME]);
await this.dataSource.query(`
UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1)
`, [reportId]);
}
private async loadView(reportId: string): Promise<InspectionReportReviewView> {
const [report] = await this.dataSource.query(`
SELECT
report.id AS "reportId",
report.code AS "reportCode",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_revision_id AS "approvedRevisionId",
report.approved_at AS "approvedAt",
report.review_note AS "reviewNote",
CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name
) END AS "approvedBy"
FROM inspection_reports report
LEFT JOIN users approver ON approver.id = report.approved_by
WHERE report.id = $1
`, [reportId]) as Array<Omit<InspectionReportReviewView, 'signature' | 'revisions'>>;
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
const revisions = await this.dataSource.query(`
SELECT
revision.id,
revision.report_id AS "reportId",
revision.revision_number AS "revisionNumber",
revision.source,
revision.original_name AS "originalName",
revision.mime_type AS "mimeType",
revision.size_bytes::integer AS "sizeBytes",
revision.sha256,
revision.change_summary AS "changeSummary",
revision.created_at AS "createdAt",
JSONB_BUILD_OBJECT(
'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name
) AS "createdBy"
FROM inspection_report_revisions revision
JOIN users creator ON creator.id = revision.created_by
WHERE revision.report_id = $1
ORDER BY revision.revision_number DESC
`, [reportId]) as InspectionReportRevisionView[];
const [signature] = await this.dataSource.query(`
SELECT
signature.id,
signature.revision_id AS "revisionId",
signature.signed_at AS "signedAt",
signature.confirmation_text AS "confirmationText",
signature.signature_sha256 AS "signatureSha256",
JSONB_BUILD_OBJECT(
'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name
) AS "signedBy"
FROM inspection_report_signatures signature
JOIN users signer ON signer.id = signature.signed_by
WHERE signature.report_id = $1
`, [reportId]) as InspectionReportSignatureView[];
return { ...report, signature: signature ?? null, revisions };
}
private async lockReport(manager: EntityManager, reportId: string): Promise<LockedReport> {
const [report] = await manager.query(`
SELECT
id, code, status, frozen_sha256 AS "frozenSha256",
review_status AS "reviewStatus",
current_revision_number AS "currentRevisionNumber",
approved_revision_id AS "approvedRevisionId",
approved_at AS "approvedAt",
review_note AS "reviewNote"
FROM inspection_reports
WHERE id = $1
FOR UPDATE
`, [reportId]) as LockedReport[];
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' });
return report;
}
private assertOpenForReview(report: LockedReport): void {
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' });
if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' });
}
private async assertDirector(userId: string, manager: EntityManager): Promise<void> {
const [role] = await manager.query(`
SELECT role.code
FROM user_roles membership
JOIN roles role ON role.id = membership.role_id
WHERE membership.user_id = $1 AND role.code = 'director'
LIMIT 1
`, [userId]) as Array<{ code: string }>;
if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' });
}
private cleanOriginalName(value: string): string {
const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim();
return (cleaned || 'informe-corregido.docx').slice(0, 255);
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' });
}
}
@@ -0,0 +1,38 @@
import { BadRequestException } from '@nestjs/common';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export const MAX_INSPECTION_REPORT_REVISION_BYTES = 15 * 1024 * 1024;
export const INSPECTION_REPORT_WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
export interface UploadedInspectionReportRevisionFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
function invalidFile(message: string): BadRequestException {
return new BadRequestException({ code: 'INSPECTION_REPORT_REVISION_INVALID_FILE', message });
}
export function inspectInspectionReportRevisionUpload(file: UploadedInspectionReportRevisionFile | undefined): void {
if (!file?.buffer?.length) throw invalidFile('Debés adjuntar un archivo Word .docx');
if (file.size <= 0 || file.size > MAX_INSPECTION_REPORT_REVISION_BYTES) throw invalidFile('El Word corregido debe pesar hasta 15 MB');
if (!file.originalname.toLowerCase().endsWith('.docx')) throw invalidFile('La versión corregida debe ser un archivo .docx');
if (file.mimetype && file.mimetype !== INSPECTION_REPORT_WORD_MIME && file.mimetype !== 'application/octet-stream') throw invalidFile('El tipo de archivo no corresponde a un Word .docx');
if (file.buffer.length < 4 || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b || file.buffer[2] !== 0x03 || file.buffer[3] !== 0x04) throw invalidFile('El archivo no tiene una estructura DOCX válida');
}
export async function validateInspectionReportRevisionContainer(filePath: string): Promise<void> {
let stdout = '';
try {
({ stdout } = await execFileAsync('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
} catch {
throw invalidFile('No se pudo validar la estructura interna del Word');
}
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
if (!entries.includes('[Content_Types].xml') || !entries.includes('word/document.xml')) throw invalidFile('El archivo no contiene la estructura mínima de un documento Word');
if (entries.some((entry) => /(^|\/)vbaProject\.bin$/i.test(entry) || /(^|\/)embeddings\//i.test(entry))) throw invalidFile('No se permiten macros ni objetos embebidos en las versiones del informe');
}
@@ -0,0 +1,214 @@
import { createHash } from 'node:crypto';
interface ZipEntry {
name: string;
data: Buffer;
}
interface ReportWordInput {
code: string;
title: string;
generatedAt: Date;
frozenSha256: string;
frozenSnapshot: Record<string, unknown>;
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let value = n;
for (let k = 0; k < 8; k += 1) {
value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[n] = value >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function xmlEscape(value: unknown): string {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(asRecord) : [];
}
function text(value: unknown, fallback = '—'): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
function isoDate(value: unknown): string {
const date = new Date(String(value ?? ''));
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
}
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
const styleXml = style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : '';
return `<w:p>${styleXml}<w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
}
function labelValue(label: string, value: string): string {
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
}
function table(headers: string[], rows: string[][]): string {
const cell = (value: string, bold = false) => `<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr><w:p><w:r>${bold ? '<w:rPr><w:b/></w:rPr>' : ''}<w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p></w:tc>`;
const header = `<w:tr>${headers.map((item) => cell(item, true)).join('')}</w:tr>`;
const body = rows.map((row) => `<w:tr>${row.map((item) => cell(item)).join('')}</w:tr>`).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
}
function documentXml(input: ReportWordInput): string {
const report = asRecord(input.frozenSnapshot.report);
const actClosure = asRecord(input.frozenSnapshot.actClosure);
const prepared = asRecord(actClosure.preparedSnapshot);
const act = asRecord(prepared.act);
const visit = asRecord(act.visit);
const responsible = asRecord(prepared.responsible);
const team = asArray(prepared.team);
const assets = asArray(prepared.assets);
const findings = asArray(prepared.findings);
const signatures = asArray(actClosure.signatures);
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean);
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]);
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
const body = [
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'),
labelValue('Informe', input.code),
labelValue('Título', input.title),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
labelValue('Acta', text(report.actCode ?? act.code)),
labelValue('Inspección', text(report.visitCode ?? visit.code)),
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
labelValue('Responsable de empresa', text(responsible.fullName)),
labelValue('Cargo', text(responsible.position)),
paragraph('Datos de la visita', 'Heading1'),
labelValue('Objetivo', text(visit.objective)),
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
labelValue('Resumen', text(act.summary)),
labelValue('Observaciones', text(act.observations)),
paragraph('Elementos inspeccionados', 'Heading1'),
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
paragraph('Hallazgos', 'Heading1'),
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
paragraph('Firmas y constancias', 'Heading1'),
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
paragraph('Integridad documental', 'Heading1'),
labelValue('Hash del informe', input.frozenSha256),
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'),
].join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
}
function buildZip(entries: ZipEntry[]): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
const dosDate = 33;
const dosTime = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name, 'utf8');
const crc = crc32(entry.data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(0, 8);
local.writeUInt16LE(dosTime, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(entry.data.length, 18);
local.writeUInt32LE(entry.data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, name, entry.data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(0, 10);
central.writeUInt16LE(dosTime, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(entry.data.length, 20);
central.writeUInt32LE(entry.data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, name);
offset += local.length + name.length + entry.data.length;
}
const centralData = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralData.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralData, end]);
}
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
const entries: ZipEntry[] = [
{
name: '[Content_Types].xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
},
{
name: '_rels/.rels',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>', 'utf8'),
},
{
name: 'word/_rels/document.xml.rels',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'),
},
{
name: 'word/styles.xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'),
},
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
{
name: 'docProps/core.xml',
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
},
{
name: 'docProps/app.xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>DH Inspección</Application></Properties>', 'utf8'),
},
];
const buffer = buildZip(entries);
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
}
@@ -0,0 +1,128 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { buildInspectionReportWord } from './inspection-report-word-builder';
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
interface WordRow {
id: string;
code: string;
title: string;
generatedAt: Date;
frozenSha256: string;
frozenSnapshot: Record<string, unknown>;
wordStatus: 'PENDING' | 'READY' | 'FAILED';
wordOriginalName: string | null;
wordStoredName: string | null;
wordMimeType: string | null;
wordSizeBytes: number | null;
wordSha256: string | null;
generatedBy: string;
}
@Injectable()
export class InspectionReportWordService {
private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
}
async ensure(reportId: string): Promise<void> {
const row = await this.load(reportId);
if (row.wordStatus === 'READY' && row.wordStoredName) {
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {}
}
try {
const built = buildInspectionReportWord({
code: row.code,
title: row.title,
generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256,
frozenSnapshot: row.frozenSnapshot,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}.docx`;
const originalName = `${row.code}.docx`;
const filePath = resolve(this.root, storedName);
await writeFile(filePath, built.buffer, { mode: 0o600 });
await this.dataSource.query(`
UPDATE inspection_reports
SET word_status = 'READY',
word_original_name = $2,
word_stored_name = $3,
word_mime_type = $4,
word_size_bytes = $5,
word_sha256 = $6,
word_generated_at = CURRENT_TIMESTAMP,
word_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
await this.ensureInitialRevision(await this.load(row.id));
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
await this.dataSource.query(`
UPDATE inspection_reports
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id, message]).catch(() => undefined);
}
}
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const row = await this.load(reportId);
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) {
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' });
}
const filePath = resolve(this.root, row.wordStoredName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.wordSizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.wordSha256) throw this.storageError();
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME };
}
private async ensureInitialRevision(row: WordRow): Promise<void> {
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return;
await this.dataSource.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP))
ON CONFLICT (report_id, revision_number) DO NOTHING
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
await this.dataSource.query(`
UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [row.id]);
}
private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(`
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus",
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256", generated_by AS "generatedBy"
FROM inspection_reports WHERE id = $1
`, [reportId]) as WordRow[];
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
return row;
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' });
}
}
@@ -0,0 +1,65 @@
import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { InspectionReportsService } from './inspection-reports.service';
import { InspectionReportWordService } from './inspection-report-word.service';
@Controller('inspection-reports')
export class InspectionReportsController {
constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {}
@Get()
@RequirePermissions('inspection_reports.read')
list(@Query() query: ListInspectionReportsQueryDto) {
return this.reports.list(query);
}
@Get('pending')
@RequirePermissions('inspection_reports.read')
pending(@Query() query: ListInspectionReportsQueryDto) {
return this.reports.listPending(query);
}
@Post(':id/word')
@RequirePermissions('inspection_reports.generate')
async generateWord(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
await this.word.ensure(id);
return this.reports.get(id);
}
@Get(':id/word')
@RequirePermissions('inspection_reports.read')
async wordContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Res() response: Response,
) {
const content = await this.word.content(id);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
return response.sendFile(content.filePath);
}
@Get(':id')
@RequirePermissions('inspection_reports.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.reports.get(id);
}
}
@Controller('inspection-acts/:actId/report')
export class InspectionActReportController {
constructor(private readonly reports: InspectionReportsService) {}
@Post()
@RequirePermissions('inspection_reports.generate')
generate(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.reports.generate(actId, principal, request);
}
}
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
import { InspectionReportReviewService } from './inspection-report-review.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({
imports: [AuditModule],
controllers: [
InspectionReportsController,
InspectionActReportController,
DocumentDeliveryController,
InspectionReportReviewController,
InspectionReportRevisionContentController,
],
providers: [
InspectionReportsService,
InspectionReportWordService,
InspectionActPdfService,
InspectionDocumentDeliveryService,
SmtpDeliveryService,
InspectionReportReviewService,
],
exports: [InspectionReportsService],
})
export class InspectionReportsModule {}
@@ -0,0 +1,572 @@
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
DocumentSequenceType,
InspectionActStatus,
InspectionReportPdfStatus,
InspectionReportStatus,
} from '../database/entities';
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1';
interface ContextAsset {
id: string;
code: string;
name: string;
}
export interface InspectionReportListItem {
id: string;
visitId: string;
actId: string;
reportYear: number;
reportNumber: number;
code: string;
status: InspectionReportStatus;
pdfStatus: InspectionReportPdfStatus;
wordStatus: 'PENDING' | 'READY' | 'FAILED';
wordGeneratedAt: Date | null;
reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
currentRevisionNumber: number;
approvedAt: Date | null;
signedAt: Date | null;
title: string;
actVersion: number;
actClosureSha256: string;
frozenSha256: string;
generatedAt: Date;
generatedBy: { id: string; username: string; firstName: string; lastName: string };
act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null };
visit: { id: string; code: string; title: string; status: string };
companies: ContextAsset[];
areas: ContextAsset[];
findingCount: number;
}
export interface InspectionReportView extends InspectionReportListItem {
frozenSnapshot: Record<string, unknown>;
}
export interface PendingInspectionReportItem {
actId: string;
visitId: string;
actCode: string;
actTitle: string;
actYear: number;
occurredAt: Date;
closedAt: Date;
closureSha256: string;
visitCode: string;
visitTitle: string;
companies: ContextAsset[];
areas: ContextAsset[];
findingCount: number;
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
function actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
@Injectable()
export class InspectionReportsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly word: InspectionReportWordService,
private readonly delivery: InspectionDocumentDeliveryService,
) {}
async list(query: ListInspectionReportsQueryDto) {
const { where, parameters, add } = this.reportFilters(query);
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = (await this.dataSource.query(
`${this.reportSelect(where)}
ORDER BY report.report_year DESC, report.report_number DESC
LIMIT ${limit} OFFSET ${offset}`,
parameters,
)) as InspectionReportListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async listPending(query: ListInspectionReportsQueryDto) {
const conditions = [
`act.status = 'CLOSED'`,
'report.id IS NULL',
'act.closure_sha256 IS NOT NULL',
];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`);
}
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId)));
if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId)));
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const base = `FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
LEFT JOIN inspection_reports report ON report.act_id = act.id`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total ${base} ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = (await this.dataSource.query(`
SELECT
act.id AS "actId",
visit.id AS "visitId",
act.code AS "actCode",
act.title AS "actTitle",
act.act_year AS "actYear",
act.occurred_at AS "occurredAt",
act.closed_at AS "closedAt",
act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode",
visit.title AS "visitTitle",
COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount"
${base}
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
) finding_count ON true
${where}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}
`, parameters)) as PendingInspectionReportItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async get(id: string): Promise<InspectionReportView> {
const [report] = (await this.dataSource.query(
`${this.reportSelect('WHERE report.id = $1')}`,
[id],
)) as InspectionReportListItem[];
if (!report) throw reportNotFound();
const [snapshot] = (await this.dataSource.query(
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
}
async generate(
actId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportView> {
assertMobileInspector(principal);
const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request));
await this.word.ensure(report.id);
return this.get(report.id);
}
async ensureFrozenReport(
manager: EntityManager,
actId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportView> {
const [act] = (await manager.query(`
SELECT
act.id,
act.visit_id AS "visitId",
act.act_year AS "actYear",
act.code,
act.title,
act.status,
act.current_version AS "currentVersion",
act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode",
visit.title AS "visitTitle"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act
`, [actId])) as Array<{
id: string;
visitId: string;
actYear: number;
code: string;
title: string;
status: InspectionActStatus;
currentVersion: number;
closureSha256: string | null;
visitCode: string;
visitTitle: string;
}>;
if (!act) throw actNotFound();
if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_NOT_CLOSED',
message: 'El informe sólo puede emitirse después del cierre definitivo del acta',
});
}
await this.assertActorAssigned(manager, act.visitId, principal.userId);
const [existing] = (await manager.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
)) as Array<{ id: string }>;
if (existing) return this.getWithManager(manager, existing.id);
const [closure] = (await manager.query(`
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
FROM inspection_act_closures
WHERE act_id = $1
`, [actId])) as Array<{
finalSnapshot: Record<string, unknown> | null;
finalSha256: string | null;
}>;
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN',
message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe',
});
}
const reportNumber = await this.allocateNumber(manager, act.actYear);
if (reportNumber > 999999) {
throw new ConflictException({
code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de informes agotó su rango disponible',
});
}
const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`;
const generatedAt = new Date();
const title = `Informe técnico · ${act.title}`.slice(0, 220);
const frozenSnapshot = {
schemaVersion: REPORT_SCHEMA_VERSION,
report: {
code,
reportYear: act.actYear,
reportNumber,
generatedAt: generatedAt.toISOString(),
generatedBy: principal.userId,
generatedByUsername: principal.username,
visitId: act.visitId,
visitCode: act.visitCode,
visitTitle: act.visitTitle,
actId: act.id,
actCode: act.code,
actVersion: act.currentVersion,
actClosureSha256: act.closureSha256,
},
actClosure: closure.finalSnapshot,
};
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
const [created] = (await manager.query(`
INSERT INTO inspection_reports (
visit_id, act_id, report_year, report_number, code, status, pdf_status,
title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot,
generated_at, generated_by
) VALUES (
$1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12
)
RETURNING id
`, [
act.visitId,
act.id,
act.actYear,
reportNumber,
code,
title,
act.currentVersion,
act.closureSha256,
frozenSha256,
frozenSnapshot,
generatedAt,
principal.userId,
])) as Array<{ id: string }>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_GENERATED,
entityType: 'inspection_report',
entityId: created.id,
afterData: {
code,
actId: act.id,
visitId: act.visitId,
actVersion: act.currentVersion,
frozenSha256,
pdfStatus: InspectionReportPdfStatus.PENDING,
},
}, manager);
return this.getWithManager(manager, created.id);
}
async ensureWordForAct(actId: string): Promise<void> {
const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>;
if (row) await this.word.ensure(row.id);
await this.delivery.dispatchForAct(actId).catch(() => undefined);
}
private reportFilters(query: ListInspectionReportsQueryDto) {
const conditions = ['1 = 1'];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
report.code ILIKE ${search}
OR report.title ILIKE ${search}
OR act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`);
}
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId)));
if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId)));
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
return { where: `WHERE ${conditions.join(' AND ')}`, parameters, add };
}
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
return `EXISTS (
SELECT 1
FROM inspection_act_assets context_filter_link
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
WHERE context_filter_link.act_id = act.id
AND context_filter_link.included = true
AND context_filter_asset.${column} = ${parameter}::uuid
)`;
}
private contextSelect(): string {
return `
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
`;
}
private reportSelect(where: string): string {
return `
SELECT
report.id,
report.visit_id AS "visitId",
report.act_id AS "actId",
report.report_year AS "reportYear",
report.report_number AS "reportNumber",
report.code,
report.status,
report.pdf_status AS "pdfStatus",
report.word_status AS "wordStatus",
report.word_generated_at AS "wordGeneratedAt",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_at AS "approvedAt",
report.signed_at AS "signedAt",
report.title,
report.act_version AS "actVersion",
report.act_closure_sha256 AS "actClosureSha256",
report.frozen_sha256 AS "frozenSha256",
report.generated_at AS "generatedAt",
JSONB_BUILD_OBJECT(
'id', generator.id,
'username', generator.username,
'firstName', generator.first_name,
'lastName', generator.last_name
) AS "generatedBy",
JSONB_BUILD_OBJECT(
'id', act.id,
'code', act.code,
'title', act.title,
'status', act.status,
'occurredAt', act.occurred_at,
'closedAt', act.closed_at
) AS act,
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'status', visit.status
) AS visit,
COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
INNER JOIN users generator ON generator.id = report.generated_by
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
) finding_count ON true
${where}
`;
}
private async getWithManager(manager: EntityManager, id: string): Promise<InspectionReportView> {
const [report] = (await manager.query(
`${this.reportSelect('WHERE report.id = $1')}`,
[id],
)) as InspectionReportListItem[];
if (!report) throw reportNotFound();
const [snapshot] = (await manager.query(
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
}
private async allocateNumber(manager: EntityManager, year: number): Promise<number> {
const [row] = (await manager.query(`
INSERT INTO document_annual_sequences (document_type, year, last_number)
VALUES ($1, $2, 1)
ON CONFLICT (document_type, year) DO UPDATE SET
last_number = document_annual_sequences.last_number + 1,
updated_at = CURRENT_TIMESTAMP
RETURNING last_number AS number
`, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>;
return Number(row.number);
}
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
const rows = (await manager.query(`
SELECT 1
FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
LIMIT 1
`, [visitId, userId])) as unknown[];
if (rows.length === 0) {
throw new ForbiddenException({
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
message: 'Sólo un inspector asignado a la visita puede solicitar el informe',
});
}
}
}
@@ -0,0 +1,37 @@
import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
interface Reply { code:number; text:string; }
class SmtpSession {
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); }
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; }
replaceSocket(socket:Socket|TLSSocket){ this.socket=socket; this.buffer=''; this.attach(socket); }
private onData(chunk:string){ this.buffer+=chunk; const lines=this.buffer.split(/\r?\n/); this.buffer=lines.pop()??''; for(const line of lines){ if(!line)continue; this.replyLines.push(line); if(/^\d{3} /.test(line)){ const code=Number(line.slice(0,3)); const reply={code,text:this.replyLines.join('\n')}; this.replyLines=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } }
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(reply)}); });}
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; }
write(data:string){this.socket.write(data);}
end(){this.socket.end();}
current(){return this.socket;}
}
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); }
@Injectable()
export class SmtpDeliveryService {
constructor(private readonly config:ConfigService){}
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } }
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; }
}