Phase 7: notify and send via Apprise (Email + WhatsApp)

The morning briefs go out through the house Apprise API on `.35:8210`: one
brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service
names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an
address, a phone number or an SMTP password (AGENTS.md §10 rule 3).

- internal/apprise: keyed and stateless client, tag filtering, and error
  redaction so nothing sensitive reaches a log, a row or a screen.
- internal/brief: builds the per-boy and the combined brief, delivers on email
  and WhatsApp, records every attempt in `notifications` (migration 004 adds
  scope, channel, tag, ok and error) and fails loudly.
- Send panel (§5 screen 4): recipients, channels, an exact preview, Send now,
  plus the Send action on a stored draft (FR-10). The dashboard shows a
  Morning briefs banner and the Sends list.
- POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule
  calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring
  without messaging the family.
- scripts/morning-brief.sh: scan both children, then send. It is the n8n job;
  deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily).
- FR-12: a failed scan is never "nothing due" -- the brief says so, and the run
  exits non-zero. A failed delivery is recorded with no sent_at.
- The long no-due-date tail is capped in the sent brief so it stays brief
  (§10 rule 6); overdue work is never capped.

Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and
sent_at, and the endpoint answers 401/200/207 as documented. Evidence in
docs/phase-7-notify-notes.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:28:01 +11:00
parent da57efad73
commit 09639c5125
31 changed files with 4266 additions and 169 deletions

View File

@@ -0,0 +1,192 @@
# Phase 7 — Notify and send (Apprise: Email + WhatsApp)
Status: **done** (2026-10-15). A brief was delivered to real recipients on both
channels. See *Evidence* at the end.
Spec: `AGENTS.md` §4 FR-9/FR-10/FR-13/FR-14, §5 screen 4 (Send panel), §8
(integrations), §10 (rules), §11 Phase 7.
## What the phase does
One morning run sends:
| Brief | Recipients | Channels |
|---|---|---|
| Harry | Harry | email, WhatsApp |
| Finn | Finn | email, WhatsApp |
| Combined (FR-14) | Jo and Sam | email, WhatsApp |
Every attempt is a row in `notifications`: one row per recipient per channel,
with the Apprise tag, the HTTP status, `sent_at`, and the redacted error when it
failed. The dashboard shows a **Morning briefs** banner and a **Sends** list, so
a failed send is visible instead of silent (FR-12).
## How the destination is kept out of this repo
The Apprise API on `.35:8210` holds every destination in one stored
configuration. This service only names a **tag**:
```
SCHOOL_APPRISE_TAGS=Harry=harry,Finn=finn,Jo=joanna,Sam=sam
```
A channel tag is `<tag base>_email` and `<tag base>_whatsapp`, which is the
naming the house configuration already uses (`harry_email`, `joanna_whatsapp`, …).
So no email address, phone number or SMTP password is stored in this repo, in
the database, in a log or on the dashboard (AGENTS.md §10 rule 3).
The Apprise configuration key is a secret and lives only in
`environment.d/school-service.env` (gitignored). Environment variables:
| Variable | Meaning |
|---|---|
| `SCHOOL_APPRISE_URL` | API base, default `http://192.168.20.35:8210` |
| `SCHOOL_APPRISE_KEY` | stored configuration key (required to send) |
| `SCHOOL_APPRISE_TOKEN` | optional bearer token for the API |
| `SCHOOL_APPRISE_TIMEOUT` | per-request timeout, default `15s` |
| `SCHOOL_APPRISE_CHANNELS` | `email,whatsapp` |
| `SCHOOL_APPRISE_TAGS` | recipients as `Name=tag-base` |
| `SCHOOL_APPRISE_PARENTS` | who receives the combined brief, `Jo,Sam` |
| `SCHOOL_NOTIFY_TOKEN` | bearer token for `POST /notify/morning` |
## Commands
```bash
# Build and send everything (the 6:00am job): scan, then send.
scripts/morning-brief.sh
# Send from the latest scan, no school access.
scripts/morning-brief.sh --no-scan
# Print the briefs and send nothing.
scripts/morning-brief.sh --no-scan --dry-run
make preview
# Delivery log: status per recipient per channel, plus the scan state.
docker exec compass-teams-nhs /app/school notify status
# One brief only, or one channel only.
docker exec compass-teams-nhs /app/school notify run --only boys
docker exec compass-teams-nhs /app/school notify run --channel email
# Rebuild and send one stored draft.
docker exec compass-teams-nhs /app/school notify send --summary 12
```
## Send panel (AGENTS.md §5 screen 4)
The dashboard has one card per brief: **recipients**, **channels**, a **preview**
that is exactly what is sent, and **Send now**. A tick box per recipient is the
recipient selection; leaving every box clear falls back to the scope default
(the boy for a child brief, Jo and Sam for the combined brief).
`POST /summaries/{id}/send` is the Send action on a stored draft (FR-10). The
draft is stamped `sent_at` only when every channel delivered.
## The 6:00am trigger (n8n)
The endpoint n8n calls:
```
POST http://192.168.20.13:8792/notify/morning
Authorization: Bearer <SCHOOL_NOTIFY_TOKEN>
```
Optional query parameters: `channels=email,whatsapp` and `dry_run=1`.
| Response | Meaning |
|---|---|
| `200` | every channel delivered |
| `207` | a channel failed, or a scan did not complete — the body names both |
| `401` | missing or bad bearer token |
| `503` | `SCHOOL_NOTIFY_TOKEN` is not set, so the endpoint is off |
| `500` | an internal error |
`deploy/n8n-morning-brief.json` is a ready-to-import workflow:
Schedule Trigger `0 6 * * *` (timezone `Australia/Melbourne`) → Execute Command
`ssh remote 'cd /home/sam/paseo/projects/compass_teams_nhs && scripts/scan-compass.sh'`
→ HTTP Request `POST /notify/morning`.
The scan runs on the host, not in the service container: patchright and its
browser are installed on `.13` (Phase 2). One-command alternative for the
Execute Command node:
```bash
ssh remote 'cd /home/sam/paseo/projects/compass_teams_nhs && scripts/morning-brief.sh'
```
**The workflow is not imported yet.** n8n is a shared service, so importing it
is Sam's call. To import: n8n → *Workflows* → *Import from file* →
`deploy/n8n-morning-brief.json`, set `SCHOOL_NOTIFY_TOKEN` in the n8n
environment (or replace the expression with the literal token), then activate.
## Fail loudly (FR-12)
- A child whose last scan failed or never ran gets a brief that opens with
`SCAN FAILED … Due work is UNKNOWN, not "nothing due"`. The combined brief
carries the same warning, and the run exits non-zero (`207` on the endpoint).
- A delivery that fails is recorded with `ok = 0`, **no** `sent_at`, and the
redacted Apprise error; the dashboard shows `SEND FAILED` and the CLI exits
non-zero.
- Nothing is retried automatically: a failure is reported, not hidden.
## Brief content (FR-9, §10 rule 6)
Overdue first, then due today, due this week, no due date, then messages. The
long no-due-date tail is capped at five entries in the *sent* brief with
`… and N more with no due date (see the dashboard)`; overdue work is never
capped. The dashboard and the Send panel preview show the full lists.
## Evidence (2026-10-15, 08:24 UTC / 19:24 AEDT)
`scripts/morning-brief.sh --no-scan` delivered all eight attempts (`HTTP 200`
from the Apprise API, `CallMeBot SUCCESS` and `Sent Email to …` in the Apprise
log). Addresses and numbers are redacted here by design.
```
notifications (8 rows)
id scope recipient channel tag ok sent_at bytes
1 Harry Harry email harry_email 1 2026-10-15T08:24:24Z 2161
2 Harry Harry whatsapp harry_whatsapp 1 2026-10-15T08:24:24Z 2161
3 Finn Finn email finn_email 1 2026-10-15T08:24:31Z 1582
4 Finn Finn whatsapp finn_whatsapp 1 2026-10-15T08:24:31Z 1582
5 combined Jo email joanna_email 1 2026-10-15T08:24:36Z 3645
6 combined Jo whatsapp joanna_whatsapp 1 2026-10-15T08:24:36Z 3645
7 combined Sam email sam_email 1 2026-10-15T08:24:36Z 3645
8 combined Sam whatsapp sam_whatsapp 1 2026-10-15T08:24:36Z 3645
```
`scans` holds the run the briefs were built from (`2026-10-15 07:58`,
`ok = 1`, 78 items for Finn and 106 for Harry), and `PRAGMA user_version = 4`
confirms migration 004.
The endpoint was checked live against the deployed container:
```
POST /notify/morning -> 401 (no token)
POST /notify/morning?dry_run=1 (real token) -> 200, 3 sends, 0 scans failed
scope=Harry recipients=['Harry'] text 2161 bytes
scope=Finn recipients=['Finn'] text 1582 bytes
scope=combined recipients=['Jo', 'Sam'] text 3645 bytes
```
A live *sending* call to the endpoint was not repeated, to avoid a second round
of eight messages five minutes after the first. The endpoint calls the same
`brief.Service.Morning` that delivered the eight rows above, and the delivery
path is covered by tests.
## Decisions and notes
1. **Channels are Email and WhatsApp** (AGENTS.md §13 decision 8). Both are
configured in the house Apprise configuration and both delivered.
2. **`/json/urls/{key}` shows `callmebot://` for the WhatsApp entries.** That is
Apprise's privacy mode masking the credentials, not an empty destination:
the two `callmebot://` entries per person carry a phone number and a
CallMeBot API key, and Apprise logged `CallMeBot SUCCESS` for each.
3. **The endpoint does not scan.** The Compass scan needs patchright on `.13`,
so it stays a host step (Phase 2/4) and the endpoint builds and sends what the
last scan stored. `scripts/morning-brief.sh` runs both steps.
4. **The n8n workflow is provided, not imported** — n8n is a shared service and
the sibling `email_agent` project set the same precedent.
5. **Teams (FR-15) is still out of scope** for this phase; the brief uses the
Compass items stored so far.