Files
jorgecuadros-platform/apps/api/src/reports/renewal-letter.ts
T
rmancinasandClaude Opus 5 33833c3af9
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s
feat(notificaciones): one send log across servicios and pólizas
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>
2026-08-02 03:01:03 -07:00

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,
};
}