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