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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user