docs: record notificaciones as built, flags global, schedules editable

The docs still described the state before the last five commits: the
insurance spec called for a `@Cron` literal and a manual mark-as-sent
mutation, PLAN.md had step 12 as "NOT STARTED", and README's module and
route lists predated seven modules.

- MASS_EMAIL_NOTIFICATIONS.md: new "Send flags", "API surface" and
  "Scheduled runs" sections; "Cron (future)" removed — it exists. The
  flags table says which flags apply where, and why a debug renewal send
  must skip both the RenewalNotice row and `lastSuccessfulAt`.
- INSURANCE_FEATURES_SPEC.md: §1 BUILT note listing the three places the
  build diverged from the spec; §1.1 and §1.4 marked superseded in place
  rather than deleted, so the reasoning stays readable.
- PLAN.md: step 12 renewal emails DONE with the divergences; status
  paragraph rewritten.
- README.md: current module/route lists, plus a "Scheduled jobs" section —
  a reader cloning this repo had no way to know the API sends mail on a
  timer.
- DEPLOY_AND_MIGRATIONS.md: the cadence lives in app_settings and survives
  an image rollback, and the servicios sweep has no multi-replica lock.
- RESUME.md: session record for the whole notificaciones arc.
- RENEWAL_NOTICES.md: pointer that this is the legacy record, not what
  shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 12:43:19 -07:00
co-authored by Claude Opus 5
parent 89611da202
commit 6331481f82
7 changed files with 348 additions and 30 deletions
+8 -3
View File
@@ -142,8 +142,11 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s). - **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section. Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11: 12. **Insurance features — one of four built, rest spec'd.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
- **Renewal notification emails**a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first. - **Renewal notification emails — DONE** (2026-08-01, extended 08-02). A sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern). The letter body is the *existing* `aviso-renovacion` report; `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**.
**Three things came out differently from the spec.** (a) The manual mark-as-sent mutation was **dropped on purpose** — a button that marks a notice sent without sending anything lets the list claim a customer was told when they were not. `POST /renewals/send` replaced it: sending from the list *is* the marking, and the report's `enviadas` total becomes real the same way. (b) The send history is **not renewal-specific** — every attempt, including the failures and no-email skips a `RenewalNotice` row cannot represent, also writes `email_notification_log` as `RENEWAL_NOTICE`/`POLICIES`, shared with the four bulk jobs from [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md). `RenewalNotice` stays *gating* state; the log is *history*. (c) The `@Cron("0 6 * * *")` literal the spec called for lasted one day: both this sweep and the servicios jobs now take their cadence from `NotificationScheduleService`, stored in `app_settings` and reinstalled on save — no redeploy. Defaults preserve the old behaviour (pólizas 06:00 daily, servicios off).
**Both halves live on one screen.** `/notificaciones` has Servicios and Pólizas tabs over the one log; `/renovaciones` is an alias onto the Pólizas tab. The send flags (`debug` in particular) sit in the shell above the tabs and govern both — before that there was no way to test a renewal aviso without mailing a real customer. A debug send diverts the mail, skips the `RenewalNotice` upsert **and** does not advance the sweep's `lastSuccessfulAt`; all three are needed together, or a test run silently narrows tomorrow's window and drops the letters it only pretended to send.
**Still blocked in production:** the `SES_*` variables are wired through the deploy workflow but unset in Gitea, so a production send fails loudly rather than going out.
- **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike. - **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike.
- **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate. - **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate.
- **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.) - **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.)
@@ -162,7 +165,9 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
**Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown. **Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end. **Step 12 is one-quarter built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. **§1 renewal emails is done** (2026-08-01/02) and carries a BUILT note recording the three places the build diverged from the spec; §2 liquidación is still the smallest remaining piece, since the per-policy fields are already wired end to end.
**Notifications are one screen, not two features.** The four legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`) and the insurance renewal avisos both mean "tell a customer something by email", so they are tabs of `/notificaciones` over one `email_notification_log`, with one shared flags panel and one schedule editor. `app_settings` + `SettingsService` (db → env → default) is the operator-config seam they introduced: summary recipients and both sweep cadences live there, so changing any of them is a save, not a redeploy. Credentials stay in the environment.
## Decisions (locked) ## Decisions (locked)
+29 -2
View File
@@ -40,8 +40,13 @@ docker-compose.yml mysql + api + web
``` ```
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`, API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`, `billing`, `bank`, `reports`, `notifications`, `renewals`, `mail`, `statements`,
`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`. `policy-ocr`, `ocr`, `storage`, `settings`, `ops`.
Web routes: `/inicio`, `/clientes`, `/polizas`, `/servicios`, `/estado-cuenta`,
`/banco` (chequera), `/recibos` (OCR capture), `/notificaciones` (mass email +
renewal avisos; `/renovaciones` is an alias onto its Pólizas tab), `/reportes`,
`/catalogos`, `/operaciones` (DB ingest/backup, ADMIN), `/usuarios`, `/login`.
--- ---
@@ -195,6 +200,28 @@ python migration/run_all.py
--- ---
## Scheduled jobs
The API runs two automatic email sweeps. Neither cadence is in the source:
both are stored in `app_settings` and edited at `/notificaciones`
"Programación de envíos" (ADMIN, `setting:manage`), taking effect immediately
without a restart. Shipped defaults:
| Job | Default | What it does |
| --- | ------- | ------------ |
| Pólizas | **on**, 06:00 daily (America/Tijuana) | Renewal avisos at 30/15 days before expiry and 7 days after. |
| Servicios | **off** | All four mass-email jobs in order, same as "Ejecutar todos". |
A scheduled run never uses the UI's send flags — in particular it ignores
`debug`, so a forgotten test toggle cannot silently stop customer mail. Full
detail in [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md).
Sending needs `SES_*` in the environment. Without it the API still boots and
logs mail to stdout in dev; in production every send fails loudly and is
recorded as `FAILED` rather than quietly going nowhere.
---
## Production notes ## Production notes
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when - Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
+84
View File
@@ -526,3 +526,87 @@ Implementation notes worth keeping:
**Open:** whether the CFE charge should be the rounded barcode/headline figure **Open:** whether the CFE charge should be the rounded barcode/headline figure
(`$268`, what is paid at the window — what the parser uses today) or the exact (`$268`, what is paid at the window — what the parser uses today) or the exact
breakdown total (`$268.88`). One question for Jorge. breakdown total (`$268.88`). One question for Jorge.
## Notificaciones (`/notificaciones`) — DONE 2026-08-01 → 08-02
Two features that were spec'd separately turned out to be one screen. The four
legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`, ported from
`email.notifications/send*.php`) and the insurance renewal avisos
(`docs/INSURANCE_FEATURES_SPEC.md` §1) both mean *tell a customer something by
email*, so they are **tabs of one screen over one log**, not two menu entries.
`/renovaciones` is an alias that lands on the Pólizas tab, the same pattern
Captura uses.
- **Servicios tab** — the four jobs (pagos pendientes, confirmación de pago,
estado de cuenta, fideicomiso), individually or "Ejecutar todos". Ability
`notification:send` (MANAGER); STAFF sees the log read-only.
- **Pólizas tab** — pending avisos at 30/15 days before expiry and 7 days
after, sent one at a time or as a sweep. Ability `renewal:send` (MANAGER).
**One send log for the whole platform.** `email_notification_log` is not
job-specific: renewals write it too (`RENEWAL_NOTICE` / `POLICIES`) through the
same `NotificationLogService`. That is what makes "Registro de envíos" complete
— the failures and no-email skips exist *only* there. `RenewalNotice` was not
made redundant by it: that row is **gating** state (one per policy+generation,
drives the pending list), the log is **history** (every attempt). `level` is
therefore per-type and unreadable without its `notificationType` — 0/1
yellow/red on `ACCOUNT_STATUS`, the aviso generation 1/2/3 on
`RENEWAL_NOTICE`.
**Manual mark-as-sent was dropped on purpose.** The spec called for it; a
button that marks a notice sent without sending anything is a button that lets
the list claim a customer was told when they were not. `POST /renewals/send`
replaced it — sending from the list *is* the marking.
**`app_settings` is the operator-config seam this work introduced.**
`SettingsService` resolves every key **db → env → default** and reports which
rung a value came from, so an existing deployment keeps behaving exactly as it
did until somebody saves in the UI. Three keys today: the summary recipients
(was `NOTIFICATION_ADMIN_EMAILS`, now a fallback) and the two sweep cadences.
Credentials deliberately stay in the environment — SES keys, `DATABASE_URL`
and S3 config are deployment identity, must exist before the app can reach its
own database, and a table only widens who can read them.
**The send flags are global, and that was a real bug fix (08-02).** The
`debug` / `ignoreDayRestriction` / `useEmailLimit` panel lived inside the
Servicios tab, so there was **no way to test a renewal aviso without mailing a
real customer**. It now lives in the shell above the tabs and both halves read
it. On the pólizas path `debug` does three things, and all three are required
together: it diverts the mail, it skips the `RenewalNotice` upsert, and it does
not advance the sweep's `lastSuccessfulAt`. Miss the third and `renewalWindow()`
narrows back to a single day on the next real run — a test send would silently
destroy the letters it only pretended to send. Flags are per-visit UI state and
are **never persisted**; a stored `debug` would survive a reload and swallow
real customer mail until somebody noticed.
**Both cadences are operator-editable (08-02).** The renewal sweep's
`@Cron("0 6 * * *")` literal lasted one day. `NotificationScheduleService` now
owns both: the owning services register a handler in `onModuleInit`, the
service compiles the stored `{hour, minute, weekdays}` to a cron expression and
installs it in `SchedulerRegistry`, and saving from the UI reinstalls the job —
no restart, which was the point. It lives in its own module for the same reason
as `NotificationLogModule`: `NotificationsModule` and `RenewalsModule` both need
it and neither may import the other. Defaults preserve prior behaviour exactly
(pólizas 06:00 daily, servicios **off** — a default that starts mailing 260
customers after a deploy is not a default, it's an incident). A scheduled run
never inherits the UI flags: no `debug`, and no `ignoreDayRestriction`, since an
automatic run on the operator's own cadence is precisely the case the
Mon/Wed/Fri gate was written for.
Implementation notes worth keeping:
- `cron` had to become a **direct dependency of `apps/api`**. It is a
transitive dep of `@nestjs/schedule`, but pnpm's strict layout does not hoist
it, so `import { CronJob } from "cron"` does not resolve without it.
- The pólizas sweep already had a DB lock (`scheduled_job_states`); the
servicios run-all does not, and relies on the deployment being
single-replica, which it is on galactus today.
- Wire shapes of the four jobs are byte-for-byte the legacy PHP responses,
quirks included (Job 1 reports `result`, not `request`).
**Open:** `SES_*` is wired through the deploy workflow but **unset in Gitea**,
so production sends fail loudly rather than going out. The 78 policyholders
with no email are logged as `SKIPPED_NO_EMAIL` but there is still no printable
worklist for them, and the notice body is English-only (`Customer` carries no
language preference) — the same three questions §1 of the insurance spec
opened.
+12
View File
@@ -325,3 +325,15 @@ backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container - `bootstrap: true` lets the pre-migrate backup be skipped when no API container
exists yet. Use it for a first-ever deploy only — it is the one switch that exists yet. Use it for a first-ever deploy only — it is the one switch that
lets a migration run with no restore point. lets a migration run with no restore point.
- **The API container sends mail on a timer.** Two sweeps run inside it
(renewal avisos, on by default at 06:00 America/Tijuana; the servicios
run-all, off by default) — see
[`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md). Two
consequences for deploys: the cadence lives in `app_settings`, so it
**survives a redeploy and is not restored by rolling back an image**, and
running more than one API replica would double-fire the servicios sweep,
which has no DB lock (the pólizas one does). Keep it single-replica.
- `SES_*` is optional to deploy — the preflight only warns — but the production
image sets `NODE_ENV=production`, which disables the stdout dev fallback. A
blank SES config therefore makes every send fail and log `FAILED`. As of
2026-08-02 these secrets are still unset in Gitea.
+60 -8
View File
@@ -199,6 +199,35 @@ recycling backfill that consulted `UTILSEG` would merge unrelated people.
## 1. Renewal notification emails ## 1. Renewal notification emails
> **BUILT — 2026-08-01, extended 2026-08-02.** `apps/api/src/renewals/`
> (sweep, `sendOne`, the `scheduled_job_states` lock) plus
> `apps/api/src/mail/` (SES). Web: the **Pólizas** tab of `/notificaciones`;
> `/renovaciones` is an alias that lands on it. Ability `renewal:send`
> (MANAGER), as specced.
>
> Three things in the sections below were **superseded**, each noted inline:
>
> - **§1.1** — the `@Cron("0 6 * * *")` literal is gone. Both this sweep and
> the servicios jobs take their cadence from `NotificationScheduleService`,
> which stores it in `app_settings` and reinstalls the job on save. The
> default is still 06:00 daily, so behaviour is unchanged until an operator
> edits it. See [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md),
> "Scheduled runs".
> - **§1.4 manual mark-as-sent — dropped, deliberately.** Sending from the
> list is what marks a notice sent; there is no way to claim a letter went
> out when no mail was sent. `RenewalNoticeChannel.MAIL` still exists for a
> future paper path, but nothing writes it.
> - **The send log is not renewal-specific.** Every attempt — including the
> failures and no-email skips a `RenewalNotice` row cannot represent — also
> writes `email_notification_log` as `RENEWAL_NOTICE` / `POLICIES`, shared
> with the four bulk jobs. `RenewalNotice` stays the *gating* state; the log
> is *history*.
>
> Also added 2026-08-02: the platform-wide `debug` flag reaches this path. A
> debug send diverts the mail, skips the `RenewalNotice` upsert **and** does
> not advance `lastSuccessfulAt` — see that doc's "Send flags" for why all
> three are required together.
### What Jorge asked for ### What Jorge asked for
Automatic notice to the customer at **30 days before expiry, 15 days before, Automatic notice to the customer at **30 days before expiry, 15 days before,
@@ -223,6 +252,13 @@ and 7 days after** — replacing the manual monthly run of the legacy
### 1.1 The scheduler ### 1.1 The scheduler
> **Superseded — the cadence is operator-editable, not a literal.**
> `RenewalsService` registers its handler with `NotificationScheduleService`
> in `onModuleInit`; that service compiles the stored
> `{hour, minute, weekdays}` to a cron expression and installs it in
> `SchedulerRegistry`. Default `0 6 * * *` / `America/Tijuana`, i.e. exactly
> what the literal below did. The rest of this section still holds.
Add `@nestjs/schedule`. One `@Cron` job, daily, early morning local time. Add `@nestjs/schedule`. One `@Cron` job, daily, early morning local time.
``` ```
@@ -302,6 +338,12 @@ traced back to the notice that caused it. (`notes` stays free-text for staff.)
### 1.4 Manual mark-as-sent ### 1.4 Manual mark-as-sent
> **Not built, and deliberately so.** A button that marks a notice sent
> without sending anything is a button that lets the list claim a customer
> was told when they were not — the exact failure the log exists to make
> visible. `POST /renewals/send` replaced it: sending *is* the marking.
> Revisit only when a real paper-mail workflow exists to record.
The `aviso-renovacion` doc comment (`reports.registry.ts:617-621`) already The `aviso-renovacion` doc comment (`reports.registry.ts:617-621`) already
anticipates this: staff who *mail* a paper notice need to record it. anticipates this: staff who *mail* a paper notice need to record it.
`RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction. `RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction.
@@ -321,12 +363,19 @@ customer to stop the mail.
### API surface ### API surface
As built (the `/policies/:id/renewal-notices` mark-as-sent mutation was
dropped — see §1.4):
| Method | Route | Ability | | Method | Route | Ability |
|---|---|---| |---|---|---|
| `POST` | `/policies/:id/renewal-notices` | `renewal:send` | | `POST` | `/renewals/sweep` (manual trigger of the scheduled body; body `{ debug? }`) | `renewal:send` |
| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` | | `POST` | `/renewals/send` (one notice; body `{ policyId, generation, debug? }`) | `renewal:send` |
| `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) | | `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) |
The cadence itself is edited through the notifications module
(`GET`/`PUT /notifications/settings/schedule[/:kind]`, `setting:manage`),
because one editor covers both sweeps.
### Abilities (new) ### Abilities (new)
| Ability | Min role | Notes | | Ability | Min role | Notes |
@@ -706,9 +755,8 @@ equivalent is `InsuranceProvider`, which today holds only a name.
## Build sequencing ## Build sequencing
1. **§1 renewal emails** — highest value, schema already ready, no blocker 1. ~~**§1 renewal emails**~~**DONE 2026-08-01/02.** See §1's BUILT note.
beyond the SES sending account. ≈260 mails/month against a 91%-reachable ≈260 mails/month against a 91%-reachable policyholder base.
policyholder base.
2. **§2 liquidación batch** — small, builds on fields already wired. Do the two 2. **§2 liquidación batch** — small, builds on fields already wired. Do the two
defect fixes (missing `policy_types` rows + FK `ON DELETE RESTRICT`) as part defect fixes (missing `policy_types` rows + FK `ON DELETE RESTRICT`) as part
of it, since both distort its own report. of it, since both distort its own report.
@@ -739,11 +787,15 @@ No collision with the abilities proposed in `RECEIPT_CAPTURE_SPEC.md`
## Open questions to take back to Jorge (collected) ## Open questions to take back to Jorge (collected)
**§1 — renewal emails** **§1 — renewal emails** (feature built; these three are still open)
- Which SES region + verified identity/configuration set, and whether to reuse - Which SES region + verified identity/configuration set, and whether to reuse
existing IAM credentials or create a scoped `ses:SendEmail` user. existing IAM credentials or create a scoped `ses:SendEmail` user. **Still
unanswered in production**: the `SES_*` variables are wired through the
deploy workflow but unset in Gitea, so production sends fail loudly.
- The 78 policyholders with no email: skip silently, or produce a print - The 78 policyholders with no email: skip silently, or produce a print
worklist? (Recommend the worklist.) worklist? Currently they are **logged as `SKIPPED_NO_EMAIL`** in
`email_notification_log` — visible in "Registro de envíos", but not yet a
printable worklist. (Recommend the worklist.)
- Spanish or English notice body? - Spanish or English notice body?
**§2 — liquidación** **§2 — liquidación**
+145 -17
View File
@@ -32,11 +32,16 @@ feature ports the four jobs onto the unified data and writes its own log.
`payment-confirm`, `account-status`, `trust-confirm`), each a public `payment-confirm`, `account-status`, `trust-confirm`), each a public
service method + a `POST /notifications/{slug}` HTTP endpoint gated on service method + a `POST /notifications/{slug}` HTTP endpoint gated on
the new `notification:send` ability (MANAGER). the new `notification:send` ability (MANAGER).
- `apps/api/src/notifications/notification-schedule.service.ts` (+ its own
module) — the cadence of **both** automatic sweeps, stored in
`app_settings` and installed into `SchedulerRegistry` at boot. See
"Scheduled runs" below.
- `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql` - `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql`
— two new tables (`email_notification_log`, `account_status_history`) — two new tables (`email_notification_log`, `account_status_history`)
with enums and FKs to `customers`. with enums and FKs to `customers`.
- `apps/web/src/app/notificaciones/` — admin page with 4 trigger cards, - `apps/web/src/app/notificaciones/` — admin page: a shared flags panel and
a flags panel, a transport-status header, and a paginated log browser. schedule editor above the tabs, then per-tab trigger cards, a
transport-status header, and a paginated log browser.
## Job semantics ## Job semantics
@@ -82,6 +87,36 @@ reported per-customer, never collapsed (see `BillingService.balances()`).
`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h `useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h
after 100 sends. Off by default; SES does not need it. after 100 sends. Off by default; SES does not need it.
## Send flags
The three flags are **platform-wide**, not per-tab. They live in the
`/notificaciones` shell above the tabs (`NotificationFlagsCard`), and the
shell passes them to both halves.
| Flag | Applies to | Effect |
|---|---|---|
| `debug` | everything | Rewrites every recipient to `DEBUG_RECIPIENT` (`rmancinas@freakma.net`), tags the log row `debug: true`, sends with `xTracking: "debug"`. |
| `ignoreDayRestriction` | Job 3 only | Bypasses the Wed / Mon-Wed-Fri gates. |
| `useEmailLimit` | Job 3 only | The vestigial throttle above. |
`debug` used to exist only on the servicios side, which meant there was no
way to test a renewal aviso without mailing a real customer. On the pólizas
path it now does three things beyond diverting the mail, all for the same
reason — *the customer was not notified, so nothing may claim they were*:
1. no `RenewalNotice` row is written, so the aviso stays in the pending list;
2. the sweep's `lastSuccessfulAt` is not advanced, because `renewalWindow()`
uses it to widen the window over missed days — advancing it after a test
run would narrow tomorrow's window and drop those candidates for good;
3. the send response carries `debug: true` and the address actually used, so
the UI says "prueba enviada … el cliente no ha recibido nada" rather than
claiming a delivery.
Flags are **per-visit UI state and are never persisted.** A stored `debug`
would survive a reload and silently swallow real customer mail for as long
as nobody noticed. For the same reason the automatic runs below ignore them
entirely and always send for real.
## Tables ## Tables
### `email_notification_log` ### `email_notification_log`
@@ -157,11 +192,19 @@ quietly going nowhere.
## UI ## UI
`/notificaciones`, two tabs over the one log: `/notificaciones`, two tabs over the one log.
- **Servicios** (`notification:send`) — four trigger cards, a Above the tabs, owned by the shell because both halves are subject to them:
debug/ignoreDayRestriction/useEmailLimit flags panel, a transport status
header. Reads the `CUSTOMERS` + `TRUST` slice. - **Flags del envío** — the three flags above.
- **Programación de envíos** — the cadence of both automatic sweeps
(`setting:manage` to edit; everyone can see when the next run is).
Then per tab:
- **Servicios** (`notification:send`) — an "Ejecutar todos" card, four
trigger cards, a transport status header, and the summary-recipients
setting. Reads the `CUSTOMERS` + `TRUST` slice.
- **Pólizas** (`renewal:send`) — pending avisos and the manual sweep. - **Pólizas** (`renewal:send`) — pending avisos and the manual sweep.
Reads the `POLICIES` slice. Reads the `POLICIES` slice.
@@ -169,6 +212,31 @@ Both render the same `NotificationLogPanel` ("Registro de envíos"), which
filters by servicio and by view (todos / enviados / fallidos / omitidos). filters by servicio and by view (todos / enviados / fallidos / omitidos).
STAFF users see the Servicios log read-only. STAFF users see the Servicios log read-only.
Both mass actions ("Ejecutar todos" and the pólizas sweep) confirm before
firing **only when `debug` is off** — that is the case where real customers
receive mail, and a confirm on every click trains people to dismiss it.
## API surface
| Method | Route | Ability |
|---|---|---|
| `POST` | `/notifications/outstanding-payments` | `notification:send` |
| `POST` | `/notifications/payment-confirmation` | `notification:send` |
| `POST` | `/notifications/account-status` | `notification:send` |
| `POST` | `/notifications/trust-payment-confirmation` | `notification:send` |
| `POST` | `/notifications/run-all` | `notification:send` |
| `GET` | `/notifications/log`, `/notifications/stats` | authenticated |
| `GET` | `/notifications/settings/admin-emails` | authenticated |
| `PUT` | `/notifications/settings/admin-emails` | `setting:manage` |
| `GET` | `/notifications/settings/schedule` | authenticated |
| `PUT` | `/notifications/settings/schedule/:kind` | `setting:manage` |
Every trigger accepts the flags as **body or query string** — the PHP
scripts took both (STDIN vs HTTP-CGI) and parity was cheap. The pólizas
endpoints (`POST /renewals/sweep`, `POST /renewals/send`) accept `debug`
only; the other two flags are estado-de-cuenta concepts and are not
accepted there rather than being silently ignored.
## Operator settings ## Operator settings
`app_settings` holds the configuration staff change without a redeploy. `app_settings` holds the configuration staff change without a redeploy.
@@ -176,26 +244,86 @@ STAFF users see the Servicios log read-only.
which of the three a value came from so the UI can say so. Adding a key which of the three a value came from so the UI can say so. Adding a key
means adding a typed accessor there, not a generic getter. means adding a typed accessor there, not a generic getter.
Currently one key: `notification.adminEmails` (summary recipients), edited Keys today:
on the Servicios tab, gated on `setting:manage` (ADMIN — above
`notification:send`, because redirecting the audit summaries is how someone | Key | Edited on | Notes |
would stop them being read). Read on every job rather than cached, so an |---|---|---|
| `notification.adminEmails` | Servicios tab | Summary recipients, comma-separated. |
| `notification.schedule.servicios` | shell | JSON cadence of the automatic run-all. |
| `notification.schedule.polizas` | shell | JSON cadence of the renewal sweep. |
All three are gated on `setting:manage` (ADMIN — above `notification:send`,
because redirecting the audit summaries is how someone would stop them being
read).
`notification.adminEmails` is read on every job rather than cached, so an
edit takes effect on the next sweep with no restart. An empty saved list edit takes effect on the next sweep with no restart. An empty saved list
means "nobody" and deliberately does **not** fall through to the env. means "nobody" and deliberately does **not** fall through to the env.
The two schedule keys have **no env rung** on the db → env → default ladder:
a cadence was never an environment variable (it was a `@Cron` literal in the
source), so the only two sources are the operator's row and the shipped
default. A row that fails to parse is logged and treated as absent — a bad
JSON blob must not take the scheduler down with it.
Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay
in the environment: they are deployment identity, they must exist before in the environment: they are deployment identity, they must exist before
the app can reach its own database, and a table only widens who can read the app can reach its own database, and a table only widens who can read
them. them.
## Cron (future) ## Scheduled runs
The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`, Both halves run themselves on an **operator-editable** cadence. Nothing
`runAccountStatus`, `runTrustConfirmation`) are the entry points. A future about the schedule is in the source any more:
`@nestjs/schedule` cron would call them on the legacy cadence (Job 3 on
Mon/Wed/Fri, Job 2 daily, Jobs 1 + 4 ad-hoc). Pattern matches | Kind | Handler | Default | Was |
`OpsService`'s single-running-job guard: one `email_notification_sweep` |---|---|---|---|
OpsJob per run, with its log streamed to `OpsJob.log`. | `servicios` | `NotificationsService.scheduledRunAll()` → all four jobs in order | **off**, 07:00 Mon/Wed/Fri when enabled | nothing — the four jobs were click-only |
| `polizas` | `RenewalsService.scheduledSweep()` | **on**, 06:00 daily | `@Cron("0 6 * * *")` in `renewals.service.ts` |
The defaults preserve exactly what each half did before: pólizas keeps its
06:00 sweep, servicios stays off. A default that starts mailing 260
customers on its own after a deploy is not a default, it's an incident.
### How it works
`NotificationScheduleService` owns both cadences. The services that own the
sweeps register a handler in `onModuleInit`:
```ts
await this.schedule.register("polizas", () => this.scheduledSweep());
```
The schedule service then compiles the stored value to a cron expression
(`{hour, minute, weekdays}``m h * * dow`, empty weekdays = `*`) and
installs a `CronJob` in `SchedulerRegistry` under
`notification-schedule:<kind>`, in `America/Tijuana`. Saving from the UI
re-reads the row, removes the old job and installs the new one — **no
restart**, which was the whole point.
Handlers are registered rather than injected because
`NotificationsModule` and `RenewalsModule` both need this service and
neither may import the other. It lives in its own
`NotificationScheduleModule` for the same reason as
`NotificationLogModule`.
`cron` is a **direct dependency of `apps/api`**, not just a transitive one
of `@nestjs/schedule`: pnpm's strict layout does not hoist it, so
`import { CronJob } from "cron"` fails to resolve without it.
### What a scheduled run does not do
- **It never uses the UI flags.** No `debug` (so a forgotten test toggle
cannot silently stop customer mail), and no `ignoreDayRestriction` — an
automatic run on the operator's own cadence is exactly the case the
Mon/Wed/Fri gate was written for, so bypassing it would mail the red list
every scheduled day.
- **It does not guard against multi-replica double-fire.** The pólizas sweep
has always had its own DB lock (`scheduled_job_states`, taken in
`RenewalsService.acquireLock`); the servicios run-all has no equivalent
and relies on the deployment being single-replica, which it is today on
galactus. Adding one means the `OpsService` single-running-job pattern —
a DB row, not an in-process flag.
## What is intentionally NOT in scope ## What is intentionally NOT in scope
+10
View File
@@ -126,6 +126,16 @@ from data (one parameterized template), not from report design text. See
`RenewalNotice` in `schema.prisma` and the `aviso-renovacion` entry in `RenewalNotice` in `schema.prisma` and the `aviso-renovacion` entry in
`apps/api/src/reports/reports.registry.ts` for the first cut at this. `apps/api/src/reports/reports.registry.ts` for the first cut at this.
> **Built 2026-08-01/02.** The three generations above are now
> `RenewalNotice.generation` 1/2/3, mailed by `apps/api/src/renewals/` on an
> operator-editable cadence (default 06:00 daily) and driven from the
> **Pólizas** tab of `/notificaciones`. The `CONTROL … X MES` companion
> reports have no equivalent and need none: every attempt — sent, failed, or
> skipped for a missing address — lands in `email_notification_log`. See
> [`MASS_EMAIL_NOTIFICATIONS.md`](MASS_EMAIL_NOTIFICATIONS.md) and
> [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1. This document
> stays a record of the **legacy** report chain, not of what shipped.
## Caveats ## Caveats
- Only the ATLAS variants were extracted verbatim; the QUALITAS and - Only the ATLAS variants were extracted verbatim; the QUALITAS and