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
+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
exists yet. Use it for a first-ever deploy only — it is the one switch that
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
> **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
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
> **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.
```
@@ -302,6 +338,12 @@ traced back to the notice that caused it. (`notes` stays free-text for staff.)
### 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
anticipates this: staff who *mail* a paper notice need to record it.
`RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction.
@@ -321,12 +363,19 @@ customer to stop the mail.
### API surface
As built (the `/policies/:id/renewal-notices` mark-as-sent mutation was
dropped — see §1.4):
| Method | Route | Ability |
|---|---|---|
| `POST` | `/policies/:id/renewal-notices` | `renewal:send` |
| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` |
| `POST` | `/renewals/sweep` (manual trigger of the scheduled body; body `{ debug? }`) | `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) |
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)
| Ability | Min role | Notes |
@@ -706,9 +755,8 @@ equivalent is `InsuranceProvider`, which today holds only a name.
## Build sequencing
1. **§1 renewal emails** — highest value, schema already ready, no blocker
beyond the SES sending account. ≈260 mails/month against a 91%-reachable
policyholder base.
1. ~~**§1 renewal emails**~~**DONE 2026-08-01/02.** See §1's BUILT note.
≈260 mails/month against a 91%-reachable policyholder base.
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
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)
**§1 — renewal emails**
**§1 — renewal emails** (feature built; these three are still open)
- 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
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?
**§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
service method + a `POST /notifications/{slug}` HTTP endpoint gated on
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`
— two new tables (`email_notification_log`, `account_status_history`)
with enums and FKs to `customers`.
- `apps/web/src/app/notificaciones/` — admin page with 4 trigger cards,
a flags panel, a transport-status header, and a paginated log browser.
- `apps/web/src/app/notificaciones/` — admin page: a shared flags panel and
schedule editor above the tabs, then per-tab trigger cards, a
transport-status header, and a paginated log browser.
## Job semantics
@@ -82,6 +87,36 @@ reported per-customer, never collapsed (see `BillingService.balances()`).
`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h
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
### `email_notification_log`
@@ -157,11 +192,19 @@ quietly going nowhere.
## UI
`/notificaciones`, two tabs over the one log:
`/notificaciones`, two tabs over the one log.
- **Servicios** (`notification:send`) — four trigger cards, a
debug/ignoreDayRestriction/useEmailLimit flags panel, a transport status
header. Reads the `CUSTOMERS` + `TRUST` slice.
Above the tabs, owned by the shell because both halves are subject to them:
- **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.
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).
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
`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
means adding a typed accessor there, not a generic getter.
Currently one key: `notification.adminEmails` (summary recipients), edited
on the Servicios tab, gated on `setting:manage` (ADMIN — above
`notification:send`, because redirecting the audit summaries is how someone
would stop them being read). Read on every job rather than cached, so an
Keys today:
| Key | Edited on | Notes |
|---|---|---|
| `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
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
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
them.
## Cron (future)
## Scheduled runs
The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`,
`runAccountStatus`, `runTrustConfirmation`) are the entry points. A future
`@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
`OpsService`'s single-running-job guard: one `email_notification_sweep`
OpsJob per run, with its log streamed to `OpsJob.log`.
Both halves run themselves on an **operator-editable** cadence. Nothing
about the schedule is in the source any more:
| Kind | Handler | Default | Was |
|---|---|---|---|
| `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
+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
`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
- Only the ATLAS variants were extracted verbatim; the QUALITAS and