# 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.
