chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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()}; }
|
||||
}
|
||||
Reference in New Issue
Block a user