Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
4.1 KiB
TypeScript
142 lines
4.1 KiB
TypeScript
import { Prisma } from "@jorgecuadros/database";
|
|
|
|
export function renewalLetterSelect(generation: number) {
|
|
return Prisma.validator<Prisma.PolicySelect>()({
|
|
id: true,
|
|
policyNumber: true,
|
|
policyTo: true,
|
|
netPremium: true,
|
|
policyFee: true,
|
|
total: true,
|
|
currency: true,
|
|
coveragesJson: true,
|
|
customer: {
|
|
select: {
|
|
// Needed by the notification log's customerId FK, not by the letter.
|
|
id: true,
|
|
name: true,
|
|
nameMissing: true,
|
|
email: true,
|
|
phone: true,
|
|
mobile: true,
|
|
addressLine1: true,
|
|
addressLine2: true,
|
|
city: true,
|
|
state: true,
|
|
zipCode: true,
|
|
country: true,
|
|
},
|
|
},
|
|
policyType: { select: { name: true } },
|
|
insuranceProvider: { select: { name: true } },
|
|
vehicles: {
|
|
take: 1,
|
|
select: {
|
|
make: true,
|
|
model: true,
|
|
modelYear: true,
|
|
bodyType: true,
|
|
engineNumber: true,
|
|
licensePlate: true,
|
|
},
|
|
},
|
|
renewalNotices: {
|
|
where: { generation },
|
|
select: { sentAt: true, channel: true },
|
|
},
|
|
});
|
|
}
|
|
|
|
export type RenewalLetterPolicy = Prisma.PolicyGetPayload<{
|
|
select: ReturnType<typeof renewalLetterSelect>;
|
|
}>;
|
|
|
|
export interface RenewalLetterRow extends Record<string, unknown> {
|
|
__kind: "letter";
|
|
policyId: string;
|
|
policyNumber: string;
|
|
policyType: string;
|
|
customerName: string;
|
|
customerEmail: string | null;
|
|
customerPhone: string | null;
|
|
customerMobile: string | null;
|
|
customerAddress: string[];
|
|
provider: string;
|
|
policyTo: string;
|
|
netPremium: string | null;
|
|
policyFee: string | null;
|
|
total: string | null;
|
|
currency: string;
|
|
coverageDays: unknown;
|
|
cslLimit: unknown;
|
|
medicalCoverage: unknown;
|
|
propertyDamage: unknown;
|
|
perPersonLiability: unknown;
|
|
additionalService: unknown;
|
|
vehicle: {
|
|
make: string | null;
|
|
model: string | null;
|
|
modelYear: string | null;
|
|
bodyType: string | null;
|
|
engineNumber: string | null;
|
|
licensePlate: string | null;
|
|
} | null;
|
|
generation: number;
|
|
sentAt: string | null;
|
|
}
|
|
|
|
export function toRenewalLetterRow(
|
|
policy: RenewalLetterPolicy,
|
|
generation: number,
|
|
): RenewalLetterRow {
|
|
const notice = policy.renewalNotices[0];
|
|
const coverage = (policy.coveragesJson ?? {}) as Record<string, unknown>;
|
|
const address = [
|
|
policy.customer.addressLine1,
|
|
policy.customer.addressLine2,
|
|
[policy.customer.city, policy.customer.state, policy.customer.zipCode]
|
|
.filter(Boolean)
|
|
.join(", "),
|
|
policy.customer.country,
|
|
].filter((part): part is string => Boolean(part));
|
|
|
|
return {
|
|
__kind: "letter",
|
|
policyId: policy.id,
|
|
policyNumber: policy.policyNumber,
|
|
policyType: policy.policyType?.name ?? "—",
|
|
customerName: policy.customer.nameMissing ? "(sin nombre)" : policy.customer.name,
|
|
customerEmail: policy.customer.email,
|
|
customerPhone: policy.customer.phone,
|
|
customerMobile: policy.customer.mobile,
|
|
customerAddress: address,
|
|
provider: policy.insuranceProvider?.name ?? "—",
|
|
policyTo: policy.policyTo ? policy.policyTo.toISOString().slice(0, 10) : "—",
|
|
netPremium: policy.netPremium ? policy.netPremium.toFixed(2) : null,
|
|
policyFee: policy.policyFee ? policy.policyFee.toFixed(2) : null,
|
|
total: policy.total ? policy.total.toFixed(2) : null,
|
|
currency: policy.currency,
|
|
coverageDays: coverage.cobertura ?? null,
|
|
cslLimit: coverage.csl_limite ?? null,
|
|
medicalCoverage: coverage.gastos_medico ?? null,
|
|
propertyDamage: coverage.propiedades ?? null,
|
|
perPersonLiability: coverage.personas ?? null,
|
|
additionalService:
|
|
coverage.servicio_adicional ?? coverage.servicio_adiconal ?? null,
|
|
vehicle: policy.vehicles[0]
|
|
? {
|
|
make: policy.vehicles[0].make,
|
|
model: policy.vehicles[0].model,
|
|
modelYear: policy.vehicles[0].modelYear,
|
|
bodyType: policy.vehicles[0].bodyType,
|
|
engineNumber: policy.vehicles[0].engineNumber,
|
|
licensePlate: policy.vehicles[0].licensePlate,
|
|
}
|
|
: null,
|
|
generation,
|
|
sentAt: notice?.sentAt
|
|
? notice.sentAt.toISOString().slice(0, 10)
|
|
: null,
|
|
};
|
|
}
|