Files
dh-inspeccion-v2/api-v3/src/inspection-reports/inspection-document-delivery.service.ts
T

376 lines
16 KiB
TypeScript

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' | '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",updated_at AS "updatedAt"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
return {
...row,
smtpConfigured: await this.smtp.configured(),
mailFrom: await this.smtp.fromAddress() ?? 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,updated_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=1
`, [dto.officeEmail ?? 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
WHERE d.recipient_kind<>'DIRECTOR'
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,
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' AND recipient_kind<>'DIRECTOR'
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"
FROM institutional_delivery_settings WHERE id=1
`) as Array<{ officeEmail: 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:'INSPECTOR',
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
recipientEmail:inspector.email,
});
}
}
}
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<DeliveryRow[]> {
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 AND d.recipient_kind<>'DIRECTOR'
ORDER BY d.created_at
`, [actId]) as Promise<DeliveryRow[]>;
}
private async load(id: string): Promise<DeliveryRow> {
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 AND d.recipient_kind<>'DIRECTOR'
`, [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" FROM institutional_delivery_settings WHERE id=1
`) as Array<{ officeEmail: string | null }>;
email = settings?.officeEmail ?? 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 (!await 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 INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
: row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
});
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)]);
}
}