import { Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource } 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, AuditSource } from '../database/entities'; import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; export interface DeliveryRow { id: string; actId: string; reportId: string | null; documentKind: 'ACT_PDF' | 'REPORT_WORD'; recipientKind: DeliveryRecipientKind; recipientAssetId: string | null; recipientUserId: 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('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_user_id AS "recipientUserId", 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", CASE WHEN recipient_user.id IS NULL THEN NULL ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name)) END AS "recipientUserName" 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 LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id ORDER BY d.created_at DESC LIMIT 200 `); return { data }; } async dispatchForAct(actId: string): Promise { 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 { 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, recipientUserId: row.recipientUserId, }, }); 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 }>; const [inspector] = await this.dataSource.query(` SELECT inspector.id, inspector.email FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id JOIN users inspector ON inspector.id=visit.lead_inspector_user_id WHERE act.id=$1 `, [actId]) as Array<{ id: string; email: string | null }>; for (const company of companies) { await this.upsertRow({ actId, reportId, documentKind: 'ACT_PDF', recipientKind: 'COMPANY', recipientAssetId: company.id, recipientUserId: null, recipientKey: company.id, recipientEmail: company.email, }); } await this.upsertRow({ actId, reportId, documentKind: 'ACT_PDF', recipientKind: 'OFFICE', recipientAssetId: null, recipientUserId: null, recipientKey: '00000000-0000-0000-0000-000000000000', recipientEmail: settings?.officeEmail ?? null, }); if (inspector) { await this.upsertRow({ actId, reportId, documentKind: 'ACT_PDF', recipientKind: 'INSPECTOR', recipientAssetId: null, recipientUserId: inspector.id, recipientKey: inspector.id, recipientEmail: inspector.email, }); } if (reportId) { await this.upsertRow({ actId, reportId, documentKind: 'REPORT_WORD', recipientKind: 'DIRECTOR', recipientAssetId: null, recipientUserId: null, recipientKey: '00000000-0000-0000-0000-000000000000', recipientEmail: settings?.directorEmail ?? null, }); } } private async upsertRow(input: { actId: string; reportId: string | null; documentKind: 'ACT_PDF' | 'REPORT_WORD'; recipientKind: DeliveryRecipientKind; recipientAssetId: string | null; recipientUserId: string | null; recipientKey: string; recipientEmail: string | null; }) { const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT'; await this.dataSource.query(` INSERT INTO inspection_document_deliveries ( act_id,report_id,document_kind,recipient_kind, recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status ) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9) 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_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id), recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), recipient_email=CASE WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email ELSE EXCLUDED.recipient_email END, status=CASE WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' ELSE inspection_document_deliveries.status END, updated_at=CURRENT_TIMESTAMP `, [ input.actId, input.reportId, input.documentKind, input.recipientKind, input.recipientAssetId, input.recipientUserId, input.recipientKey, input.recipientEmail, initialStatus, ]); } private async rowsForAct(actId: string): Promise { 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_user_id AS "recipientUserId", 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; } private async load(id: string): Promise { 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_user_id AS "recipientUserId", 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 if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { const [inspector] = await this.dataSource.query(` SELECT email FROM users WHERE id=$1 AND is_active=true `, [row.recipientUserId]) as Array<{ email: string | null }>; email = inspector?.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 text = row.documentKind === 'REPORT_WORD' ? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` : row.recipientKind === 'INSPECTOR' ? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` : `Se adjunta el acta cerrada e inmutable ${row.actCode}.`; const sent = await this.smtp.send({ to: row.recipientEmail, subject: `DH Inspección · ${label}`, text, 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, recipientUserId: row.recipientUserId, 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)]); } }