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

@@ -1,5 +1,5 @@
IMAGE ?= compass-teams-nhs/school:0.4.0-phase6 IMAGE ?= compass-teams-nhs/school:0.5.0-phase7
BUILD_VERSION ?= 0.4.0-phase6 BUILD_VERSION ?= 0.5.0-phase7
# Parse options (see scripts/parse-compass.sh). # Parse options (see scripts/parse-compass.sh).
CHILD ?= Harry CHILD ?= Harry
@@ -9,6 +9,9 @@ CLASSIFY ?= auto
# Scan target: 'all' scans every child; set SCAN_CHILD=Harry to narrow it. # Scan target: 'all' scans every child; set SCAN_CHILD=Harry to narrow it.
SCAN_CHILD ?= all SCAN_CHILD ?= all
# Notify options (see scripts/morning-brief.sh and school notify run).
NOTIFY_ARGS ?=
# Bounded docker helper for the Go toolchain. The container runs as the current # Bounded docker helper for the Go toolchain. The container runs as the current
# user, so the repository and the module cache stay owned by you. # user, so the repository and the module cache stay owned by you.
GO_IMAGE ?= golang:1.25-alpine GO_IMAGE ?= golang:1.25-alpine
@@ -16,7 +19,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)"
-e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \
-v "$$PWD":/src -w /src $(GO_IMAGE) -v "$$PWD":/src -w /src $(GO_IMAGE)
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar .PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar notify morning-brief
help: help:
@echo "make test run go test ./... in golang:1.25-alpine" @echo "make test run go test ./... in golang:1.25-alpine"
@@ -32,6 +35,9 @@ help:
@echo "make parse parse stored Compass pages into SQLite (Phase 3)" @echo "make parse parse stored Compass pages into SQLite (Phase 3)"
@echo "make scan fetch + parse one or both children (Phase 4; SCAN_CHILD=Harry|Finn|all)" @echo "make scan fetch + parse one or both children (Phase 4; SCAN_CHILD=Harry|Finn|all)"
@echo "make calendar push important events to the HA calendar (Phase 6; CALENDAR_ARGS=...)" @echo "make calendar push important events to the HA calendar (Phase 6; CALENDAR_ARGS=...)"
@echo "make notify build and send the morning briefs via Apprise (Phase 7; NOTIFY_ARGS=...)"
@echo "make preview print the briefs without sending (Phase 7)"
@echo "make morning-brief scan both children, then send the briefs (the n8n job)"
@echo "make verify-phase1 prove /health, the login and the Caddy route" @echo "make verify-phase1 prove /health, the login and the Caddy route"
test: test:
@@ -83,6 +89,18 @@ scan:
calendar: calendar:
IMAGE=$(IMAGE) scripts/calendar-push.sh $(if $(CALENDAR_ARGS),$(CALENDAR_ARGS),status) IMAGE=$(IMAGE) scripts/calendar-push.sh $(if $(CALENDAR_ARGS),$(CALENDAR_ARGS),status)
# Phase 7: send the morning briefs through Apprise (FR-9, FR-14).
notify:
IMAGE=$(IMAGE) scripts/morning-brief.sh --no-scan $(NOTIFY_ARGS)
# Phase 7: print the briefs and send nothing.
preview:
IMAGE=$(IMAGE) scripts/morning-brief.sh --no-scan --dry-run
# Phase 7: the job the 6:00am n8n schedule calls — scan, then send.
morning-brief:
IMAGE=$(IMAGE) scripts/morning-brief.sh
# Phase 1 definition of done: /health 200, login page + session login, and the # Phase 1 definition of done: /health 200, login page + session login, and the
# Caddy route on .35. # Caddy route on .35.
verify-phase1: verify-phase1:

View File

@@ -4,8 +4,29 @@ A school service for Harry and Finn: **Compass** for the daily schedule and
notices, **Teams** for homework and assignments. It stores what is due per child, notices, **Teams** for homework and assignments. It stores what is due per child,
puts overdue work first, and tells the family before work is late. puts overdue work first, and tells the family before work is late.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 6** (the > Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 7** (notify
> calendar write). See §11 of the spec for the phase order. > and send via Apprise). See §11 of the spec for the phase order.
## Status — Phase 7
Phase 7 builds and sends the morning briefs through the house **Apprise** API on
`.35:8210` (FR-9, FR-14): one brief per boy, plus one combined brief to Jo and
Sam, on **Email and WhatsApp**. Every attempt is recorded in `notifications` and
shown on the dashboard, so a failed send is loud rather than silent (FR-12).
`POST /notify/morning` is the endpoint the 6:00am n8n schedule calls. See
[`docs/phase-7-notify-notes.md`](docs/phase-7-notify-notes.md).
```bash
make notify # send the briefs from the latest scan
make preview # print the briefs, send nothing
make morning-brief # scan both children, then send (the n8n job)
# Delivery log: per recipient per channel, plus the scan state.
docker exec compass-teams-nhs /app/school notify status
```
The destinations live in the Apprise configuration, not here: this service only
names a tag such as `harry_email` (AGENTS.md §10 rule 3).
## Status — Phase 6 ## Status — Phase 6
@@ -77,9 +98,8 @@ Built and deployed on `.13`:
- Its own container and port **8792**, behind Caddy at - Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`. `school.lab.audasmedia.com.au`.
Not built yet (later phases): notifications and the Hub API. The calendar write Not built yet (later phases): the Hub API. The service itself does **not** scan
is Phase 6. The service itself does **not** scan the school; n8n triggers the the school; n8n triggers the scan. Teams (FR-15) is a separate source.
scan later. Teams (FR-15) is a separate source.
### Fail loudly (FR-12) ### Fail loudly (FR-12)
@@ -98,11 +118,13 @@ internal/config/ environment configuration
internal/store/ SQLite: schema, migrations, queries internal/store/ SQLite: schema, migrations, queries
internal/parse/ HTML -> structured items (tasks, messages, events) internal/parse/ HTML -> structured items (tasks, messages, events)
internal/llm/ OmniRoute client (importance classification only) internal/llm/ OmniRoute client (importance classification only)
internal/apprise/ Apprise API client (tag-based, redacts errors)
internal/brief/ build + deliver the morning briefs (FR-9, FR-14)
internal/calendar/ Home Assistant calendar client + the idempotent sync internal/calendar/ Home Assistant calendar client + the idempotent sync
internal/auth/ session login (signed cookies, CSRF) internal/auth/ session login (signed cookies, CSRF)
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
deploy/ Caddy snippet, deployment notes, verify script deploy/ Caddy snippet, the n8n workflow, deployment notes, verify script
scripts/ Compass fetchers, the parse wrapper and the calendar push scripts/ Compass fetchers, the parse and morning-brief wrappers, the calendar push
environment.d/ env example (the real file is gitignored) environment.d/ env example (the real file is gitignored)
``` ```
@@ -117,8 +139,8 @@ docker compose up -d --build
Generate secrets with the built image: Generate secrets with the built image:
```bash ```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 gen-secret docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 hash-password 'the-password' docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 hash-password 'the-password'
``` ```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash

View File

@@ -7,16 +7,18 @@ import (
"os" "os"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/brief"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
) )
// briefCommand is the `school brief` entry point. Phase 5 builds and deletes the // briefCommand is the `school brief` entry point: build and delete the stored
// stored brief (the summary half of FR-10); sending it is Phase 7. // brief (the summary half of FR-10), list what is stored, and send it. Sending
// through Apprise is Phase 7 and lives in `school notify` too.
func briefCommand(args []string) { func briefCommand(args []string) {
if len(args) == 0 { if len(args) == 0 {
usage("school brief build --child NAME | school brief list") usage("school brief build --child NAME | school brief build --combined | school brief list")
} }
switch args[0] { switch args[0] {
case "build": case "build":
@@ -24,26 +26,30 @@ func briefCommand(args []string) {
case "list": case "list":
briefList() briefList()
default: default:
usage("school brief build --child NAME | school brief list") usage("school brief build --child NAME | school brief build --combined | school brief list")
} }
} }
func briefBuild(args []string) { func briefBuild(args []string) {
fs := flag.NewFlagSet("brief build", flag.ContinueOnError) fs := flag.NewFlagSet("brief build", flag.ContinueOnError)
var child string var (
child string
combined bool
)
fs.StringVar(&child, "child", "", "child name (Harry or Finn)") fs.StringVar(&child, "child", "", "child name (Harry or Finn)")
fs.BoolVar(&combined, "combined", false, "build the combined brief for Jo and Sam")
_ = fs.Parse(args) _ = fs.Parse(args)
if child == "" { if child == "" && !combined {
fmt.Fprintln(os.Stderr, "school brief build: --child is required") fmt.Fprintln(os.Stderr, "school brief build: --child NAME or --combined is required")
os.Exit(2) os.Exit(2)
} }
if err := runBriefBuild(child); err != nil { if err := runBriefBuild(child, combined); err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err) fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1) os.Exit(1)
} }
} }
func runBriefBuild(child string) error { func runBriefBuild(child string, combined bool) error {
ctx := context.Background() ctx := context.Background()
cfg, err := config.LoadDB() cfg, err := config.LoadDB()
if err != nil { if err != nil {
@@ -57,6 +63,33 @@ func runBriefBuild(child string) error {
if err := st.Migrate(ctx); err != nil { if err := st.Migrate(ctx); err != nil {
return err return err
} }
if combined {
children, err := st.ListChildren(ctx)
if err != nil {
return err
}
parts := make([]dashboard.Combined, 0, len(children))
for _, c := range children {
items, err := st.ItemsForChild(ctx, c.ID)
if err != nil {
return err
}
parts = append(parts, dashboard.Combined{
Child: c.Name,
Buckets: dashboard.Classify(items, time.Now(), cfg.Location),
})
}
text := dashboard.RenderCombined(parts, cfg.Location)
recipient := brief.CombinedNames(children)
id, err := st.CreateDraft(ctx, brief.ScopeCombined, recipient, text)
if err != nil {
return err
}
fmt.Printf("brief=%d scope=combined recipient=%s bytes=%d\n", id, recipient, len(text))
return nil
}
c, err := st.ChildByName(ctx, child) c, err := st.ChildByName(ctx, child)
if err != nil { if err != nil {
return err return err
@@ -90,21 +123,26 @@ func briefList() {
os.Exit(1) os.Exit(1)
} }
defer st.Close() defer st.Close()
summaries, err := st.ListSummaries(ctx, 20) rows, err := st.RecentNotifications(ctx, 20)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err) fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1) os.Exit(1)
} }
if len(summaries) == 0 { if len(rows) == 0 {
fmt.Println("no briefs stored") fmt.Println("no briefs stored")
return return
} }
for _, sm := range summaries { for _, n := range rows {
status := "draft" state := "draft"
if sm.SentAt != nil { switch {
status = "sent " + sm.SentAt.Format("2006-01-02 15:04") case n.Channel != "" && n.OK && n.SentAt != nil:
state = "sent " + n.SentAt.In(cfg.Location).Format("2006-01-02 15:04")
case n.Channel != "" && n.OK:
state = "sent"
case n.Channel != "":
state = "FAILED"
} }
fmt.Printf("%d\t%s\t%s\t%s\n", sm.ID, sm.Recipient, status, firstLine(sm.Text)) fmt.Printf("%d\t%s\t%s\t%s\t%s\n", n.ID, n.Scope, n.Recipient, state, firstLine(n.Text))
} }
} }

View File

@@ -56,6 +56,9 @@ func main() {
case "brief": case "brief":
briefCommand(os.Args[2:]) briefCommand(os.Args[2:])
return return
case "notify":
notifyCommand(os.Args[2:])
return
case "calendar": case "calendar":
calendarCommand(os.Args[2:]) calendarCommand(os.Args[2:])
return return

527
cmd/school/notify.go Normal file
View File

@@ -0,0 +1,527 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/brief"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// notifyCommand is the `school notify` entry point (AGENTS.md §11 Phase 7).
// `school notify run` is the job the 6:00am n8n schedule calls: it builds the
// per-boy briefs and the combined brief to Jo and Sam and delivers them through
// Apprise (FR-9, FR-14).
func notifyCommand(args []string) {
if len(args) == 0 {
usage("school notify run|preview|send|status")
}
switch args[0] {
case "run":
notifyRun(args[1:])
case "preview":
notifyPreview(args[1:])
case "send":
notifySend(args[1:])
case "status":
notifyStatus(args[1:])
default:
usage("school notify run|preview|send|status")
}
}
// newBriefService opens the database and builds the delivery service shared by
// every notify subcommand.
func newBriefService(ctx context.Context) (*brief.Service, *store.Store, *config.DBConfig, error) {
cfg, err := config.LoadDB()
if err != nil {
return nil, nil, nil, err
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return nil, nil, nil, err
}
if err := st.Migrate(ctx); err != nil {
st.Close()
return nil, nil, nil, err
}
client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout)
return brief.New(st, client, cfg.Apprise, cfg.Location, nil), st, cfg, nil
}
func notifyRun(args []string) {
fs := flag.NewFlagSet("notify run", flag.ContinueOnError)
var (
channel string
dryRun bool
only string
)
fs.StringVar(&channel, "channel", "", "channels to use (default: configured)")
fs.BoolVar(&dryRun, "dry-run", false, "build the briefs but send nothing")
fs.StringVar(&only, "only", "all", "all, boys or combined")
_ = fs.Parse(args)
ctx := context.Background()
svc, st, cfg, err := newBriefService(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
defer st.Close()
channels, err := resolveChannels(cfg.Apprise, channel)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(2)
}
if !cfg.Apprise.Enabled() && !dryRun {
fmt.Fprintln(os.Stderr, "school notify: the Apprise gateway is not configured: set "+
strings.Join(cfg.Apprise.Missing(), ", "))
os.Exit(1)
}
if dryRun || !cfg.Apprise.Enabled() {
sends, err := buildOnly(ctx, svc, st, only)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
for _, s := range sends {
fmt.Printf("--- %s (-> %s) ---\n%s\n", s.Scope, strings.Join(s.Recipients, ", "), s.Text)
}
fmt.Println("dry run: nothing was sent")
exitOnFailure(sends)
return
}
sends, err := svc.Morning(ctx, channels)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
reportSends(sends)
exitOnFailure(sends)
}
// buildOnly builds the brief text for the requested scope without sending.
func buildOnly(ctx context.Context, svc *brief.Service, st *store.Store, only string) ([]brief.Send, error) {
children, err := st.ListChildren(ctx)
if err != nil {
return nil, err
}
if len(children) == 0 {
return nil, fmt.Errorf("no children on file")
}
var out []brief.Send
if only != "combined" {
for _, c := range children {
text, ok, err := svc.ChildrenText(ctx, c)
if err != nil {
return nil, err
}
s := brief.Send{Scope: c.Name, Text: text}
if recips, err := svc.ChildRecipients(c); err == nil {
for _, r := range recips {
s.Recipients = append(s.Recipients, r.Name)
}
}
if !ok {
s.ScanFailed = []string{c.Name}
}
out = append(out, s)
}
}
if only != "boys" {
text, failed, err := svc.CombinedText(ctx, children)
if err != nil {
return nil, err
}
parents, _, err := svc.ParentRecipients()
if err != nil {
return nil, err
}
s := brief.Send{Scope: brief.ScopeCombined, Text: text, ScanFailed: failed}
for _, r := range parents {
s.Recipients = append(s.Recipients, r.Name)
}
out = append(out, s)
}
return out, nil
}
// reportSends prints one line per delivery attempt: briefs are lists only
// (AGENTS.md §10 rule 6), and a failure is printed as a failure.
func reportSends(sends []brief.Send) {
for _, s := range sends {
fmt.Printf("%s: %d/%d delivered\n", s.Scope, s.Delivered(), len(s.Deliveries))
for _, d := range s.Deliveries {
status := "sent"
if !d.OK {
status = "FAILED"
}
fmt.Printf(" %-8s %-8s %-16s http=%d %s\n", d.Recipient, d.Channel, d.Tag, d.Status, status)
if !d.OK {
fmt.Printf(" %s\n", d.Error)
}
}
}
}
// exitOnFailure makes a broken run loud: a delivery that did not go out and a
// scan that did not complete both exit non-zero, so the 6:00am schedule shows a
// failed execution instead of a silent "nothing due" or a silent missing brief
// (FR-12).
func exitOnFailure(sends []brief.Send) {
var scans []string
deliveries := 0
for _, s := range sends {
scans = append(scans, s.ScanFailed...)
deliveries += s.Failed()
}
if deliveries == 0 && len(scans) == 0 {
return
}
if len(scans) > 0 {
fmt.Fprintf(os.Stderr, "school notify: SCAN FAILED for %s — the brief says so; due work is UNKNOWN\n",
strings.Join(scans, ", "))
}
if deliveries > 0 {
fmt.Fprintf(os.Stderr, "school notify: %d delivery attempt(s) FAILED — those recipients were NOT told\n", deliveries)
}
os.Exit(1)
}
func notifyPreview(args []string) {
fs := flag.NewFlagSet("notify preview", flag.ContinueOnError)
var (
child string
combined bool
)
fs.StringVar(&child, "child", "", "child name (Harry or Finn)")
fs.BoolVar(&combined, "combined", false, "preview the combined brief to Jo and Sam")
_ = fs.Parse(args)
ctx := context.Background()
svc, st, cfg, err := newBriefService(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
defer st.Close()
if combined {
children, err := st.ListChildren(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
text, failed, err := svc.CombinedText(ctx, children)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
fmt.Print(text)
if len(failed) > 0 {
fmt.Fprintf(os.Stderr, "school notify: scan failed for %s\n", strings.Join(failed, ", "))
os.Exit(1)
}
return
}
if child == "" {
fmt.Fprintln(os.Stderr, "school notify preview: --child or --combined is required")
os.Exit(2)
}
c, err := st.ChildByName(ctx, child)
if err != nil || c == nil {
fmt.Fprintf(os.Stderr, "school notify: unknown child %q\n", child)
os.Exit(1)
}
text, ok, err := svc.ChildrenText(ctx, *c)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
fmt.Print(text)
fmt.Fprintf(os.Stderr, "channels=%s\n", strings.Join(cfg.Apprise.Channels, ","))
if !ok {
os.Exit(1)
}
}
func notifySend(args []string) {
fs := flag.NewFlagSet("notify send", flag.ContinueOnError)
var (
summary int64
to string
channel string
scopeArg string
)
fs.Int64Var(&summary, "summary", 0, "notification id of the stored brief to send")
fs.StringVar(&to, "to", "", "comma list of recipients (default: the scope's recipients)")
fs.StringVar(&channel, "channel", "", "channels to use (default: configured)")
fs.StringVar(&scopeArg, "scope", "", "scope when the brief is rebuilt instead of sent from an id (child name or combined)")
_ = fs.Parse(args)
ctx := context.Background()
svc, st, cfg, err := newBriefService(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
defer st.Close()
channels, err := resolveChannels(cfg.Apprise, channel)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(2)
}
var text, scope string
var draft *store.Notification
switch {
case summary > 0:
n, err := st.NotificationByID(ctx, summary)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
if n == nil {
fmt.Fprintf(os.Stderr, "school notify: no brief with id %d\n", summary)
os.Exit(1)
}
draft, text, scope = n, n.Text, n.Scope
if scope == "" {
scope = n.Recipient
}
case scopeArg != "":
scope = scopeArg
text, _, err = textForScope(ctx, svc, st, scopeArg)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
default:
fmt.Fprintln(os.Stderr, "school notify send: --summary ID or --scope NAME is required")
os.Exit(2)
}
recipients, err := recipientsForScope(ctx, svc, st, scope, to)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
send, err := svc.Deliver(ctx, scope, "School brief — "+scope, text, recipients, channels)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
reportSends([]brief.Send{send})
// The draft is stamped only when every channel delivered, so a stored brief
// is never marked sent after a failure (FR-12).
if draft != nil {
tag, ch := firstDelivery(send)
if err := st.MarkSummarySent(ctx, draft.ID, ch, tag, send.SentOK(), firstError(send), svc.Now()); err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
}
if !send.SentOK() {
os.Exit(1)
}
}
func notifyStatus(args []string) {
fs := flag.NewFlagSet("notify status", flag.ContinueOnError)
var limit int
fs.IntVar(&limit, "limit", 20, "how many rows to show")
_ = fs.Parse(args)
ctx := context.Background()
svc, st, cfg, err := newBriefService(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
defer st.Close()
fmt.Printf("gateway=%s key=%s channels=%s\n", cfg.Apprise.BaseURL, maskKey(cfg.Apprise.Key), strings.Join(cfg.Apprise.Channels, ","))
if !cfg.Apprise.Enabled() {
fmt.Printf("apprise NOT configured: missing %s\n", strings.Join(cfg.Apprise.Missing(), ", "))
}
for _, r := range cfg.Apprise.Recipients {
role := "child"
if cfg.Apprise.IsParent(r.Name) {
role = "parent"
}
fmt.Printf("recipient %-6s %-6s %s\n", r.Name, role, cfg.Apprise.ChannelTag(r, config.ChannelEmail)+"/"+cfg.Apprise.ChannelTag(r, config.ChannelWhatsApp))
}
status, err := st.NotificationSummary(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
fmt.Printf("\nsends: total=%d delivered=%d failed=%d drafts=%d\n", status.Total, status.Sent, status.Failed, status.Drafts)
if status.Last != nil {
fmt.Printf("last attempt: %s %s/%s ok=%t at %s\n", status.Last.Scope, status.Last.Recipient,
channelOrDraft(status.Last.Channel), status.Last.OK, status.Last.CreatedAt.In(svc.Loc).Format("2006-01-02 15:04"))
}
rows, err := st.RecentNotifications(ctx, limit)
if err != nil {
fmt.Fprintln(os.Stderr, "school notify:", err)
os.Exit(1)
}
for _, n := range rows {
sent := "draft"
if n.SentAt != nil {
sent = n.SentAt.In(svc.Loc).Format("2006-01-02 15:04")
}
state := "ok"
switch {
case n.Channel == "":
state = "draft"
case !n.OK:
state = "FAILED"
}
line := fmt.Sprintf("%4d %-9s %-7s %-8s %-14s %s", n.ID, n.Scope, n.Recipient, channelOrDraft(n.Channel), state, sent)
if n.Error != "" {
line += " :: " + apprise.Truncate(n.Error, 80)
}
fmt.Println(line)
}
children, err := st.ListChildren(ctx)
if err != nil {
return
}
fmt.Println("\nscans:")
for _, c := range children {
sc, err := st.ScanSummaryForChild(ctx, c.ID)
if err != nil {
continue
}
state := "never run"
if sc.EverRun && sc.Last != nil {
state = "ok"
if !sc.Last.OK {
state = "FAILED"
}
}
at := ""
if sc.Last != nil {
at = sc.Last.StartedAt.In(svc.Loc).Format("2006-01-02 15:04")
}
fmt.Printf(" %-6s %-9s %s\n", c.Name, state, at)
}
}
// textForScope rebuilds a brief for a child name or the combined scope, so the
// Send panel can send fresh text without a stored draft.
func textForScope(ctx context.Context, svc *brief.Service, st *store.Store, scope string) (string, bool, error) {
if strings.EqualFold(scope, brief.ScopeCombined) {
children, err := st.ListChildren(ctx)
if err != nil {
return "", false, err
}
text, failed, err := svc.CombinedText(ctx, children)
return text, len(failed) == 0, err
}
c, err := st.ChildByName(ctx, scope)
if err != nil || c == nil {
return "", false, fmt.Errorf("unknown scope %q", scope)
}
return svc.ChildrenText(ctx, *c)
}
// recipientsForScope resolves the recipients for a scope: the explicit list,
// else the child plus the parents for a child scope, else the parents.
func recipientsForScope(ctx context.Context, svc *brief.Service, st *store.Store, scope, to string) ([]config.Recipient, error) {
if strings.TrimSpace(to) != "" {
return svc.RecipientsFor(splitNames(to))
}
if strings.EqualFold(scope, brief.ScopeCombined) {
parents, _, err := svc.ParentRecipients()
return parents, err
}
c, err := st.ChildByName(ctx, scope)
if err != nil || c == nil {
return nil, fmt.Errorf("unknown scope %q", scope)
}
return svc.ChildRecipients(*c)
}
// resolveChannels returns the channels to use: the flag when given, else the
// configured list. It fails loudly on an unknown channel.
func resolveChannels(cfg config.AppriseConfig, channel string) ([]string, error) {
channels := cfg.Channels
if strings.TrimSpace(channel) != "" {
channels = splitNames(channel)
}
if len(channels) == 0 {
return nil, fmt.Errorf("no channels configured (SCHOOL_APPRISE_CHANNELS)")
}
var bad []string
for _, ch := range channels {
if ch != config.ChannelEmail && ch != config.ChannelWhatsApp {
bad = append(bad, ch)
}
}
if len(bad) > 0 {
return nil, fmt.Errorf("unknown channel(s) %s (use email,whatsapp)", strings.Join(bad, ", "))
}
return channels, nil
}
func splitNames(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
func firstDelivery(s brief.Send) (tag, channel string) {
if len(s.Deliveries) == 0 {
return "", ""
}
return s.Deliveries[0].Tag, s.Deliveries[0].Channel
}
func firstError(s brief.Send) string {
for _, d := range s.Deliveries {
if !d.OK {
return d.Error
}
}
return ""
}
func channelOrDraft(ch string) string {
if ch == "" {
return "draft"
}
return ch
}
// maskKey never prints the Apprise configuration key in full.
func maskKey(key string) string {
if key == "" {
return "(unset)"
}
if len(key) <= 8 {
return "****"
}
return key[:4] + "…" + key[len(key)-4:]
}

View File

@@ -56,3 +56,56 @@ on `.35` (sam-ubuntu1) terminates TLS and proxies to `192.168.20.13:8792`.
create a project network. create a project network.
- The env file is gitignored. School credentials and harness session state are - The env file is gitignored. School credentials and harness session state are
secrets (AGENTS.md §10 rule 3); they never enter git. secrets (AGENTS.md §10 rule 3); they never enter git.
## Phase 7 — notifications (Apprise)
The morning briefs are delivered through the house **Apprise** API on `.35:8210`
(container `apprise`, host port 8210 → container 8000). Caddy exposes it at
`https://apprise.lab.audasmedia.com.au` behind basic auth; the service talks to
the API directly on the internal address.
1. Create (or reuse) the Apprise **stored configuration** that holds every
destination, using these tags:
| Tag | Destination |
|---|---|
| `harry_email`, `finn_email`, `joanna_email`, `sam_email` | email |
| `harry_whatsapp`, `finn_whatsapp`, `joanna_whatsapp`, `sam_whatsapp` | WhatsApp (CallMeBot) |
2. Copy its **key** into `environment.d/school-service.env` as
`SCHOOL_APPRISE_KEY`, and generate the n8n token:
```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 gen-secret
# -> SCHOOL_NOTIFY_TOKEN=<the value>
```
3. Restart the service, then prove the whole path without messaging anyone:
```bash
make preview # prints both boys + the combined brief
curl -s -X POST -H "Authorization: Bearer $SCHOOL_NOTIFY_TOKEN" \
'http://127.0.0.1:8792/notify/morning?dry_run=1' | jq .
```
4. Send for real (this messages the family):
```bash
make notify # from the latest scan
make morning-brief # scan both children, then send
docker exec compass-teams-nhs /app/school notify status
```
5. Import `deploy/n8n-morning-brief.json` in n8n, set `SCHOOL_NOTIFY_TOKEN` in
the n8n environment, and activate the workflow (06:00 daily, Australia/Melbourne).
Notes:
- The destination URLs (addresses, phone numbers, SMTP passwords) live only in
the Apprise configuration. `GET /json/urls/{key}` prints `callmebot://` for a
WhatsApp entry because Apprise masks credentials in privacy mode; the entry is
real, and the container log shows `CallMeBot SUCCESS`.
- The scan stays a host step: patchright and its browser are on `.13`, not in the
service image. `scripts/morning-brief.sh` runs the scan and the send in order.
- A run that cannot deliver exits non-zero, and the endpoint answers `207`, so a
broken morning is visible in n8n instead of silent (FR-12).

View File

@@ -0,0 +1,109 @@
{
"name": "Compass / NHS — 6:00am morning brief",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 6 * * *"
}
]
}
},
"id": "c1a0a0a0-0000-4000-8000-000000000001",
"name": "6:00am every day",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [200, 300]
},
{
"parameters": {
"command": "ssh remote 'cd /home/sam/paseo/projects/compass_teams_nhs && scripts/scan-compass.sh'"
},
"id": "c1a0a0a0-0000-4000-8000-000000000002",
"name": "Scan Compass (both children, read-only)",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [480, 300],
"notes": "The scan needs node + patchright on the host, so it runs outside the service container. A failed scan still lets the briefs go out: the brief carries the failure notice (FR-12)."
},
{
"parameters": {
"method": "POST",
"url": "http://192.168.20.13:8792/notify/morning",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{$env.SCHOOL_NOTIFY_TOKEN}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": false,
"options": {
"timeout": 240000,
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "c1a0a0a0-0000-4000-8000-000000000003",
"name": "Send the morning briefs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [760, 300],
"notes": "POST /notify/morning builds one brief per boy plus the combined brief to Jo and Sam and delivers them through Apprise. 200 = every channel delivered; 207 = a channel failed or a scan failed; 503 = SCHOOL_NOTIFY_TOKEN is not set on the service."
}
],
"connections": {
"6:00am every day": {
"main": [
[
{
"node": "Scan Compass (both children, read-only)",
"type": "main",
"index": 0
}
]
]
},
"Scan Compass (both children, read-only)": {
"main": [
[
{
"node": "Send the morning briefs",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"timezone": "Australia/Melbourne",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "compass-teams-nhs"
},
"pinData": {},
"tags": [
{
"name": "school"
}
],
"active": false
}

View File

@@ -10,8 +10,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.4.0-phase6" VERSION: "0.5.0-phase7"
image: compass-teams-nhs/school:0.4.0-phase6 image: compass-teams-nhs/school:0.5.0-phase7
container_name: compass-teams-nhs container_name: compass-teams-nhs
restart: unless-stopped restart: unless-stopped
env_file: env_file:

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.

View File

@@ -45,3 +45,32 @@ SCHOOL_HA_CALENDAR_ENTITY=calendar.school
# Set true only when SCHOOL_HA_URL is the https://homeassistant.home.lab URL. # Set true only when SCHOOL_HA_URL is the https://homeassistant.home.lab URL.
SCHOOL_HA_INSECURE=false SCHOOL_HA_INSECURE=false
SCHOOL_HA_TIMEOUT=20s SCHOOL_HA_TIMEOUT=20s
# ---- Apprise notifications (AGENTS.md §4 FR-9/FR-14, §11 Phase 7) ----------
# The morning briefs go out through the house Apprise API on `.35:8210`. The
# Apprise configuration holds every destination (email address, WhatsApp number,
# SMTP password); this service only names a tag such as "harry_email", so no
# address or password is ever stored here or committed (AGENTS.md §10 rule 3).
#
# Create the stored configuration once in the Apprise web UI
# (https://apprise.lab.audasmedia.com.au), then copy its key here. The key acts
# like a password: keep it in this file only, never in git.
SCHOOL_APPRISE_URL=http://192.168.20.35:8210
SCHOOL_APPRISE_KEY=
# Optional bearer token if the Apprise API is protected. Empty in this lab.
SCHOOL_APPRISE_TOKEN=
SCHOOL_APPRISE_TIMEOUT=15s
# Channels the morning brief uses (AGENTS.md §13 decision 8). Each recipient
# needs a tag per channel: <tag base>_email and <tag base>_whatsapp.
SCHOOL_APPRISE_CHANNELS=email,whatsapp
# Recipients: Name=tag-base. The boys are children in the database and get their
# own brief; the rest are parents and get one combined brief (FR-9, FR-14).
SCHOOL_APPRISE_TAGS=Harry=harry,Finn=finn,Jo=joanna,Sam=sam
SCHOOL_APPRISE_PARENTS=Jo,Sam
# Bearer token for POST /notify/morning, the endpoint n8n calls at 6:00am.
# Generate one and keep it here:
# docker run --rm --entrypoint /app/school <image> gen-secret
SCHOOL_NOTIFY_TOKEN=

247
internal/apprise/apprise.go Normal file
View File

@@ -0,0 +1,247 @@
// Package apprise delivers notifications through the house Apprise API on
// `.35:8210` (AGENTS.md §8). Phase 7 uses it for the morning briefs: one brief
// per boy, plus one combined brief to Jo and Sam (FR-9, FR-14).
//
// The Apprise API holds every destination in a stored configuration, selected
// by tag. The school service therefore never holds an email address, a phone
// number or an SMTP password: it only names a tag such as "harry_email"
// (AGENTS.md §10 rule 3). The API shape it speaks is:
//
// POST {base}/notify/{key} JSON {"tag":["harry_email"],"title","body","type"}
// POST {base}/notify JSON {"urls":[...],"title","body","type"} (stateless)
//
// A failure is returned as an error with the redacted server text, so the
// caller can record it and show it loudly (FR-12). Nothing is retried here.
package apprise
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Notification classes understood by Apprise.
const (
TypeInfo = "info"
TypeSuccess = "success"
TypeWarning = "warning"
TypeFailure = "failure"
)
// Result is the outcome of one delivery attempt.
type Result struct {
// Status is the HTTP status the Apprise API returned (0 when the request
// never completed).
Status int
// Body is the redacted response text, kept short for the log.
Body string
// Duration is how long the attempt took.
Duration time.Duration
}
// OK reports whether Apprise accepted the notification for every service.
func (r Result) OK() bool { return r.Status >= 200 && r.Status < 300 }
// Client posts notifications to one Apprise API server.
type Client struct {
// BaseURL is the Apprise API root, for example http://192.168.20.35:8210.
BaseURL string
// Key names a stored Apprise configuration. Empty means stateless mode,
// which requires URLs on every send.
Key string
// Token is sent as a bearer token when the Apprise API is protected.
Token string
// HTTP is the transport. A nil value means a client with a 15s timeout.
HTTP *http.Client
}
// New builds a client. A zero timeout defaults to fifteen seconds: the morning
// brief waits on a real SMTP or WhatsApp gateway, so a short timeout would
// report a false failure.
func New(baseURL, key, token string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 15 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
Key: strings.TrimSpace(key),
Token: strings.TrimSpace(token),
HTTP: &http.Client{Timeout: timeout},
}
}
// Configured reports whether a delivery can be attempted. A nil client is not
// configured, so callers never branch on nil.
func (c *Client) Configured() bool {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return false
}
return true // the base URL is set; a key or explicit URLs are checked on send
}
// notifyBody is the Apprise API request body. `tag` selects a subset of the
// stored configuration; `urls` is the stateless alternative.
type notifyBody struct {
URLs []string `json:"urls,omitempty"`
Tag []string `json:"tag,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Type string `json:"type,omitempty"`
}
// SendTags delivers one notification to every stored destination that carries
// one of the given tags. An empty tag list is refused: sending the whole stored
// configuration would hit destinations that are configured for other purposes
// (and the empty WhatsApp placeholders), so the failure would be ambiguous.
func (c *Client) SendTags(ctx context.Context, tags []string, title, body, notifyType string) (Result, error) {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
}
if c.Key == "" {
return Result{}, fmt.Errorf("apprise key is not set: set SCHOOL_APPRISE_KEY")
}
clean := make([]string, 0, len(tags))
for _, t := range tags {
if t = strings.TrimSpace(t); t != "" {
clean = append(clean, t)
}
}
if len(clean) == 0 {
return Result{}, fmt.Errorf("apprise tag is empty: nothing would be delivered")
}
return c.post(ctx, c.BaseURL+"/notify/"+c.Key, notifyBody{Tag: clean, Title: title, Body: body, Type: notifyType})
}
// SendURLs delivers one notification to an explicit stateless destination list.
// It is the fallback when no stored configuration is set.
func (c *Client) SendURLs(ctx context.Context, urls []string, title, body, notifyType string) (Result, error) {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
}
if len(urls) == 0 {
return Result{}, fmt.Errorf("apprise destination list is empty")
}
return c.post(ctx, c.BaseURL+"/notify", notifyBody{URLs: urls, Title: title, Body: body, Type: notifyType})
}
func (c *Client) post(ctx context.Context, url string, payload notifyBody) (Result, error) {
if payload.Type == "" {
payload.Type = TypeInfo
}
raw, err := json.Marshal(payload)
if err != nil {
return Result{}, fmt.Errorf("encode apprise request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return Result{}, fmt.Errorf("build apprise request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
started := time.Now()
resp, err := httpClient.Do(req)
if err != nil {
// A transport error is wrapped by net/http; it never echoes a secret.
return Result{Duration: time.Since(started)}, fmt.Errorf("post apprise: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
res := Result{Status: resp.StatusCode, Body: Truncate(Redact(strings.TrimSpace(string(body))), 300), Duration: time.Since(started)}
if !res.OK() {
msg := fmt.Sprintf("apprise returned status %d", resp.StatusCode)
if res.Body != "" {
msg += ": " + res.Body
}
return res, fmt.Errorf("post apprise: %s", msg)
}
return res, nil
}
// Redact removes anything that must never reach a log, a database row or a
// screen: email addresses, phone numbers, Apprise keys and long tokens.
// Apprise echoes the failing destination in its error text, so this runs on
// every response before the text is stored (AGENTS.md §10 rule 3).
func Redact(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); {
c := s[i]
if isEmailStart(c) {
if j := emailEnd(s, i); j > i {
b.WriteString("<redacted-address>")
i = j
continue
}
}
if isDigit(c) {
// A run of seven or more digits is a phone number or a token.
j := i
for j < len(s) && (isDigit(s[j]) || s[j] == '-' || s[j] == ' ') && j-i < 24 {
j++
}
run := strings.NewReplacer("-", "", " ", "").Replace(s[i:j])
if len(run) >= 7 {
b.WriteString("<redacted-number>")
i = j
continue
}
}
b.WriteByte(c)
i++
}
return b.String()
}
func isEmailStart(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c)
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// emailEnd returns the index just past an address starting at i, or i when the
// text at i is not an address.
func emailEnd(s string, i int) int {
j := i
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '_' || s[j] == '%' || s[j] == '+' || s[j] == '-') {
j++
}
if j >= len(s) || s[j] != '@' {
return i
}
j++
host := j
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '-') {
j++
}
if j == host || !strings.Contains(s[host:j], ".") {
return i
}
return j
}
// Truncate shortens s to at most n bytes, marking that it was cut.
func Truncate(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
cut := s[:n]
if i := strings.LastIndexAny(cut, " \n"); i > n/2 {
cut = cut[:i]
}
return cut + "… (truncated)"
}

View File

@@ -0,0 +1,148 @@
package apprise
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestSendTagsUsesKeyTagAndBearer proves the request shape the house Apprise
// API expects: POST /notify/{key} with the tag list and the bearer token.
func TestSendTagsUsesKeyTagAndBearer(t *testing.T) {
var (
path, auth string
body notifyBody
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
auth = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "abc123", "secret-token", 2*time.Second)
res, err := c.SendTags(context.Background(), []string{"harry_email", "harry_whatsapp"}, "Harry — school brief", "OVERDUE\n- Essay", TypeInfo)
if err != nil {
t.Fatalf("send: %v", err)
}
if !res.OK() || res.Status != http.StatusOK {
t.Errorf("result = %+v, want 200", res)
}
if path != "/notify/abc123" {
t.Errorf("path = %q, want /notify/abc123", path)
}
if auth != "Bearer secret-token" {
t.Errorf("auth = %q, want the bearer token", auth)
}
if len(body.Tag) != 2 || body.Tag[0] != "harry_email" || body.Tag[1] != "harry_whatsapp" {
t.Errorf("tag = %#v, want both tags", body.Tag)
}
if body.Title != "Harry — school brief" || body.Body != "OVERDUE\n- Essay" || body.Type != TypeInfo {
t.Errorf("body = %+v", body)
}
if body.URLs != nil {
t.Errorf("urls = %#v, want none in keyed mode", body.URLs)
}
}
// TestSendTagsFailsLoudlyAndRedacts proves a 424 is an error and that the
// echoed address and phone number never reach the caller (AGENTS.md §10 rule 3).
func TestSendTagsFailsLoudlyAndRedacts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusFailedDependency)
_, _ = w.Write([]byte(`{"error":"One or more notifications not sent. Failed to send Email to person@example.com and WhatsApp to 61400000000"}`))
}))
defer srv.Close()
c := New(srv.URL, "abc123", "", 2*time.Second)
res, err := c.SendTags(context.Background(), []string{"harry_email"}, "t", "b", TypeInfo)
if err == nil {
t.Fatal("want an error on 424")
}
if res.Status != http.StatusFailedDependency {
t.Errorf("status = %d, want 424", res.Status)
}
msg := err.Error()
for _, leak := range []string{"person", "example.com", "61400000000"} {
if strings.Contains(msg, leak) {
t.Errorf("error leaks %q: %s", leak, msg)
}
}
if !strings.Contains(msg, "424") {
t.Errorf("error should carry the status: %s", msg)
}
}
// TestSendTagsRefusesEmptyTag proves a send without a tag is refused: sending
// the whole stored configuration would hit destinations that are configured for
// other purposes, so the result would be ambiguous.
func TestSendTagsRefusesEmptyTag(t *testing.T) {
c := New("http://127.0.0.1:1", "abc123", "", time.Second)
if _, err := c.SendTags(context.Background(), []string{"", " "}, "t", "b", TypeInfo); err == nil {
t.Fatal("want an error for an empty tag list")
}
}
// TestSendTagsNeedsKey proves stateless mode is explicit: without a key the
// caller must use SendURLs, so a missing key never silently sends nowhere.
func TestSendTagsNeedsKey(t *testing.T) {
c := New("http://127.0.0.1:1", "", "", time.Second)
if _, err := c.SendTags(context.Background(), []string{"harry_email"}, "t", "b", TypeInfo); err == nil {
t.Fatal("want an error without a key")
}
}
// TestSendURLsStateless covers the fallback shape.
func TestSendURLsStateless(t *testing.T) {
var body notifyBody
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "", "", 2*time.Second)
if _, err := c.SendURLs(context.Background(), []string{"mailto://user:pass@example.com"}, "t", "b", TypeWarning); err != nil {
t.Fatalf("send: %v", err)
}
if len(body.URLs) != 1 || body.Type != TypeWarning {
t.Errorf("body = %+v", body)
}
}
func TestRedact(t *testing.T) {
cases := []struct {
in, want string
}{
{"plain text", "plain text"},
{"Failed to send Email to sam@example.com", "Failed to send Email to <redacted-address>"},
{"phone 61412345678", "phone <redacted-number>"},
{"short 12 stays", "short 12 stays"},
{"two: a@b.co and c@d.co", "two: <redacted-address> and <redacted-address>"},
}
for _, c := range cases {
if got := Redact(c.in); got != c.want {
t.Errorf("Redact(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTruncate(t *testing.T) {
if got := Truncate("short", 10); got != "short" {
t.Errorf("Truncate short = %q", got)
}
long := strings.Repeat("word ", 40)
got := Truncate(long, 40)
if len(got) > 60 || !strings.HasSuffix(got, "(truncated)") {
t.Errorf("Truncate long = %q", got)
}
}

401
internal/brief/brief.go Normal file
View File

@@ -0,0 +1,401 @@
// Package brief builds and sends the morning briefs through the house Apprise
// API (AGENTS.md §4 FR-9, FR-10, FR-12, FR-14, §11 Phase 7).
//
// One brief goes to each boy — what is on today, overdue work, pending work and
// the school notices — and one combined brief goes to Jo and Sam. Every
// delivery attempt is recorded in `notifications`, one row per recipient per
// channel, so a failed send is visible rather than silent (FR-12, FR-13).
//
// The service never holds a destination: it names an Apprise tag, for example
// "harry_email", and the Apprise configuration on `.35` holds the address
// (AGENTS.md §10 rule 3).
package brief
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// ScopeCombined is the scope of the single brief to Jo and Sam.
const ScopeCombined = "combined"
// Service builds and delivers briefs. Every field is required except Log, which
// defaults to the standard logger.
type Service struct {
Store *store.Store
Client *apprise.Client
Cfg config.AppriseConfig
Loc *time.Location
Log *slog.Logger
// Now is injectable so tests can pin the clock.
Now func() time.Time
}
// New builds a Service.
func New(st *store.Store, client *apprise.Client, cfg config.AppriseConfig, loc *time.Location, log *slog.Logger) *Service {
if loc == nil {
loc = time.UTC
}
if log == nil {
log = slog.Default()
}
return &Service{Store: st, Client: client, Cfg: cfg, Loc: loc, Log: log, Now: time.Now}
}
// Delivery is one send attempt: one recipient, one channel, one Apprise tag.
type Delivery struct {
Recipient string
Channel string
Tag string
OK bool
Status int
Error string
// ID is the `notifications` row for this attempt.
ID int64
}
// Send is one brief and everything that happened to it.
type Send struct {
// Scope is the child name, or ScopeCombined.
Scope string
// Title is the message subject.
Title string
// Text is the brief itself.
Text string
// Recipients are the names the brief went to, in order.
Recipients []string
// Deliveries is one entry per recipient per channel.
Deliveries []Delivery
// ScanFailed lists the children whose last scan did not complete, so the
// caller can exit loudly (FR-12).
ScanFailed []string
}
// Failed is the number of delivery attempts that did not deliver.
func (s Send) Failed() int {
n := 0
for _, d := range s.Deliveries {
if !d.OK {
n++
}
}
return n
}
// Delivered is the number of delivery attempts that delivered.
func (s Send) Delivered() int { return len(s.Deliveries) - s.Failed() }
// OK reports whether every attempt delivered and no scan was broken.
func (s Send) OK() bool { return s.Failed() == 0 && len(s.ScanFailed) == 0 }
// SentOK reports whether every delivery attempt delivered, ignoring the scan
// state (which is reported separately).
func (s Send) SentOK() bool { return s.Failed() == 0 }
// FirstError is the first redacted delivery error, or "" when nothing failed.
func (s Send) FirstError() string {
for _, d := range s.Deliveries {
if !d.OK {
return d.Error
}
}
return ""
}
// ChildrenText returns one child's brief and whether that child's last scan
// completed. A failed or missing scan never reads as "nothing due": the brief
// opens with an explicit warning (FR-12).
func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, bool, error) {
items, err := s.Store.ItemsForChild(ctx, child.ID)
if err != nil {
return "", false, fmt.Errorf("items for %s: %w", child.Name, err)
}
buckets := dashboard.Classify(items, s.Now(), s.Loc)
text := dashboard.RenderWithLimits(child.Name, buckets, s.Loc, dashboard.MorningLimits)
sc, err := s.Store.ScanSummaryForChild(ctx, child.ID)
if err != nil {
return "", false, fmt.Errorf("scan summary for %s: %w", child.Name, err)
}
notice := s.scanNotice(child.Name, sc)
if notice == "" {
return text, true, nil
}
return notice + "\n" + text, false, nil
}
// CombinedText returns the single brief for Jo and Sam — both boys in one
// message — and the children whose scan did not complete.
func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) {
parts := make([]dashboard.Combined, 0, len(children))
var failed []string
var notices []string
for _, c := range children {
items, err := s.Store.ItemsForChild(ctx, c.ID)
if err != nil {
return "", nil, fmt.Errorf("items for %s: %w", c.Name, err)
}
parts = append(parts, dashboard.Combined{
Child: c.Name,
Buckets: dashboard.Classify(items, s.Now(), s.Loc),
})
sc, err := s.Store.ScanSummaryForChild(ctx, c.ID)
if err != nil {
return "", nil, fmt.Errorf("scan summary for %s: %w", c.Name, err)
}
if notice := s.scanNotice(c.Name, sc); notice != "" {
notices = append(notices, notice)
failed = append(failed, c.Name)
}
}
text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits)
if len(notices) > 0 {
text = strings.Join(notices, "\n") + "\n\n" + text
}
return text, failed, nil
}
// scanNotice is the FR-12 warning for one child, or "" when the last scan
// completed.
func (s *Service) scanNotice(child string, sc store.ScanStatus) string {
switch {
case !sc.EverRun:
return fmt.Sprintf("%s — BRIEF UNAVAILABLE: no scan has run yet. Due work is UNKNOWN, not “nothing due”.", child)
case sc.Last != nil && !sc.Last.OK:
detail := strings.TrimSpace(sc.Last.Error)
when := sc.Last.StartedAt.In(s.Loc).Format("Mon 02 Jan 15:04")
msg := fmt.Sprintf("%s — SCAN FAILED at %s", child, when)
if detail != "" {
msg += " (" + detail + ")"
}
return msg + ". Due work is UNKNOWN, not “nothing due”."
default:
return ""
}
}
// RecipientsFor resolves names to configured Apprise recipients, and fails
// loudly when a name is not configured.
func (s *Service) RecipientsFor(names []string) ([]config.Recipient, error) {
out := make([]config.Recipient, 0, len(names))
for _, name := range names {
r, ok := s.Cfg.RecipientByName(name)
if !ok {
return nil, fmt.Errorf("recipient %q is not configured (SCHOOL_APPRISE_TAGS)", name)
}
out = append(out, r)
}
return out, nil
}
// ChildRecipients returns the configured recipients that are children, matched
// by name against the children table.
func (s *Service) ChildRecipients(child store.Child) ([]config.Recipient, error) {
return s.RecipientsFor([]string{child.Name})
}
// ParentRecipients returns the recipients of the combined brief (FR-14).
func (s *Service) ParentRecipients() ([]config.Recipient, string, error) {
parents := s.Cfg.ParentRecipients()
if len(parents) == 0 {
return nil, "", fmt.Errorf("no parents configured for the combined brief (SCHOOL_APPRISE_PARENTS)")
}
names := make([]string, 0, len(parents))
for _, p := range parents {
names = append(names, p.Name)
}
return parents, strings.Join(names, "+"), nil
}
// Deliver sends one brief to the given recipients on the given channels and
// records every attempt. It never returns an error for a failed delivery: the
// failure is in the returned Send, recorded in the database, and the caller is
// responsible for going loud (FR-12).
func (s *Service) Deliver(ctx context.Context, scope, title, text string, recipients []config.Recipient, channels []string) (Send, error) {
out := Send{Scope: scope, Title: title, Text: text}
if len(recipients) == 0 {
return out, fmt.Errorf("no recipients for scope %q", scope)
}
if len(channels) == 0 {
return out, fmt.Errorf("no channels requested for scope %q", scope)
}
if s.Client == nil || !s.Client.Configured() {
return out, fmt.Errorf("apprise is not configured: set %s",
strings.Join(s.Cfg.Missing(), ", "))
}
now := s.Now()
for _, r := range recipients {
out.Recipients = append(out.Recipients, r.Name)
for _, ch := range channels {
tag := s.Cfg.ChannelTag(r, ch)
d := Delivery{Recipient: r.Name, Channel: ch, Tag: tag}
res, err := s.Client.SendTags(ctx, []string{tag}, title, text, apprise.TypeInfo)
d.Status = res.Status
d.OK = err == nil
if err != nil {
// apprise.Redact has already run on the response text, so no
// address or phone number reaches the log or the row.
d.Error = apprise.Truncate(err.Error(), 300)
}
sentAt := &now
if !d.OK {
sentAt = nil
}
id, recErr := s.Store.RecordNotification(ctx, store.Notification{
Scope: scope,
Recipient: r.Name,
Channel: ch,
Tag: tag,
Text: text,
OK: d.OK,
Error: d.Error,
SentAt: sentAt,
})
if recErr != nil {
return out, recErr
}
d.ID = id
if d.OK {
s.Log.Info("brief delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status)
} else {
s.Log.Error("brief NOT delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status, "err", d.Error)
}
out.Deliveries = append(out.Deliveries, d)
}
}
return out, nil
}
// planned is one brief that is ready to go: the send, plus the resolved
// Apprise recipients it would go to.
type planned struct {
Send
recipients []config.Recipient
}
// Morning builds and sends the whole morning run: one brief per boy, then one
// combined brief to Jo and Sam (FR-9, FR-14). The briefs always go out, even
// when a scan failed, because the failure notice is the message that matters
// most; ScanFailed tells the caller to exit loudly.
func (s *Service) Morning(ctx context.Context, channels []string) ([]Send, error) {
plans, err := s.plan(ctx)
if err != nil {
return nil, err
}
sends := make([]Send, 0, len(plans))
for _, p := range plans {
send, err := s.Deliver(ctx, p.Scope, p.Title, p.Text, p.recipients, channels)
if err != nil {
return sends, err
}
send.ScanFailed = p.ScanFailed
sends = append(sends, send)
}
return sends, nil
}
// Plan builds every morning brief without sending anything. It is the build
// path Morning uses, exposed for the machine endpoint's dry run so the n8n
// wiring can be checked without messaging the family.
func (s *Service) Plan(ctx context.Context) ([]Send, error) {
plans, err := s.plan(ctx)
if err != nil {
return nil, err
}
out := make([]Send, 0, len(plans))
for _, p := range plans {
out = append(out, p.Send)
}
return out, nil
}
// plan builds the per-boy briefs and the combined brief, without sending.
func (s *Service) plan(ctx context.Context) ([]planned, error) {
children, err := s.Store.ListChildren(ctx)
if err != nil {
return nil, fmt.Errorf("list children: %w", err)
}
if len(children) == 0 {
return nil, fmt.Errorf("no children on file: nothing to brief")
}
var out []planned
for _, c := range children {
text, scanOK, err := s.ChildrenText(ctx, c)
if err != nil {
return out, err
}
recipients, err := s.ChildRecipients(c)
if err != nil {
return out, err
}
p := planned{
Send: Send{
Scope: c.Name,
Title: fmt.Sprintf("%s — school brief %s", c.Name, s.Now().In(s.Loc).Format("Mon 02 Jan")),
Text: text,
Recipients: []string{c.Name},
},
recipients: recipients,
}
if !scanOK {
p.ScanFailed = []string{c.Name}
}
out = append(out, p)
}
parents, _, err := s.ParentRecipients()
if err != nil {
return out, err
}
text, failed, err := s.CombinedText(ctx, children)
if err != nil {
return out, err
}
names := make([]string, 0, len(parents))
for _, r := range parents {
names = append(names, r.Name)
}
out = append(out, planned{
Send: Send{
Scope: ScopeCombined,
Title: fmt.Sprintf("School brief — %s %s", combinedNames(children), s.Now().In(s.Loc).Format("Mon 02 Jan")),
Text: text,
Recipients: names,
ScanFailed: failed,
},
recipients: parents,
})
return out, nil
}
func combinedNames(children []store.Child) string {
names := make([]string, 0, len(children))
for _, c := range children {
names = append(names, c.Name)
}
switch len(names) {
case 0:
return "school"
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
}
// CombinedNames is the exported form of the display label for the combined
// brief, used by the Send panel.
func CombinedNames(children []store.Child) string { return combinedNames(children) }

View File

@@ -0,0 +1,334 @@
package brief
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// fakeApprise records every notification request and answers with the status
// the test chooses, so a delivery and a failure are both provable.
type fakeApprise struct {
mu sync.Mutex
tags []string
titles []string
bodies []string
tagFail map[string]int // tag -> status
fallback int
}
func (f *fakeApprise) server(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Tag []string `json:"tag"`
Title string `json:"title"`
Body string `json:"body"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.tags = append(f.tags, strings.Join(body.Tag, ","))
f.titles = append(f.titles, body.Title)
f.bodies = append(f.bodies, body.Body)
status := f.fallback
for _, tag := range body.Tag {
if s, ok := f.tagFail[tag]; ok {
status = s
}
}
f.mu.Unlock()
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if status != http.StatusOK {
_, _ = w.Write([]byte(`{"error":"One or more notifications not sent"}`))
}
}))
t.Cleanup(srv.Close)
return srv
}
func (f *fakeApprise) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.tags...)
}
// newService builds a brief.Service against a fresh database and the fake
// gateway.
func newService(t *testing.T, url string, seed func(*store.Store)) (*Service, *store.Store) {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(ctx, "Harry", "Finn"); err != nil {
t.Fatalf("seed: %v", err)
}
if seed != nil {
seed(st)
}
cfg := config.AppriseConfig{
BaseURL: url,
Key: "test-key",
Timeout: 2 * time.Second,
Channels: []string{config.ChannelEmail, config.ChannelWhatsApp},
Recipients: []config.Recipient{
{Name: "Harry", TagBase: "harry"},
{Name: "Finn", TagBase: "finn"},
{Name: "Jo", TagBase: "joanna"},
{Name: "Sam", TagBase: "sam"},
},
Parents: []string{"Jo", "Sam"},
}
client := apprise.New(url, cfg.Key, "", cfg.Timeout)
svc := New(st, client, cfg, loc, slog.New(slog.NewTextHandler(io.Discard, nil)))
svc.Now = func() time.Time { return time.Date(2026, 10, 16, 6, 0, 0, 0, loc) }
return svc, st
}
// seedItems stores one overdue task per child and a successful scan, so the
// briefs are not empty.
func seedItems(st *store.Store) {
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "task", Subject: "English", Title: "Essay",
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "h-essay",
}}); err != nil {
panic(err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{{
Source: "compass", Type: "task", Subject: "Science", Title: "Lab report",
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "f-lab",
}}); err != nil {
panic(err)
}
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
panic(err)
}
if _, err := st.RecordScan(ctx, &finn.ID, 4, 1, true, ""); err != nil {
panic(err)
}
}
// TestMorningSendsPerBoyAndCombined proves FR-9 and FR-14: one brief per boy,
// one combined brief to Jo and Sam, one recorded row per recipient per channel.
func TestMorningSendsPerBoyAndCombined(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, st := newService(t, srv.URL, seedItems)
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
if err != nil {
t.Fatalf("morning: %v", err)
}
if len(sends) != 3 {
t.Fatalf("sends = %d, want Harry, Finn and combined", len(sends))
}
for _, s := range sends {
if !s.OK() {
t.Errorf("send %s not OK: %+v", s.Scope, s)
}
}
// Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels.
if got := len(sends[0].Deliveries); got != 2 {
t.Errorf("Harry deliveries = %d, want 2", got)
}
combined := sends[2]
if combined.Scope != ScopeCombined || len(combined.Deliveries) != 4 {
t.Fatalf("combined = %+v", combined)
}
recipients := map[string]bool{}
for _, d := range combined.Deliveries {
recipients[d.Recipient] = true
}
if !recipients["Jo"] || !recipients["Sam"] {
t.Errorf("combined recipients = %#v", recipients)
}
if len(combined.Recipients) != 2 {
t.Errorf("combined recipient names = %#v", combined.Recipients)
}
// The tags are the configured per-channel names, and the combined text
// carries both boys, overdue first.
want := map[string]bool{
"harry_email": true, "harry_whatsapp": true,
"finn_email": true, "finn_whatsapp": true,
"joanna_email": true, "joanna_whatsapp": true,
"sam_email": true, "sam_whatsapp": true,
}
calls := fake.calls()
if len(calls) != 8 {
t.Fatalf("calls = %#v, want 8", calls)
}
for _, c := range calls {
if !want[c] {
t.Errorf("unexpected tag %q", c)
}
}
if !strings.Contains(combined.Text, "OVERDUE") ||
!strings.Contains(combined.Text, "Harry: English: Essay") ||
!strings.Contains(combined.Text, "Finn: Science: Lab report") {
t.Errorf("combined text = %q", combined.Text)
}
rows, err := st.RecentNotifications(context.Background(), 20)
if err != nil {
t.Fatalf("rows: %v", err)
}
if len(rows) != 8 {
t.Fatalf("rows = %d, want 8", len(rows))
}
for _, n := range rows {
if !n.OK || n.SentAt == nil || n.Channel == "" || n.Tag == "" {
t.Errorf("row = %+v", n)
}
}
}
// TestDeliveryFailureIsRecordedAndLoud proves a failed channel is recorded with
// its error and no sent_at, and that the send is not reported as OK (FR-12).
func TestDeliveryFailureIsRecordedAndLoud(t *testing.T) {
fake := &fakeApprise{tagFail: map[string]int{
"harry_whatsapp": http.StatusFailedDependency,
"finn_whatsapp": http.StatusFailedDependency,
"joanna_whatsapp": http.StatusFailedDependency,
"sam_whatsapp": http.StatusFailedDependency,
}}
srv := fake.server(t)
svc, st := newService(t, srv.URL, seedItems)
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
if err != nil {
t.Fatalf("morning: %v", err)
}
for _, s := range sends {
if s.SentOK() {
t.Errorf("%s reported OK despite the WhatsApp failures", s.Scope)
}
wantFailed := 1 // the boy's own WhatsApp tag
if s.Scope == ScopeCombined {
wantFailed = 2 // Jo and Sam
}
if s.Failed() != wantFailed {
t.Errorf("%s failed = %d, want %d (one per WhatsApp recipient)", s.Scope, s.Failed(), wantFailed)
}
if s.FirstError() == "" {
t.Errorf("%s has no recorded error", s.Scope)
}
}
status, err := st.NotificationSummary(context.Background())
if err != nil {
t.Fatalf("summary: %v", err)
}
if status.Sent != 4 || status.Failed != 4 {
t.Errorf("summary = %+v, want 4 sent and 4 failed", status)
}
rows, _ := st.RecentNotifications(context.Background(), 20)
for _, n := range rows {
if n.Channel == config.ChannelWhatsApp {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("whatsapp row = %+v, want a loud failure", n)
}
}
}
}
// TestFailedScanIsNeverNothingDue proves FR-12: a child whose last scan failed
// gets an explicit warning instead of a clean, empty brief.
func TestFailedScanIsNeverNothingDue(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, _ := newService(t, srv.URL, func(st *store.Store) {
seedItems(st)
harry, _ := st.ChildByName(context.Background(), "Harry")
if _, err := st.RecordScan(context.Background(), &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
t.Fatal(err)
}
})
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail})
if err != nil {
t.Fatalf("morning: %v", err)
}
if len(sends[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" {
t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed)
}
if !strings.Contains(sends[0].Text, "SCAN FAILED") {
t.Errorf("brief does not say the scan failed: %q", sends[0].Text)
}
if !strings.Contains(sends[0].Text, "UNKNOWN") {
t.Errorf("brief does not say due work is unknown: %q", sends[0].Text)
}
if !strings.Contains(sends[2].Text, "SCAN FAILED") {
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text)
}
if sends[0].OK() {
t.Error("a scan failure must make the run not OK")
}
}
// TestNoScanYetIsLoud proves a child that has never been scanned is never shown
// as "nothing due".
func TestNoScanYetIsLoud(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, st := newService(t, srv.URL, nil)
harry, _ := st.ChildByName(context.Background(), "Harry")
text, ok, err := svc.ChildrenText(context.Background(), *harry)
if err != nil {
t.Fatalf("brief: %v", err)
}
if ok {
t.Error("a child with no scan must not be reported as OK")
}
if !strings.Contains(text, "no scan has run yet") {
t.Errorf("brief = %q", text)
}
}
// TestUnconfiguredRecipientFails proves a name that is not configured fails
// loudly instead of sending somewhere unexpected.
func TestUnconfiguredRecipientFails(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, _ := newService(t, srv.URL, seedItems)
if _, err := svc.RecipientsFor([]string{"Nana"}); err == nil {
t.Fatal("want an error for an unknown recipient")
}
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", nil, []string{config.ChannelEmail}); err == nil {
t.Fatal("want an error for no recipients")
}
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", []config.Recipient{{Name: "Harry", TagBase: "harry"}}, nil); err == nil {
t.Fatal("want an error for no channels")
}
}

View File

@@ -0,0 +1,127 @@
package config
import (
"strings"
"testing"
"time"
)
// clearAppriseEnv removes every Apprise variable so a load starts from the
// defaults, not from the developer's environment.
func clearAppriseEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"SCHOOL_APPRISE_URL", "SCHOOL_APPRISE_KEY", "SCHOOL_APPRISE_TOKEN",
"SCHOOL_APPRISE_TIMEOUT", "SCHOOL_APPRISE_CHANNELS", "SCHOOL_APPRISE_TAGS",
"SCHOOL_APPRISE_PARENTS", "SCHOOL_NOTIFY_TOKEN",
} {
t.Setenv(k, "")
}
}
func TestLoadAppriseDefaults(t *testing.T) {
clearAppriseEnv(t)
cfg, err := loadApprise()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.BaseURL != DefaultAppriseURL {
t.Errorf("url = %q, want %q", cfg.BaseURL, DefaultAppriseURL)
}
if cfg.Enabled() {
t.Error("Enabled = true without a key")
}
if got := strings.Join(cfg.Missing(), ","); got != "SCHOOL_APPRISE_KEY" {
t.Errorf("missing = %q", got)
}
if cfg.Timeout != 15*time.Second {
t.Errorf("timeout = %v", cfg.Timeout)
}
if len(cfg.Channels) != 2 || cfg.Channels[0] != ChannelEmail || cfg.Channels[1] != ChannelWhatsApp {
t.Errorf("channels = %#v", cfg.Channels)
}
// The family recipients are the default, so a missing env value cannot
// silently send the briefs nowhere.
names := make([]string, 0, len(cfg.Recipients))
for _, r := range cfg.Recipients {
names = append(names, r.Name+"="+r.TagBase)
}
if got := strings.Join(names, ","); got != DefaultAppriseTags {
t.Errorf("recipients = %q, want %q", got, DefaultAppriseTags)
}
harry, ok := cfg.RecipientByName("harry")
if !ok {
t.Fatal("Harry is not configured")
}
if got := cfg.ChannelTag(harry, ChannelWhatsApp); got != "harry_whatsapp" {
t.Errorf("whatsapp tag = %q", got)
}
if got := cfg.ChannelTag(harry, ChannelEmail); got != "harry_email" {
t.Errorf("email tag = %q", got)
}
if got := cfg.ParentRecipients(); len(got) != 2 || got[0].Name != "Jo" || got[1].Name != "Sam" {
t.Errorf("parents = %#v", got)
}
if cfg.IsParent("Finn") {
t.Error("Finn is not a parent")
}
}
func TestLoadAppriseCustom(t *testing.T) {
clearAppriseEnv(t)
t.Setenv("SCHOOL_APPRISE_URL", "http://example.test:9000/")
t.Setenv("SCHOOL_APPRISE_KEY", "abc")
t.Setenv("SCHOOL_APPRISE_TIMEOUT", "3s")
t.Setenv("SCHOOL_APPRISE_CHANNELS", "email")
t.Setenv("SCHOOL_APPRISE_TAGS", "Harry=h")
t.Setenv("SCHOOL_APPRISE_PARENTS", "Harry")
t.Setenv("SCHOOL_NOTIFY_TOKEN", "n8n-token")
cfg, err := loadApprise()
if err != nil {
t.Fatalf("load: %v", err)
}
if !cfg.Enabled() {
t.Error("Enabled = false with a key")
}
if cfg.BaseURL != "http://example.test:9000" {
t.Errorf("url = %q", cfg.BaseURL)
}
if cfg.Timeout != 3*time.Second {
t.Errorf("timeout = %v", cfg.Timeout)
}
if len(cfg.Channels) != 1 || cfg.Channels[0] != ChannelEmail {
t.Errorf("channels = %#v", cfg.Channels)
}
if cfg.NotifyToken != "n8n-token" {
t.Errorf("notify token = %q", cfg.NotifyToken)
}
}
// TestLoadAppriseRejectsBadInput proves a typo fails at start-up instead of at
// 6:00am, when nobody is watching (FR-12).
func TestLoadAppriseRejectsBadInput(t *testing.T) {
cases := map[string]func(t *testing.T){
"unknown channel": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_CHANNELS", "sms") },
"unknown parent": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_PARENTS", "Nana") },
"bad tag entry": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_TAGS", "Harry") },
}
for name, set := range cases {
t.Run(name, func(t *testing.T) {
clearAppriseEnv(t)
set(t)
if _, err := loadApprise(); err == nil {
t.Fatal("want an error")
}
})
}
}
func TestValidChannels(t *testing.T) {
c := AppriseConfig{Channels: []string{ChannelEmail, "sms", ChannelWhatsApp}}
ok, bad := c.ValidChannels()
if len(ok) != 2 || len(bad) != 1 || bad[0] != "sms" {
t.Errorf("ok = %#v bad = %#v", ok, bad)
}
}

View File

@@ -46,6 +46,9 @@ type Config struct {
// Calendar is the Home Assistant calendar target for important school // Calendar is the Home Assistant calendar target for important school
// events (AGENTS.md §4 FR-8). // events (AGENTS.md §4 FR-8).
Calendar CalendarConfig Calendar CalendarConfig
// Apprise is the notification gateway for the morning briefs (FR-9,
// FR-14).
Apprise AppriseConfig
} }
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes // CalendarConfig points at Sam's Home Assistant calendar. Every value comes
@@ -91,6 +94,137 @@ func (c CalendarConfig) Missing() []string {
return out return out
} }
// AppriseConfig points at the house Apprise API on `.35:8210` (AGENTS.md §8)
// and names the destinations by tag. The school service never holds an email
// address, a phone number or an SMTP password; the Apprise API holds those and
// selects them by tag (AGENTS.md §10 rule 3).
//
// Environment variables:
//
// 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
// SCHOOL_APPRISE_TIMEOUT per-request timeout, default 15s
// SCHOOL_APPRISE_CHANNELS channels to use, default "email,whatsapp"
// SCHOOL_APPRISE_TAGS recipient=tag-base list, default
// "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// SCHOOL_APPRISE_PARENTS who gets the combined brief, default "Jo,Sam"
// SCHOOL_NOTIFY_TOKEN bearer token for POST /notify/morning (n8n)
//
// A channel tag is the recipient's tag base plus "_email" or "_whatsapp",
// which is the naming the house Apprise configuration already uses.
type AppriseConfig struct {
BaseURL string
// Key names the stored Apprise configuration that holds every destination.
Key string
// Token is an optional bearer token when the Apprise API is protected.
Token string
// Timeout bounds one delivery attempt.
Timeout time.Duration
// Channels are the channels the morning brief uses, in order. Each entry is
// "email" or "whatsapp".
Channels []string
// Recipients are the people a brief can be sent to, in order.
Recipients []Recipient
// Parents are the recipient names that receive the combined brief.
Parents []string
// NotifyToken guards POST /notify/morning, the endpoint n8n calls.
NotifyToken string
}
// Recipient is one person a brief can go to. TagBase is the Apprise tag prefix,
// for example "harry" for the tags harry_email and harry_whatsapp.
type Recipient struct {
Name string
TagBase string
}
// The channels Phase 7 delivers on (AGENTS.md §13 decision 8).
const (
ChannelEmail = "email"
ChannelWhatsApp = "whatsapp"
)
// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
const DefaultAppriseURL = "http://192.168.20.35:8210"
// DefaultAppriseTags is the recipient list already present in the house
// Apprise configuration: each boy, and both parents.
const DefaultAppriseTags = "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// DefaultAppriseParents is who receives the combined brief (FR-14).
const DefaultAppriseParents = "Jo,Sam"
// Enabled reports whether a brief can be delivered at all.
func (c AppriseConfig) Enabled() bool {
return c.BaseURL != "" && c.Key != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the gateway is usable.
func (c AppriseConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_APPRISE_URL")
}
if c.Key == "" {
out = append(out, "SCHOOL_APPRISE_KEY")
}
return out
}
// RecipientByName returns the configured recipient with the given name.
func (c AppriseConfig) RecipientByName(name string) (Recipient, bool) {
for _, r := range c.Recipients {
if strings.EqualFold(r.Name, strings.TrimSpace(name)) {
return r, true
}
}
return Recipient{}, false
}
// ChannelTag is the Apprise tag for one recipient and channel, for example
// "harry_email".
func (c AppriseConfig) ChannelTag(r Recipient, channel string) string {
return r.TagBase + "_" + strings.ToLower(strings.TrimSpace(channel))
}
// IsParent reports whether the recipient receives the combined brief.
func (c AppriseConfig) IsParent(name string) bool {
for _, p := range c.Parents {
if strings.EqualFold(strings.TrimSpace(p), strings.TrimSpace(name)) {
return true
}
}
return false
}
// ParentRecipients returns the configured parents, in order.
func (c AppriseConfig) ParentRecipients() []Recipient {
out := make([]Recipient, 0, len(c.Parents))
for _, name := range c.Parents {
if r, ok := c.RecipientByName(name); ok {
out = append(out, r)
}
}
return out
}
// ValidChannels returns the requested channels that are configured, in order,
// and the ones that are not.
func (c AppriseConfig) ValidChannels() ([]string, []string) {
var ok, bad []string
for _, ch := range c.Channels {
switch ch {
case ChannelEmail, ChannelWhatsApp:
ok = append(ok, ch)
default:
bad = append(bad, ch)
}
}
return ok, bad
}
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset. // DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
const DefaultTimezone = "Australia/Sydney" const DefaultTimezone = "Australia/Sydney"
@@ -137,6 +271,11 @@ func Load() (*Config, error) {
} }
cfg.Location = loc cfg.Location = loc
cfg.Calendar = loadCalendar() cfg.Calendar = loadCalendar()
apprise, err := loadApprise()
if err != nil {
return nil, err
}
cfg.Apprise = apprise
return cfg, nil return cfg, nil
} }
@@ -148,21 +287,95 @@ type DBConfig struct {
DBPath string DBPath string
Location *time.Location Location *time.Location
Calendar CalendarConfig Calendar CalendarConfig
Apprise AppriseConfig
} }
// LoadDB reads the database path and the family timezone only. // LoadDB reads the database path, the family timezone, the calendar target and
// the notification gateway only.
func LoadDB() (*DBConfig, error) { func LoadDB() (*DBConfig, error) {
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone)) loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil { if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
} }
apprise, err := loadApprise()
if err != nil {
return nil, err
}
return &DBConfig{ return &DBConfig{
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"), DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
Location: loc, Location: loc,
Calendar: loadCalendar(), Calendar: loadCalendar(),
Apprise: apprise,
}, nil }, nil
} }
// loadApprise reads the Apprise gateway settings. Like the calendar target it
// never fails on an empty value: the service boots before the gateway is
// configured, and every send then fails loudly instead of silently.
func loadApprise() (AppriseConfig, error) {
c := AppriseConfig{
BaseURL: strings.TrimRight(env("SCHOOL_APPRISE_URL", DefaultAppriseURL), "/"),
Key: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_KEY")),
Token: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TOKEN")),
Timeout: 15 * time.Second,
Channels: splitList(env("SCHOOL_APPRISE_CHANNELS", ChannelEmail+","+ChannelWhatsApp)),
Parents: splitList(env("SCHOOL_APPRISE_PARENTS", DefaultAppriseParents)),
NotifyToken: strings.TrimSpace(os.Getenv("SCHOOL_NOTIFY_TOKEN")),
}
if v := strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
c.Timeout = d
}
}
recipients, err := parseRecipients(env("SCHOOL_APPRISE_TAGS", DefaultAppriseTags))
if err != nil {
return c, fmt.Errorf("SCHOOL_APPRISE_TAGS: %w", err)
}
c.Recipients = recipients
for _, p := range c.Parents {
if _, ok := c.RecipientByName(p); !ok {
return c, fmt.Errorf("SCHOOL_APPRISE_PARENTS: %q is not in SCHOOL_APPRISE_TAGS", p)
}
}
if _, bad := c.ValidChannels(); len(bad) > 0 {
return c, fmt.Errorf("SCHOOL_APPRISE_CHANNELS: unknown channel(s) %s", strings.Join(bad, ", "))
}
return c, nil
}
// parseRecipients reads the "Name=tagbase,Name=tagbase" recipient list. The tag
// base names the Apprise destinations, so a name that is wrong fails at send
// time with the Apprise error, not here.
func parseRecipients(v string) ([]Recipient, error) {
var out []Recipient
for _, entry := range splitList(v) {
name, tag, found := strings.Cut(entry, "=")
name = strings.TrimSpace(name)
tag = strings.TrimSpace(tag)
if !found || name == "" || tag == "" {
return nil, fmt.Errorf("expected Name=tag entries, got %q", entry)
}
out = append(out, Recipient{Name: name, TagBase: tag})
}
if len(out) == 0 {
return nil, fmt.Errorf("no recipients configured")
}
return out, nil
}
// splitList splits a comma list and drops empty entries.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// loadCalendar reads the Home Assistant calendar target. It never fails: an // loadCalendar reads the Home Assistant calendar target. It never fails: an
// empty value simply leaves the target disabled, so the service boots before // empty value simply leaves the target disabled, so the service boots before
// the calendar is configured and the calendar commands fail loudly instead. // the calendar is configured and the calendar commands fail loudly instead.

View File

@@ -178,29 +178,50 @@ func startOfDay(t time.Time, loc *time.Location) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
} }
// Limits caps a section so a brief stays brief (AGENTS.md §10 rule 6). Overdue
// work is never capped; pending work without a due date is the long tail, so it
// is the one that is summarised.
type Limits struct {
// NoDue is the most no-due-date items a brief lists (0 means no cap).
NoDue int
}
// MorningLimits is the cap the per-boy and combined morning briefs use. The
// dashboard and the preview always show the full lists.
var MorningLimits = Limits{NoDue: 5}
// Render writes one child's brief as plain text: overdue first, then due today, // Render writes one child's brief as plain text: overdue first, then due today,
// due this week, no due date and messages (FR-9, FR-14, AGENTS.md §4 rule 6). // due this week, no due date and messages (FR-9, FR-14, AGENTS.md §4 rule 6).
func Render(child string, b Buckets, loc *time.Location) string { func Render(child string, b Buckets, loc *time.Location) string {
return RenderWithLimits(child, b, loc, Limits{})
}
// RenderWithLimits is Render with a cap on the long-tail sections.
func RenderWithLimits(child string, b Buckets, loc *time.Location, lim Limits) string {
if loc == nil { if loc == nil {
loc = time.UTC loc = time.UTC
} }
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s — %s.\n", child, b.Headline()) fmt.Fprintf(&sb, "%s — %s.\n", child, b.Headline())
writeSection(&sb, "OVERDUE", b.Overdue, loc) writeSection(&sb, "OVERDUE", b.Overdue, loc, 0)
writeSection(&sb, "DUE TODAY", b.DueToday, loc) writeSection(&sb, "DUE TODAY", b.DueToday, loc, 0)
writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc) writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc, 0)
writeSection(&sb, "NO DUE DATE", b.NoDue, loc) writeSection(&sb, "NO DUE DATE", b.NoDue, loc, lim.NoDue)
writeMessages(&sb, b.Messages, loc) writeMessages(&sb, b.Messages, loc)
return strings.TrimRight(sb.String(), "\n") + "\n" return strings.TrimRight(sb.String(), "\n") + "\n"
} }
func writeSection(sb *strings.Builder, title string, items []store.Item, loc *time.Location) { func writeSection(sb *strings.Builder, title string, items []store.Item, loc *time.Location, limit int) {
if len(items) == 0 { if len(items) == 0 {
return return
} }
fmt.Fprintf(sb, "\n%s\n", title) fmt.Fprintf(sb, "\n%s\n", title)
for _, it := range items { for i, it := range items {
if limit > 0 && i >= limit {
fmt.Fprintf(sb, "- … and %d more with no due date (see the dashboard)\n", len(items)-limit)
break
}
fmt.Fprintf(sb, "- %s\n", line(it, loc)) fmt.Fprintf(sb, "- %s\n", line(it, loc))
} }
} }
@@ -219,6 +240,110 @@ func writeMessages(sb *strings.Builder, items []store.Item, loc *time.Location)
} }
} }
// Combined is one child's share of the combined brief.
type Combined struct {
Child string
Buckets Buckets
}
// RenderCombined writes the single brief for Jo and Sam (FR-14): both boys in
// one message, overdue first, lists only (AGENTS.md §10 rule 6). Every line is
// prefixed with the child's name so a parent never has to guess whose work it
// is. It never claims "nothing due" by itself: the caller adds the scan warning
// when a scan failed (FR-12).
func RenderCombined(parts []Combined, loc *time.Location) string {
return RenderCombinedWithLimits(parts, loc, Limits{})
}
// RenderCombinedWithLimits is RenderCombined with a cap on the long tail.
func RenderCombinedWithLimits(parts []Combined, loc *time.Location, lim Limits) string {
if loc == nil {
loc = time.UTC
}
names := make([]string, 0, len(parts))
for _, p := range parts {
names = append(names, p.Child)
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s — %s.\n", joinNames(names), combinedHeadline(parts))
writeCombined(&sb, "OVERDUE", parts, loc, 0, func(b Buckets) []store.Item { return b.Overdue })
writeCombined(&sb, "DUE TODAY", parts, loc, 0, func(b Buckets) []store.Item { return b.DueToday })
writeCombined(&sb, "DUE THIS WEEK", parts, loc, 0, func(b Buckets) []store.Item { return b.DueWeek })
writeCombined(&sb, "NO DUE DATE", parts, loc, lim.NoDue, func(b Buckets) []store.Item { return b.NoDue })
writeCombinedMessages(&sb, parts, loc)
return strings.TrimRight(sb.String(), "\n") + "\n"
}
// joinNames renders "Harry and Finn" for any number of children.
func joinNames(names []string) string {
switch len(names) {
case 0:
return "School brief"
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
}
// combinedHeadline counts both boys in one line, so the whole family sees the
// size of the day before the lists.
func combinedHeadline(parts []Combined) string {
var overdue, today, week, messages int
for _, p := range parts {
overdue += len(p.Buckets.Overdue)
today += len(p.Buckets.DueToday)
week += len(p.Buckets.DueWeek)
messages += len(p.Buckets.Messages)
}
return fmt.Sprintf("%d overdue · %d due today · %d this week · %d message(s)",
overdue, today, week, messages)
}
func writeCombined(sb *strings.Builder, title string, parts []Combined, loc *time.Location, limit int, pick func(Buckets) []store.Item) {
var lines []string
for _, p := range parts {
for _, it := range pick(p.Buckets) {
lines = append(lines, fmt.Sprintf("- %s: %s\n", p.Child, line(it, loc)))
}
}
if len(lines) == 0 {
return
}
fmt.Fprintf(sb, "\n%s\n", title)
for i, l := range lines {
if limit > 0 && i >= limit {
fmt.Fprintf(sb, "- … and %d more with no due date (see the dashboard)\n", len(lines)-limit)
break
}
sb.WriteString(l)
}
}
func writeCombinedMessages(sb *strings.Builder, parts []Combined, loc *time.Location) {
var lines []string
for _, p := range parts {
for _, it := range p.Buckets.Messages {
when := ""
if it.DueAt != nil {
when = " (" + it.DueAt.In(loc).Format("Mon 02 Jan 15:04") + ")"
}
lines = append(lines, fmt.Sprintf("- %s: %s%s\n", p.Child, it.Title, when))
}
}
if len(lines) == 0 {
return
}
sb.WriteString("\nMESSAGES\n")
for _, l := range lines {
sb.WriteString(l)
}
}
// line renders one actionable item for the brief. // line renders one actionable item for the brief.
func line(it store.Item, loc *time.Location) string { func line(it store.Item, loc *time.Location) string {
subject := strings.TrimSpace(it.Subject) subject := strings.TrimSpace(it.Subject)

View File

@@ -167,3 +167,96 @@ func equal(a, b []string) bool {
} }
return true return true
} }
// TestRenderCombinedBothBoys proves FR-14: the combined brief for Jo and Sam
// carries both boys, overdue first, and every line names the child.
func TestRenderCombinedBothBoys(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
harry := Classify([]store.Item{
{Type: "task", Title: "Essay", Subject: "English", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
{Type: "task", Title: "Worksheet", Subject: "Maths", DueAt: due(t, loc, "2026-10-15 15:00")},
{Type: "message", Title: "Sports day", DueAt: due(t, loc, "2026-10-14 07:00")},
}, now, loc)
finn := Classify([]store.Item{
{Type: "task", Title: "Lab report", Subject: "Science", DueAt: due(t, loc, "2026-10-09 09:00"), Body: "Status: Overdue"},
}, now, loc)
text := RenderCombined([]Combined{
{Child: "Harry", Buckets: harry},
{Child: "Finn", Buckets: finn},
}, loc)
if !strings.HasPrefix(text, "Harry and Finn — 2 overdue · 1 due today · 0 this week · 1 message(s).\n") {
t.Errorf("headline = %q", firstLineOf(text))
}
overdue := strings.Index(text, "OVERDUE")
today := strings.Index(text, "DUE TODAY")
if overdue < 0 || today < 0 || overdue > today {
t.Errorf("overdue must come first:\n%s", text)
}
for _, want := range []string{
"- Harry: English: Essay (due Sat 10 Oct 09:00) [overdue]",
"- Finn: Science: Lab report (due Fri 09 Oct 09:00) [overdue]",
"- Harry: Maths: Worksheet (due Thu 15 Oct 15:00)",
"MESSAGES",
"- Harry: Sports day (Wed 14 Oct 07:00)",
} {
if !strings.Contains(text, want) {
t.Errorf("combined brief is missing %q:\n%s", want, text)
}
}
}
// TestRenderCombinedSingleChild proves one child's brief still renders a
// sensible combined message.
func TestRenderCombinedSingleChild(t *testing.T) {
loc := sydney(t)
text := RenderCombined([]Combined{{Child: "Harry"}}, loc)
if !strings.HasPrefix(text, "Harry — 0 overdue") {
t.Errorf("text = %q", text)
}
if strings.Contains(text, "OVERDUE") {
t.Errorf("an empty brief must not show an OVERDUE heading: %q", text)
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// TestRenderWithLimitsKeepsTheBriefBrief proves the long tail is summarised, so
// the morning message stays a brief (AGENTS.md §10 rule 6) without hiding work.
func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
items := make([]store.Item, 0, 8)
for _, title := range []string{"A", "B", "C", "D", "E", "F", "G", "H"} {
items = append(items, store.Item{Type: "task", Title: "Pending " + title, Body: "Status: Pending"})
}
items = append(items, store.Item{
Type: "task", Title: "Essay", Subject: "English",
DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue",
})
b := Classify(items, now, loc)
limited := RenderWithLimits("Harry", b, loc, Limits{NoDue: 3})
if !strings.Contains(limited, "… and 5 more with no due date") {
t.Errorf("the long tail is not summarised:\n%s", limited)
}
if strings.Contains(limited, "Pending D") {
t.Errorf("the cap did not apply:\n%s", limited)
}
// Overdue work is never hidden by a cap.
if !strings.Contains(limited, "English: Essay") {
t.Errorf("overdue work went missing:\n%s", limited)
}
if full := Render("Harry", b, loc); !strings.Contains(full, "Pending H") {
t.Errorf("the full render must keep every item:\n%s", full)
}
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"time"
) )
// This file holds the read/write helpers the Phase 5 dashboard needs: // This file holds the read/write helpers the Phase 5 dashboard needs:
@@ -131,93 +130,43 @@ func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanSta
return st, nil return st, nil
} }
// Summary is one stored brief (AGENTS.md §7 notifications): either a draft // Draft briefs (FR-10). A brief is a row in `notifications`; the delivery log
// (SentAt nil) or a delivered brief. It is the summary half of FR-10. // and the per-channel rows live in notify.go. CreateDraft stores a brief that
type Summary struct { // has not been sent yet, so the Send panel can preview it first.
ID int64
Recipient string // CreateSummary stores a brief for one recipient as a draft (sent_at NULL). It
Text string // is CreateDraft with the scope defaulted to the recipient.
SentAt *time.Time func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
CreatedAt time.Time return s.CreateDraft(ctx, recipient, recipient, text)
} }
// CreateSummary stores a brief for one recipient. sent_at stays NULL for a // CreateDraft stores a brief that has not been sent yet. scope is the child
// draft; Phase 7 sets it when the brief is sent. // name, or "combined" for the brief to Jo and Sam (FR-9, FR-14).
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) { func (s *Store) CreateDraft(ctx context.Context, scope, recipient, text string) (int64, error) {
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
`INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`, `INSERT INTO notifications (scope, recipient, channel, summary, sent_at)
recipient, text) VALUES (?, ?, '', ?, NULL)`,
scope, recipient, text)
if err != nil { if err != nil {
return 0, fmt.Errorf("create summary for %q: %w", recipient, err) return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
} }
id, err := res.LastInsertId() id, err := res.LastInsertId()
if err != nil { if err != nil {
return 0, fmt.Errorf("create summary for %q: %w", recipient, err) return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
} }
return id, nil return id, nil
} }
// ListSummaries returns recent briefs, newest first. // DeleteNotification removes one brief or delivery row. It returns the number
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) { // of rows removed.
if limit <= 0 { func (s *Store) DeleteNotification(ctx context.Context, id int64) (int64, error) {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, recipient, summary, sent_at, created_at
FROM notifications ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list summaries: %w", err)
}
defer rows.Close()
var out []Summary
for rows.Next() {
var sm Summary
var sent sql.NullString
var created string
if err := rows.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
return nil, fmt.Errorf("scan summary: %w", err)
}
if sent.Valid {
t := parseTime(sent.String)
sm.SentAt = &t
}
sm.CreatedAt = parseTime(created)
out = append(out, sm)
}
return out, rows.Err()
}
// SummaryByID returns one brief, or nil when it does not exist.
func (s *Store) SummaryByID(ctx context.Context, id int64) (*Summary, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, recipient, summary, sent_at, created_at FROM notifications WHERE id = ?`, id)
var sm Summary
var sent sql.NullString
var created string
if err := row.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("summary %d: %w", id, err)
}
if sent.Valid {
t := parseTime(sent.String)
sm.SentAt = &t
}
sm.CreatedAt = parseTime(created)
return &sm, nil
}
// DeleteSummary removes one stored brief. It returns the number of rows removed.
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id) res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id)
if err != nil { if err != nil {
return 0, fmt.Errorf("delete summary %d: %w", id, err) return 0, fmt.Errorf("delete notification %d: %w", id, err)
} }
n, err := res.RowsAffected() n, err := res.RowsAffected()
if err != nil { if err != nil {
return 0, fmt.Errorf("delete summary %d: %w", id, err) return 0, fmt.Errorf("delete notification %d: %w", id, err)
} }
return n, nil return n, nil
} }

View File

@@ -83,22 +83,22 @@ func TestSummariesRoundTrip(t *testing.T) {
if err != nil || id == 0 { if err != nil || id == 0 {
t.Fatalf("create: %v id=%d", err, id) t.Fatalf("create: %v id=%d", err, id)
} }
list, err := st.ListSummaries(ctx, 10) list, err := st.RecentNotifications(ctx, 10)
if err != nil || len(list) != 1 { if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list)) t.Fatalf("list: %v len=%d", err, len(list))
} }
if list[0].Recipient != "Harry" || list[0].SentAt != nil { if list[0].Recipient != "Harry" || list[0].SentAt != nil || list[0].Channel != "" {
t.Errorf("summary = %+v, want draft for Harry", list[0]) t.Errorf("summary = %+v, want draft for Harry", list[0])
} }
got, err := st.SummaryByID(ctx, id) got, err := st.NotificationByID(ctx, id)
if err != nil || got == nil || got.Text != list[0].Text { if err != nil || got == nil || got.Text != list[0].Text {
t.Fatalf("by id: %v %+v", err, got) t.Fatalf("by id: %v %+v", err, got)
} }
n, err := st.DeleteSummary(ctx, id) n, err := st.DeleteNotification(ctx, id)
if err != nil || n != 1 { if err != nil || n != 1 {
t.Fatalf("delete = %d, %v", n, err) t.Fatalf("delete = %d, %v", n, err)
} }
if got, _ := st.SummaryByID(ctx, id); got != nil { if got, _ := st.NotificationByID(ctx, id); got != nil {
t.Error("summary survived delete") t.Error("summary survived delete")
} }
} }

View File

@@ -0,0 +1,20 @@
-- Migration 004 — Phase 7 notify and send (AGENTS.md §4 FR-9/FR-13/FR-14,
-- §11 Phase 7).
--
-- `notifications` becomes the delivery log: one row per brief per recipient per
-- channel. The brief text is the `summary` column (it was already there), and
-- the new columns record where the row belongs (scope), which channel and
-- Apprise tag were used, whether the delivery succeeded, and the redacted error
-- when it did not. A failed send is therefore visible and loud instead of being
-- lost (FR-12).
--
-- A draft (built but not sent) is a row with an empty `channel`, ok = 0 and
-- sent_at NULL, which is what the Phase 5 dashboard already showed.
ALTER TABLE notifications ADD COLUMN scope TEXT NOT NULL DEFAULT '';
ALTER TABLE notifications ADD COLUMN channel TEXT NOT NULL DEFAULT '';
ALTER TABLE notifications ADD COLUMN tag TEXT NOT NULL DEFAULT '';
ALTER TABLE notifications ADD COLUMN ok INTEGER NOT NULL DEFAULT 0;
ALTER TABLE notifications ADD COLUMN error TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_notifications_scope ON notifications(scope, created_at);

169
internal/store/notify.go Normal file
View File

@@ -0,0 +1,169 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// This file holds the Phase 7 delivery helpers (AGENTS.md §4 FR-9, FR-13,
// FR-14): one row in `notifications` per brief per recipient per channel, the
// draft that the Send panel previews, and the summary the dashboard uses to
// show a failed send loudly (FR-12).
// Notification is one brief row: either a draft, or one delivery attempt on one
// channel. It is both the stored summary (FR-10) and the send log (FR-13).
type Notification struct {
ID int64
// Scope is the child name, or "combined" for the brief to Jo and Sam.
Scope string
// Recipient is the person the brief is for, for example "Harry" or "Jo+Sam".
Recipient string
// Channel is "email", "whatsapp", or "" for a draft.
Channel string
// Tag is the Apprise tag the delivery used, for example "harry_email".
Tag string
// Text is the brief itself (the `summary` column).
Text string
// OK reports whether the delivery succeeded.
OK bool
// Error is the redacted failure text, empty on success.
Error string
// SentAt is when the brief was delivered, or nil for a draft or a failure.
SentAt *time.Time
// CreatedAt is when the row was written.
CreatedAt time.Time
}
// notificationSelect is the shared column list for a notification row.
const notificationSelect = `SELECT id, COALESCE(scope, ''), recipient, COALESCE(channel, ''), COALESCE(tag, ''),
summary, ok, COALESCE(error, ''), sent_at, created_at FROM notifications`
func scanNotification(r rowScanner) (Notification, error) {
var n Notification
var sent sql.NullString
var created string
if err := r.Scan(&n.ID, &n.Scope, &n.Recipient, &n.Channel, &n.Tag,
&n.Text, &n.OK, &n.Error, &sent, &created); err != nil {
return n, err
}
if sent.Valid {
t := parseTime(sent.String)
n.SentAt = &t
}
n.CreatedAt = parseTime(created)
return n, nil
}
// RecordNotification writes one brief or one delivery attempt. sent_at is NULL
// while the delivery failed, so a failure can never be read as "delivered".
func (s *Store) RecordNotification(ctx context.Context, n Notification) (int64, error) {
var sentAt any
if n.SentAt != nil {
sentAt = n.SentAt.UTC().Format(time.RFC3339)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO notifications (scope, recipient, channel, tag, summary, ok, error, sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
n.Scope, n.Recipient, n.Channel, n.Tag, n.Text, n.OK, n.Error, sentAt)
if err != nil {
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
}
return id, nil
}
// MarkSummarySent stamps a stored draft as sent and records which channel and
// tag delivered it.
func (s *Store) MarkSummarySent(ctx context.Context, id int64, channel, tag string, ok bool, errText string, at time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE notifications SET sent_at = ?, channel = ?, tag = ?, ok = ?, error = ? WHERE id = ?`,
at.UTC().Format(time.RFC3339), channel, tag, ok, errText, id)
if err != nil {
return fmt.Errorf("mark summary %d sent: %w", id, err)
}
return nil
}
// RecentNotifications returns the newest brief rows, newest first. limit <= 0
// means twenty.
func (s *Store) RecentNotifications(ctx context.Context, limit int) ([]Notification, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
notificationSelect+` ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("recent notifications: %w", err)
}
defer rows.Close()
var out []Notification
for rows.Next() {
n, err := scanNotification(rows)
if err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// NotificationStatus counts the delivery attempts so the dashboard can show a
// failed send loudly (FR-12).
type NotificationStatus struct {
// Total is every delivery attempt on record.
Total int
// Failed is the attempts that did not deliver.
Failed int
// Sent is the attempts that delivered.
Sent int
// Drafts is the stored briefs that were never sent.
Drafts int
// Last is the newest attempt, or nil.
Last *Notification
}
// EverRun reports whether any delivery has been attempted.
func (st NotificationStatus) EverRun() bool { return st.Total > 0 }
// NotificationSummary reads the delivery log summary.
func (s *Store) NotificationSummary(ctx context.Context) (NotificationStatus, error) {
var st NotificationStatus
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN ok = 0 AND channel <> '' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN channel = '' THEN 1 ELSE 0 END), 0)
FROM notifications`).
Scan(&st.Total, &st.Failed, &st.Sent, &st.Drafts); err != nil {
return st, fmt.Errorf("notification summary: %w", err)
}
row := s.db.QueryRowContext(ctx, notificationSelect+` ORDER BY id DESC LIMIT 1`)
n, err := scanNotification(row)
if err == sql.ErrNoRows {
return st, nil
}
if err != nil {
return st, fmt.Errorf("last notification: %w", err)
}
st.Last = &n
return st, nil
}
// NotificationByID returns one brief row, or nil when it does not exist.
func (s *Store) NotificationByID(ctx context.Context, id int64) (*Notification, error) {
row := s.db.QueryRowContext(ctx, notificationSelect+` WHERE id = ?`, id)
n, err := scanNotification(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("notification %d: %w", id, err)
}
return &n, nil
}

View File

@@ -0,0 +1,127 @@
package store
import (
"context"
"strings"
"testing"
"time"
)
// TestNotificationDeliveryLog covers the Phase 7 delivery row: a delivered
// brief records the channel, the tag and sent_at, and a failure records the
// error without a sent_at, so a failure can never read as delivered (FR-12).
func TestNotificationDeliveryLog(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
sent := time.Date(2026, 10, 16, 6, 0, 0, 0, time.UTC)
okID, err := st.RecordNotification(ctx, Notification{
Scope: "Harry", Recipient: "Harry", Channel: "email", Tag: "harry_email",
Text: "Harry — 1 overdue.", OK: true, SentAt: &sent,
})
if err != nil {
t.Fatalf("record ok: %v", err)
}
if _, err := st.RecordNotification(ctx, Notification{
Scope: "Harry", Recipient: "Harry", Channel: "whatsapp", Tag: "harry_whatsapp",
Text: "Harry — 1 overdue.", OK: false, Error: "apprise returned status 424",
}); err != nil {
t.Fatalf("record fail: %v", err)
}
got, err := st.NotificationByID(ctx, okID)
if err != nil || got == nil {
t.Fatalf("by id: %v %v", got, err)
}
if !got.OK || got.Channel != "email" || got.Tag != "harry_email" || got.SentAt == nil {
t.Errorf("row = %+v", got)
}
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("recent = %d, %v", len(rows), err)
}
var failed *Notification
for i := range rows {
if !rows[i].OK {
failed = &rows[i]
}
}
if failed == nil {
t.Fatal("the failed delivery row is missing")
}
if failed.SentAt != nil {
t.Error("a failed delivery must not carry sent_at")
}
if failed.Error == "" {
t.Error("a failed delivery must record its error")
}
st2, err := st.NotificationSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if st2.Total != 2 || st2.Sent != 1 || st2.Failed != 1 || st2.Drafts != 0 {
t.Errorf("summary = %+v", st2)
}
if st2.Last == nil || st2.Last.Tag != "harry_whatsapp" {
t.Errorf("last = %+v", st2.Last)
}
}
// TestDraftAndMarkSent covers the Send action on a stored brief (FR-10): a
// draft starts unsent and is stamped only when a channel delivered.
func TestDraftAndMarkSent(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
id, err := st.CreateDraft(ctx, "combined", "Jo+Sam", "School brief — Harry and Finn")
if err != nil {
t.Fatalf("draft: %v", err)
}
draft, err := st.NotificationByID(ctx, id)
if err != nil || draft == nil {
t.Fatalf("by id: %v %v", draft, err)
}
if draft.Scope != "combined" || draft.Channel != "" || draft.OK || draft.SentAt != nil {
t.Errorf("draft = %+v", draft)
}
at := time.Date(2026, 10, 16, 6, 1, 0, 0, time.UTC)
if err := st.MarkSummarySent(ctx, id, "email", "joanna_email", true, "", at); err != nil {
t.Fatalf("mark sent: %v", err)
}
got, _ := st.NotificationByID(ctx, id)
if got == nil || !got.OK || got.SentAt == nil || got.Channel != "email" {
t.Errorf("after send = %+v", got)
}
st2, _ := st.NotificationSummary(ctx)
if st2.Drafts != 0 || st2.Sent != 1 {
t.Errorf("summary = %+v", st2)
}
}
// TestNotificationSummaryCountsFailures proves a failed send is visible in the
// summary the dashboard shows (FR-12).
func TestNotificationSummaryCountsFailures(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
if _, err := st.RecordNotification(ctx, Notification{
Scope: "combined", Recipient: "Jo", Channel: "whatsapp", Tag: "joanna_whatsapp",
Text: "brief", Error: "apprise returned status 424: <redacted>",
}); err != nil {
t.Fatalf("record: %v", err)
}
status, err := st.NotificationSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if status.Failed != 1 || status.Sent != 0 || !status.EverRun() {
t.Errorf("status = %+v", status)
}
if !strings.Contains(status.Last.Error, "424") {
t.Errorf("last error = %q", status.Last.Error)
}
}

View File

@@ -1,11 +1,12 @@
// Package web is the HTTP surface of the Compass/NHS service: /health, the // Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard // service's own session login, the server-rendered HTMX dashboard, the Send
// (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail // panel and the machine endpoint the 6:00am n8n schedule calls (AGENTS.md §5,
// screen, and the Delete actions (FR-7, FR-10, FR-12). // §9, §11 Phase 7).
package web package web
import ( import (
"context" "context"
"crypto/subtle"
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -13,11 +14,14 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/brief"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
@@ -37,6 +41,7 @@ type Server struct {
auth *auth.Manager auth *auth.Manager
tmpl *template.Template tmpl *template.Template
log *slog.Logger log *slog.Logger
brief *brief.Service
} }
// New builds a Server and parses the embedded templates. // New builds a Server and parses the embedded templates.
@@ -50,7 +55,9 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
if err != nil { if err != nil {
return nil, fmt.Errorf("parse templates: %w", err) return nil, fmt.Errorf("parse templates: %w", err)
} }
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout)
svc := brief.New(st, client, cfg.Apprise, cfg.Location, log)
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc}, nil
} }
// Handler returns the fully wrapped HTTP handler. // Handler returns the fully wrapped HTTP handler.
@@ -67,9 +74,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete)) mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush)) mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild)) mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
mux.HandleFunc("POST /summaries/send", s.requireAuth(s.handleSendBrief))
mux.HandleFunc("POST /summaries/{id}/send", s.requireAuth(s.handleSummarySend))
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete)) mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial)) mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
// The endpoint the 6:00am n8n schedule calls (AGENTS.md §11 Phase 7). It is
// machine-to-machine: a bearer token, not the dashboard session.
mux.HandleFunc("POST /notify/morning", s.handleMorningNotify)
staticSub, err := fs.Sub(staticFS, "static") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
panic("web: embedded static dir missing: " + err.Error()) panic("web: embedded static dir missing: " + err.Error())
@@ -380,27 +393,82 @@ type childTab struct {
type summaryView struct { type summaryView struct {
ID int64 ID int64
Scope string
Recipient string Recipient string
Channel string
Tag string
Created string Created string
Sent string Sent string
Draft bool Draft bool
Failed bool
Error string
Text string Text string
} }
// notifyBannerView is the fail-loudly view of the Apprise gateway (FR-12).
type notifyBannerView struct {
// Level is "off", "warn", "error" or "ok".
Level string
Message string
Sent int
Failed int
LastAt string
}
// OK reports whether the last delivery attempt succeeded.
func (v notifyBannerView) OK() bool { return v.Level == "ok" }
// channelOption is one tick box in the Send panel.
type channelOption struct {
Name string
Label string
Checked bool
}
// recipientOption is one recipient tick box in the Send panel.
type recipientOption struct {
Name string
Tag string
Checked bool
}
// sendScopeView is one brief the Send panel can build, preview and send:
// a child's own brief, or the combined brief to Jo and Sam.
type sendScopeView struct {
Key string
Label string
Preview string
Warning string
Recipients []recipientOption
}
// sendPanelView is the Send panel (AGENTS.md §5 screen 4): choose recipients,
// preview the brief, send.
type sendPanelView struct {
Enabled bool
Missing []string
Channels []channelOption
Scopes []sendScopeView
}
type dashboardData struct { type dashboardData struct {
Title string Title string
Username string Username string
CSRF string CSRF string
Version string Version string
AppEnv string AppEnv string
Scan scanStatusView Scan scanStatusView
Calendar calendarBannerView Calendar calendarBannerView
Tabs []childTab Notify notifyBannerView
Child childPanel Tabs []childTab
HasChild bool Child childPanel
Summaries []summaryView HasChild bool
ServerUp string Summaries []summaryView
ErrMsg string Send sendPanelView
ServerUp string
ErrMsg string
Flash string
FlashLevel string
} }
var serverStarted = time.Now() var serverStarted = time.Now()
@@ -418,9 +486,19 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
AppEnv: s.cfg.AppEnv, AppEnv: s.cfg.AppEnv,
Scan: s.scanStatus(ctx), Scan: s.scanStatus(ctx),
Calendar: s.calendarBanner(ctx), Calendar: s.calendarBanner(ctx),
Notify: s.notifyBanner(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
} }
// A send reports back on the dashboard: what went out, or what failed.
if msg := strings.TrimSpace(r.URL.Query().Get("msg")); msg != "" {
data.Flash = msg
data.FlashLevel = "ok"
}
if level := strings.TrimSpace(r.URL.Query().Get("level")); level == "error" {
data.FlashLevel = "error"
data.ErrMsg = ""
}
children, err := s.store.ListChildren(ctx) children, err := s.store.ListChildren(ctx)
if err != nil { if err != nil {
@@ -466,22 +544,32 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
} }
summaries, err := s.store.ListSummaries(ctx, 10) data.Send = s.sendPanel(ctx, children)
notifications, err := s.store.RecentNotifications(ctx, 12)
if err != nil { if err != nil {
s.log.Error("list summaries", "err", err) s.log.Error("list notifications", "err", err)
} }
for _, sm := range summaries { for _, n := range notifications {
v := summaryView{ v := summaryView{
ID: sm.ID, ID: n.ID,
Recipient: sm.Recipient, Scope: n.Scope,
Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"), Recipient: n.Recipient,
Text: sm.Text, Channel: n.Channel,
Draft: sm.SentAt == nil, Tag: n.Tag,
Created: n.CreatedAt.In(loc).Format("Mon 02 Jan 15:04"),
Text: n.Text,
Draft: n.Channel == "",
Failed: n.Channel != "" && !n.OK,
Error: n.Error,
} }
if sm.SentAt != nil { switch {
v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04") case n.Channel == "":
} else {
v.Sent = "draft" v.Sent = "draft"
case n.SentAt != nil:
v.Sent = n.SentAt.In(loc).Format("Mon 02 Jan 15:04")
default:
v.Sent = "failed"
} }
data.Summaries = append(data.Summaries, v) data.Summaries = append(data.Summaries, v)
} }
@@ -489,6 +577,103 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "dashboard.html", data) s.render(w, r, "dashboard.html", data)
} }
// notifyBanner is the fail-loudly banner for the Apprise gateway (FR-12).
func (s *Server) notifyBanner(ctx context.Context) notifyBannerView {
ac := s.cfg.Apprise
if !ac.Enabled() {
return notifyBannerView{
Level: "warn",
Message: "NOTIFICATIONS NOT CONFIGURED — the morning briefs cannot be sent. Set " + strings.Join(ac.Missing(), ", ") + ".",
}
}
st, err := s.store.NotificationSummary(ctx)
if err != nil {
s.log.Error("notification summary", "err", err)
return notifyBannerView{Level: "error", Message: "Could not read the delivery log: " + err.Error()}
}
v := notifyBannerView{Sent: st.Sent, Failed: st.Failed}
if st.Last != nil {
v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
if st.Failed > 0 && st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = fmt.Sprintf("SEND FAILED — the last brief delivery did not complete (%s/%s). It was NOT delivered.",
st.Last.Recipient, channelLabel(st.Last.Channel))
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
return v
}
if !st.EverRun() || st.Sent == 0 {
v.Level = "warn"
v.Message = "No brief has been sent yet."
return v
}
v.Level = "ok"
v.Message = fmt.Sprintf("%d delivery attempt(s) delivered, %d failed.", st.Sent, st.Failed)
return v
}
// sendPanel builds the Send panel (AGENTS.md §5 screen 4): one card per brief
// that can go out, each with a preview, the recipients and the channels.
func (s *Server) sendPanel(ctx context.Context, children []store.Child) sendPanelView {
ac := s.cfg.Apprise
p := sendPanelView{Enabled: ac.Enabled(), Missing: ac.Missing()}
for _, ch := range ac.Channels {
p.Channels = append(p.Channels, channelOption{Name: ch, Label: channelLabel(ch), Checked: true})
}
parents := ac.ParentRecipients()
for _, c := range children {
scope := sendScopeView{Key: strconv.FormatInt(c.ID, 10), Label: c.Name}
text, scanOK, err := s.brief.ChildrenText(ctx, c)
if err != nil {
s.log.Error("brief preview", "child", c.Name, "err", err)
scope.Warning = "Could not build the brief: " + err.Error()
}
scope.Preview = text
if !scanOK {
scope.Warning = "The last scan for " + c.Name + " did not complete. The brief says so and must not be read as “nothing due” (FR-12)."
}
if r, ok := ac.RecipientByName(c.Name); ok {
scope.Recipients = append(scope.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail), Checked: true})
}
for _, r := range parents {
scope.Recipients = append(scope.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail)})
}
p.Scopes = append(p.Scopes, scope)
}
combined := sendScopeView{Key: brief.ScopeCombined, Label: brief.CombinedNames(children) + " (combined)"}
text, failed, err := s.brief.CombinedText(ctx, children)
if err != nil {
s.log.Error("combined brief preview", "err", err)
combined.Warning = "Could not build the combined brief: " + err.Error()
}
combined.Preview = text
if len(failed) > 0 {
combined.Warning = "The last scan did not complete for " + strings.Join(failed, ", ") + ". The brief says so (FR-12)."
}
for _, r := range parents {
combined.Recipients = append(combined.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail), Checked: true})
}
p.Scopes = append(p.Scopes, combined)
return p
}
func channelLabel(ch string) string {
switch ch {
case config.ChannelEmail:
return "Email"
case config.ChannelWhatsApp:
return "WhatsApp"
case "":
return "draft"
default:
return ch
}
}
// buildChildPanel classifies one child's items and prepares the sections. // buildChildPanel classifies one child's items and prepares the sections.
func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel { func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel {
items, err := s.store.ItemsForChild(ctx, c.ID) items, err := s.store.ItemsForChild(ctx, c.ID)
@@ -777,6 +962,33 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session expired", http.StatusForbidden) http.Error(w, "session expired", http.StatusForbidden)
return return
} }
// The combined brief is built from both children (FR-14); a child brief is
// built from that child's items only.
if scope := strings.TrimSpace(r.PostFormValue("scope")); strings.EqualFold(scope, brief.ScopeCombined) {
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
http.Error(w, "could not read the children", http.StatusInternalServerError)
return
}
text, _, err := s.brief.CombinedText(ctx, children)
if err != nil {
s.log.Error("combined brief", "err", err)
http.Error(w, "could not build the combined brief", http.StatusInternalServerError)
return
}
id, err := s.store.CreateDraft(ctx, brief.ScopeCombined, brief.CombinedNames(children), text)
if err != nil {
s.log.Error("create combined draft", "err", err)
http.Error(w, "could not store the summary", http.StatusInternalServerError)
return
}
s.log.Info("combined summary built", "id", id)
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape("Combined brief built as a draft."))
return
}
childID, err := strconv.ParseInt(r.PostFormValue("child"), 10, 64) childID, err := strconv.ParseInt(r.PostFormValue("child"), 10, 64)
if err != nil { if err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@@ -787,13 +999,12 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
items, err := s.store.ItemsForChild(ctx, childID) text, _, err := s.brief.ChildrenText(ctx, *child)
if err != nil { if err != nil {
s.log.Error("items for child", "child", child.Name, "err", err) s.log.Error("build brief", "child", child.Name, "err", err)
http.Error(w, "could not read the items", http.StatusInternalServerError) http.Error(w, "could not read the items", http.StatusInternalServerError)
return return
} }
text := dashboard.Render(child.Name, dashboard.Classify(items, time.Now(), s.cfg.Location), s.cfg.Location)
id, err := s.store.CreateSummary(ctx, child.Name, text) id, err := s.store.CreateSummary(ctx, child.Name, text)
if err != nil { if err != nil {
s.log.Error("create summary", "child", child.Name, "err", err) s.log.Error("create summary", "child", child.Name, "err", err)
@@ -804,6 +1015,348 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(childID, 10)) s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(childID, 10))
} }
// ---------- Send panel (FR-9, FR-10, §5 screen 4) ----------
// handleSendBrief builds the brief for the chosen scope and delivers it to the
// chosen recipients on the chosen channels. Every attempt is recorded, so a
// failure is visible on the page and in `notifications` (FR-12).
func (s *Server) handleSendBrief(w http.ResponseWriter, r *http.Request) {
// Sending waits on SMTP and the WhatsApp gateway, so it is allowed longer
// than a page render but is still bounded.
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
scope := strings.TrimSpace(r.PostFormValue("scope"))
if scope == "" {
http.Error(w, "choose a brief to send", http.StatusBadRequest)
return
}
channels := r.PostForm["channel"]
if len(channels) == 0 {
channels = s.cfg.Apprise.Channels
}
text, childID, label, err := s.briefText(ctx, scope)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
recipients, err := s.recipientsForScope(ctx, label, childID, r.PostForm["recipient"])
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
title := "School brief — " + label
send, err := s.brief.Deliver(ctx, label, title, text, recipients, channels)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
s.log.Info("brief sent from the Send panel", "scope", label, "delivered", send.Delivered(), "failed", send.Failed())
msg := describeSend(send)
if send.Failed() > 0 || len(send.ScanFailed) > 0 {
s.redirectSendError(w, r, msg)
return
}
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape(msg))
}
// handleSummarySend sends one stored brief (a draft) as it is. It is the Send
// action on a summary (FR-10).
func (s *Server) handleSummarySend(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
n, err := s.store.NotificationByID(ctx, id)
if err != nil || n == nil {
http.NotFound(w, r)
return
}
scope := n.Scope
if scope == "" {
scope = n.Recipient
}
var childID int64
if !strings.EqualFold(scope, brief.ScopeCombined) {
if c, err := s.store.ChildByName(ctx, scope); err == nil && c != nil {
childID = c.ID
}
}
channels := r.PostForm["channel"]
if len(channels) == 0 {
channels = s.cfg.Apprise.Channels
}
recipients, err := s.recipientsForScope(ctx, scope, childID, r.PostForm["recipient"])
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
send, err := s.brief.Deliver(ctx, scope, "School brief — "+scope, n.Text, recipients, channels)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
// The stored brief is stamped only when every channel delivered, so a row is
// never marked sent after a failure (FR-12).
tag, channel := "", ""
if len(send.Deliveries) > 0 {
tag, channel = send.Deliveries[0].Tag, send.Deliveries[0].Channel
}
if err := s.store.MarkSummarySent(ctx, n.ID, channel, tag, send.SentOK(), send.FirstError(), s.brief.Now()); err != nil {
s.log.Error("mark summary sent", "id", n.ID, "err", err)
}
msg := describeSend(send)
if !send.SentOK() {
s.redirectSendError(w, r, msg)
return
}
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape(msg))
}
// briefText builds the brief text for a scope. The scope is either
// "combined" or a child id; the returned label is the stored scope name
// ("combined" or the child's name), so the delivery log never shows a bare id.
func (s *Server) briefText(ctx context.Context, scope string) (string, int64, string, error) {
if strings.EqualFold(scope, brief.ScopeCombined) {
children, err := s.store.ListChildren(ctx)
if err != nil {
return "", 0, "", err
}
text, _, err := s.brief.CombinedText(ctx, children)
return text, 0, brief.ScopeCombined, err
}
id, err := strconv.ParseInt(scope, 10, 64)
if err != nil {
return "", 0, "", fmt.Errorf("unknown brief %q", scope)
}
child, err := s.store.ChildByID(ctx, id)
if err != nil || child == nil {
return "", 0, "", fmt.Errorf("unknown child %d", id)
}
text, _, err := s.brief.ChildrenText(ctx, *child)
return text, child.ID, child.Name, err
}
// recipientsForScope resolves the tick boxes: the explicit list when given,
// otherwise the child, otherwise the parents (FR-14).
func (s *Server) recipientsForScope(ctx context.Context, scope string, childID int64, names []string) ([]config.Recipient, error) {
if len(names) > 0 {
return s.brief.RecipientsFor(names)
}
if strings.EqualFold(scope, brief.ScopeCombined) || childID == 0 {
parents, _, err := s.brief.ParentRecipients()
return parents, err
}
child, err := s.store.ChildByID(ctx, childID)
if err != nil || child == nil {
return nil, fmt.Errorf("unknown child %d", childID)
}
return s.brief.ChildRecipients(*child)
}
// describeSend is the one-line result shown on the dashboard.
func describeSend(send brief.Send) string {
parts := make([]string, 0, len(send.Deliveries))
for _, d := range send.Deliveries {
if d.OK {
parts = append(parts, fmt.Sprintf("%s %s: sent", d.Recipient, channelLabel(d.Channel)))
continue
}
parts = append(parts, fmt.Sprintf("%s %s: FAILED (%s)", d.Recipient, channelLabel(d.Channel), apprise.Truncate(d.Error, 140)))
}
msg := fmt.Sprintf("%s: %d delivered, %d failed", scopeDisplay(send.Scope), send.Delivered(), send.Failed())
if len(parts) > 0 {
msg += " — " + strings.Join(parts, "; ")
}
if len(send.ScanFailed) > 0 {
msg += fmt.Sprintf(" — scan failed for %s, so due work is UNKNOWN", strings.Join(send.ScanFailed, ", "))
}
return msg
}
func scopeDisplay(scope string) string {
if strings.EqualFold(scope, brief.ScopeCombined) {
return "combined"
}
return scope
}
// redirectSendError sends the send result back to the dashboard as an error, so
// a failed delivery is never a silent success.
func (s *Server) redirectSendError(w http.ResponseWriter, r *http.Request, msg string) {
s.log.Error("brief send reported a failure", "err", msg)
s.redirectAfterWrite(w, r, "/?level=error&err="+url.QueryEscape(msg))
}
// ---------- the 6:00am endpoint n8n calls (§11 Phase 7) ----------
// morningResponse is the machine-readable result of POST /notify/morning.
type morningResponse struct {
OK bool `json:"ok"`
At string `json:"at"`
DryRun bool `json:"dry_run,omitempty"`
Channels []string `json:"channels"`
ScansFailed []string `json:"scans_failed,omitempty"`
Sends []morningSend `json:"sends"`
Error string `json:"error,omitempty"`
}
type morningSend struct {
Scope string `json:"scope"`
Title string `json:"title,omitempty"`
Recipients []string `json:"recipients"`
Delivered int `json:"delivered"`
Failed int `json:"failed"`
Deliveries []morningDelivery `json:"deliveries"`
// Text is the brief itself; it is filled in only by a dry run, so the n8n
// wiring can be checked without messaging the family.
Text string `json:"text,omitempty"`
}
type morningDelivery struct {
Recipient string `json:"recipient"`
Channel string `json:"channel"`
Tag string `json:"tag"`
OK bool `json:"ok"`
Status int `json:"status"`
Error string `json:"error,omitempty"`
}
// handleMorningNotify builds and sends every morning brief. n8n calls it at
// 6:00am with the shared bearer token (SCHOOL_NOTIFY_TOKEN). It never needs the
// dashboard session, and it fails loudly: a delivery failure returns 207 with
// the failure in the body, an internal error returns 500.
func (s *Server) handleMorningNotify(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(s.cfg.Apprise.NotifyToken)
if token == "" {
s.writeJSON(w, http.StatusServiceUnavailable, morningResponse{
Error: "the morning endpoint is disabled: set SCHOOL_NOTIFY_TOKEN",
})
return
}
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
s.writeJSON(w, http.StatusUnauthorized, morningResponse{Error: "missing or bad bearer token"})
return
}
dryRun := strings.TrimSpace(r.URL.Query().Get("dry_run")) != ""
channels := s.cfg.Apprise.Channels
if v := strings.TrimSpace(r.URL.Query().Get("channels")); v != "" {
channels = nil
for _, ch := range strings.Split(v, ",") {
if ch = strings.TrimSpace(ch); ch != "" {
channels = append(channels, ch)
}
}
}
// A brief of many recipients on two channels needs longer than one send.
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
defer cancel()
if dryRun {
plans, err := s.brief.Plan(ctx)
if err != nil {
s.log.Error("morning dry run", "err", err)
s.writeJSON(w, http.StatusInternalServerError, morningResponse{Error: err.Error()})
return
}
resp := morningResponse{OK: true, DryRun: true, Channels: channels,
At: time.Now().In(s.cfg.Location).Format(time.RFC3339)}
for _, p := range plans {
resp.Sends = append(resp.Sends, morningSend{
Scope: p.Scope, Title: p.Title, Recipients: p.Recipients, Text: p.Text,
})
resp.ScansFailed = append(resp.ScansFailed, p.ScanFailed...)
}
resp.ScansFailed = dedupe(resp.ScansFailed)
if len(resp.ScansFailed) > 0 {
resp.OK = false
}
s.log.Info("morning dry run", "sends", len(resp.Sends), "scans_failed", strings.Join(resp.ScansFailed, ","))
s.writeJSON(w, http.StatusOK, resp)
return
}
sends, err := s.brief.Morning(ctx, channels)
if err != nil {
s.log.Error("morning brief", "err", err)
s.writeJSON(w, http.StatusInternalServerError, morningResponse{Error: err.Error()})
return
}
resp := morningResponse{
OK: true,
At: time.Now().In(s.cfg.Location).Format(time.RFC3339),
Channels: channels,
}
status := http.StatusOK
for _, send := range sends {
ms := morningSend{Scope: send.Scope, Recipients: send.Recipients, Delivered: send.Delivered(), Failed: send.Failed()}
for _, d := range send.Deliveries {
ms.Deliveries = append(ms.Deliveries, morningDelivery{
Recipient: d.Recipient, Channel: d.Channel, Tag: d.Tag, OK: d.OK, Status: d.Status, Error: d.Error,
})
}
resp.Sends = append(resp.Sends, ms)
resp.ScansFailed = append(resp.ScansFailed, send.ScanFailed...)
if send.Failed() > 0 {
status = http.StatusMultiStatus
}
}
if len(resp.ScansFailed) > 0 {
// A child can appear in both its own brief and the combined brief, so
// the summary list is deduplicated.
resp.ScansFailed = dedupe(resp.ScansFailed)
status = http.StatusMultiStatus
}
if status != http.StatusOK {
resp.OK = false
resp.Error = "one or more briefs did not deliver, or a scan did not complete"
}
s.log.Info("morning run", "status", status, "sends", len(resp.Sends), "scans_failed", strings.Join(resp.ScansFailed, ","))
s.writeJSON(w, status, resp)
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
s.log.Error("write json", "err", err)
}
}
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
@@ -821,7 +1374,7 @@ func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if _, err := s.store.DeleteSummary(ctx, id); err != nil { if _, err := s.store.DeleteNotification(ctx, id); err != nil {
s.log.Error("delete summary", "id", id, "err", err) s.log.Error("delete summary", "id", id, "err", err)
http.Error(w, "could not delete the summary", http.StatusInternalServerError) http.Error(w, "could not delete the summary", http.StatusInternalServerError)
return return
@@ -871,3 +1424,17 @@ func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name str
http.Error(w, "template error", http.StatusInternalServerError) http.Error(w, "template error", http.StatusInternalServerError)
} }
} }
// dedupe keeps the first occurrence of each name, in order.
func dedupe(names []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(names))
for _, n := range names {
if seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}

View File

@@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -32,11 +33,21 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database. // newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) { func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
return newTestServerCalendar(t, config.CalendarConfig{}) return newTestServerFull(t, config.CalendarConfig{}, config.AppriseConfig{})
} }
// newTestServerCalendar is newTestServer with a Home Assistant calendar target. // newTestServerCalendar is newTestServer with a Home Assistant calendar target.
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) { func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, cal, config.AppriseConfig{})
}
// newTestServerNotify is newTestServer with an Apprise gateway and token.
func newTestServerNotify(t *testing.T, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, config.CalendarConfig{}, ap)
}
// newTestServerFull builds the handler with both integrations configured.
func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
t.Helper() t.Helper()
loc, err := time.LoadLocation("Australia/Sydney") loc, err := time.LoadLocation("Australia/Sydney")
if err != nil { if err != nil {
@@ -65,6 +76,7 @@ func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.S
Version: "test", Version: "test",
Location: loc, Location: loc,
Calendar: cal, Calendar: cal,
Apprise: ap,
} }
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword) am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil { if err != nil {
@@ -283,7 +295,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
if resp.StatusCode != http.StatusSeeOther { if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("build status = %d, want 303", resp.StatusCode) t.Fatalf("build status = %d, want 303", resp.StatusCode)
} }
summaries, err := st.ListSummaries(ctx, 10) summaries, err := st.RecentNotifications(ctx, 10)
if err != nil || len(summaries) != 1 { if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %d, %v", len(summaries), err) t.Fatalf("summaries = %d, %v", len(summaries), err)
} }
@@ -301,7 +313,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
if resp2.StatusCode != http.StatusSeeOther { if resp2.StatusCode != http.StatusSeeOther {
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode) t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
} }
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil { if got, _ := st.NotificationByID(ctx, summaries[0].ID); got != nil {
t.Error("summary survived delete") t.Error("summary survived delete")
} }
} }
@@ -498,3 +510,357 @@ func TestItemCalendarPushNeedsCSRF(t *testing.T) {
t.Errorf("a tokenless push recorded a write: %+v", summary) t.Errorf("a tokenless push recorded a write: %+v", summary)
} }
} }
// ---------- Phase 7: Send panel and the 6:00am endpoint ----------
// testAppriseConfig is the recipient and channel set the tests use.
func testAppriseConfig(url, key, token string) config.AppriseConfig {
return config.AppriseConfig{
BaseURL: url,
Key: key,
Timeout: 2 * time.Second,
Channels: []string{config.ChannelEmail, config.ChannelWhatsApp},
Recipients: []config.Recipient{
{Name: "Harry", TagBase: "harry"},
{Name: "Finn", TagBase: "finn"},
{Name: "Jo", TagBase: "joanna"},
{Name: "Sam", TagBase: "sam"},
},
Parents: []string{"Jo", "Sam"},
NotifyToken: token,
}
}
// fakeAppriseServer answers every notification with the given status and records
// the tags it was asked to notify.
func fakeAppriseServer(t *testing.T, status int) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var tags []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Tag []string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
tags = append(tags, strings.Join(body.Tag, ","))
mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, &tags
}
// TestSendPanelShowsPreviewAndSends proves the Send panel (§5 screen 4): the
// page previews the brief, choosing a recipient and channel sends it, and the
// delivery is recorded against the right tag (FR-9, FR-10).
func TestSendPanelShowsPreviewAndSends(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Send a brief") {
t.Fatalf("Send panel missing: %s", snippet(page))
}
if !strings.Contains(page, "OVERDUE") && !strings.Contains(page, "Harry —") {
t.Errorf("preview missing from the Send panel: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("send status = %d, want 303", resp.StatusCode)
}
if len(*tags) != 1 || (*tags)[0] != "harry_email" {
t.Fatalf("apprise tags = %#v, want [harry_email]", *tags)
}
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 1 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
got := rows[0]
if got.Scope != "Harry" || got.Recipient != "Harry" || got.Channel != "email" || !got.OK || got.SentAt == nil {
t.Errorf("row = %+v", got)
}
if !strings.Contains(got.Text, "Harry") {
t.Errorf("stored brief = %q", got.Text)
}
// The dashboard reports the send back, and the banner is healthy.
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "delivered") {
t.Errorf("dashboard does not report the send: %s", snippet(page))
}
}
// TestSendPanelIsLoudWithoutApprise proves a missing gateway is shown, and the
// send is refused instead of being reported as sent (FR-12).
func TestSendPanelIsLoudWithoutApprise(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "NOTIFICATIONS NOT CONFIGURED") {
t.Errorf("missing banner: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 with the failure reported", resp.StatusCode)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a refused send must not be recorded as delivered: %+v", rows)
}
}
// TestSendReportsChannelFailure proves a failed channel is recorded and shown,
// never reported as a success (FR-12).
func TestSendReportsChannelFailure(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email", "whatsapp"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
for _, n := range rows {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("row = %+v, want a recorded failure", n)
}
}
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "SEND FAILED") {
t.Errorf("dashboard does not show the failure: %s", snippet(page))
}
}
// TestMorningEndpoint covers the endpoint n8n calls at 6:00am: it refuses a bad
// token, refuses to run unconfigured, and delivers one brief per boy plus the
// combined brief, reporting failures as 207 (FR-9, FR-12, FR-14).
func TestMorningEndpoint(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
post := func(auth string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
if auth != "" {
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
resp := post("wrong")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token status = %d, want 401", resp.StatusCode)
}
resp.Body.Close()
// A healthy morning: both children scanned, every channel answers 200.
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
OK bool `json:"ok"`
Channels []string `json:"channels"`
ScansFailed []string `json:"scans_failed"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if len(body.ScansFailed) != 0 {
t.Errorf("scans_failed = %#v, want none", body.ScansFailed)
}
if len(*tags) != 8 {
t.Errorf("deliveries = %d, want 8 (2 boys + 2 parents, 2 channels)", len(*tags))
}
// The combined brief goes to both parents.
var combined *struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
}
for i := range body.Sends {
if body.Sends[i].Scope == "combined" {
combined = &body.Sends[i]
}
}
if combined == nil || len(combined.Recipients) != 2 || combined.Delivered != 4 {
t.Errorf("combined send = %+v", combined)
}
rows, err := st.RecentNotifications(ctx, 20)
if err != nil || len(rows) != 8 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
// A failed scan still sends the briefs, but the run is not green (FR-12).
harry, _ := st.ChildByName(ctx, "Harry")
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
t.Fatalf("record failed scan: %v", err)
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("failed-scan status = %d, want 207", resp.StatusCode)
}
var after struct {
OK bool `json:"ok"`
ScansFailed []string `json:"scans_failed"`
}
if err := json.NewDecoder(resp.Body).Decode(&after); err != nil {
t.Fatalf("decode: %v", err)
}
if after.OK || len(after.ScansFailed) != 1 || after.ScansFailed[0] != "Harry" {
t.Errorf("after a failed scan = %+v", after)
}
}
// TestMorningEndpointDisabled proves the endpoint is off, not silently
// accepting, when the token is unset.
func TestMorningEndpointDisabled(t *testing.T) {
ts, _ := newTestServerNotify(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""))
resp, err := http.Post(ts.URL+"/notify/morning", "application/json", nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
// TestMorningEndpointFailsLoudly proves a failed delivery is a 207, so the n8n
// execution is not green when a brief did not go out (FR-12).
func TestMorningEndpointFailsLoudly(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, _ := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("status = %d, want 207", resp.StatusCode)
}
}
// TestMorningEndpointDryRun proves the n8n wiring can be checked without
// messaging the family: the same build path, no delivery, nothing recorded.
func TestMorningEndpointDryRun(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning?dry_run=1&channels=email,whatsapp", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
DryRun bool `json:"dry_run"`
OK bool `json:"ok"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string `json:"recipients"`
Text string `json:"text"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.DryRun || !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if body.Sends[2].Scope != "combined" || len(body.Sends[2].Recipients) != 2 {
t.Errorf("combined plan = %+v", body.Sends[2])
}
if body.Sends[0].Text == "" {
t.Error("dry run should return the brief text")
}
if len(*tags) != 0 {
t.Errorf("a dry run must not call Apprise: %#v", *tags)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a dry run must not record deliveries: %+v", rows)
}
}

View File

@@ -423,7 +423,8 @@ a:hover { text-decoration: underline; }
} }
.item__subject { color: var(--ink-secondary); } .item__subject { color: var(--ink-secondary); }
.item__due { color: var(--ink-secondary); } .item__due { color: var(--ink-secondary); }
.item__actions { margin: 0; flex: 0 0 auto; } .item__actions { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin: 0; flex: 0 0 auto; justify-content: flex-end; }
.item__actions form { margin: 0; }
.tag { .tag {
display: inline-flex; display: inline-flex;
@@ -507,3 +508,29 @@ a:hover { text-decoration: underline; }
.item__actions .button-utility { width: 100%; } .item__actions .button-utility { width: 100%; }
.child__head { flex-direction: column; } .child__head { flex-direction: column; }
} }
/* ---------- Send panel (Phase 7: recipients, preview, send) ---------- */
.send-card {
display: grid;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.send-card__summary {
cursor: pointer;
font: var(--type-body-md);
font-weight: 600;
color: var(--ink);
}
.send-card__form { display: grid; gap: var(--space-md); margin-top: var(--space-sm); }
.send-card__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.send-card__hint { font: var(--type-caption); color: var(--ink-muted); }
.check-row { display: flex; flex-wrap: wrap; gap: var(--space-sm) var(--space-lg); }
.check { display: inline-flex; align-items: center; gap: var(--space-xxs); font: var(--type-body-sm); }
.brief-wrap { display: grid; gap: var(--space-xxs); }
.brief-wrap > summary { cursor: pointer; }

View File

@@ -34,6 +34,7 @@
</header> </header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}} {{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
{{if .Flash}}<p class="flash flash--{{$.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML"> <div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
{{template "scan-status.html" .Scan}} {{template "scan-status.html" .Scan}}
@@ -41,6 +42,22 @@
{{template "calendar-status.html" .Calendar}} {{template "calendar-status.html" .Calendar}}
<section class="scan scan--{{.Notify.Level}}" aria-labelledby="notify-title">
<div class="scan__head">
<h2 id="notify-title" class="heading-2">Morning briefs</h2>
<span class="category-pill scan__pill">
{{if eq .Notify.Level "ok"}}sending
{{else if eq .Notify.Level "error"}}failed
{{else if eq .Notify.Level "warn"}}attention
{{else}}off{{end}}
</span>
</div>
<p class="scan__message">{{.Notify.Message}}</p>
{{if or (gt .Notify.Sent 0) (gt .Notify.Failed 0)}}
<p class="scan__meta">{{.Notify.Sent}} delivered · {{.Notify.Failed}} failed{{if .Notify.LastAt}} · last attempt {{.Notify.LastAt}}{{end}}</p>
{{end}}
</section>
{{if .Tabs}} {{if .Tabs}}
<nav class="child-tabs" aria-label="Children"> <nav class="child-tabs" aria-label="Children">
{{range .Tabs}} {{range .Tabs}}
@@ -126,26 +143,91 @@
</div> </div>
{{end}} {{end}}
{{if .Summaries}} {{if .Send.Scopes}}
<section class="section" aria-labelledby="summaries-title"> <section class="section" id="send-panel" aria-labelledby="send-title">
<div class="section__head"> <div class="section__head">
<h2 id="summaries-title" class="heading-2">Briefs</h2> <h2 id="send-title" class="heading-2">Send a brief</h2>
<span class="category-pill">Apprise</span>
</div>
{{if not .Send.Enabled}}
<p class="flash flash--error" role="alert">
NOTIFICATIONS NOT CONFIGURED — nothing can be sent. Set {{range $i, $m := .Send.Missing}}{{if $i}}, {{end}}{{$m}}{{end}}.
</p>
{{else}}
{{range .Send.Scopes}}
<details class="send-card">
<summary class="send-card__summary">{{.Label}}</summary>
<form method="post" action="/summaries/send" class="send-card__form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Key}}">
<fieldset class="field">
<legend class="field__label">Recipients</legend>
<div class="check-row">
{{range .Recipients}}
<label class="check"><input type="checkbox" name="recipient" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Name}}</label>
{{end}}
</div>
</fieldset>
<fieldset class="field">
<legend class="field__label">Channels</legend>
<div class="check-row">
{{range $.Send.Channels}}
<label class="check"><input type="checkbox" name="channel" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</fieldset>
{{if .Warning}}<p class="flash flash--error" role="alert">{{.Warning}}</p>{{end}}
<div class="send-card__actions">
<button class="button-utility" type="submit">Send now</button>
<span class="send-card__hint">The preview is exactly what is sent. Overdue first, lists only.</span>
</div>
<details class="brief-wrap" open>
<summary class="send-card__summary">preview ({{len .Preview}} bytes)</summary>
<pre class="brief">{{.Preview}}</pre>
</details>
</form>
</details>
{{end}}
{{end}}
</section>
{{end}}
{{if .Summaries}}
<section class="section" aria-labelledby="sends-title">
<div class="section__head">
<h2 id="sends-title" class="heading-2">Sends</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span> <span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
</div> </div>
<ul class="item-list"> <ul class="item-list">
{{range .Summaries}} {{range .Summaries}}
<li class="item" id="summary-{{.ID}}"> <li class="item{{if .Failed}} item--overdue{{end}}" id="summary-{{.ID}}">
<div class="item__body"> <div class="item__body">
<p class="item__title">{{.Recipient}} · {{.Sent}}</p> <p class="item__title">{{.Recipient}} · {{.Sent}}{{if .Channel}} · {{.Channel}}{{end}}</p>
<p class="item__meta"><span class="item__subject">built {{.Created}}</span></p> <p class="item__meta">
<pre class="brief">{{.Text}}</pre> <span class="tag tag--type">{{.Scope}}</span>
{{if .Tag}}<span class="item__subject">{{.Tag}}</span>{{end}}
{{if .Draft}}<span class="item__subject">built {{.Created}}</span>{{end}}
</p>
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<details class="brief-wrap">
<summary class="send-card__summary">brief text ({{len .Text}} bytes)</summary>
<pre class="brief">{{.Text}}</pre>
</details>
</div>
<div class="item__actions">
{{if .Draft}}
<form method="post" action="/summaries/{{.ID}}/send">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility" type="submit">Send</button>
</form>
{{end}}
<form method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief and its delivery record?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</div> </div>
<form class="item__actions" method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li> </li>
{{end}} {{end}}
</ul> </ul>

View File

@@ -13,7 +13,7 @@
# scripts/calendar-push.sh events [--days 14] # scripts/calendar-push.sh events [--days 14]
# #
# Environment overrides: # Environment overrides:
# IMAGE image tag (default 0.4.0-phase6) # IMAGE image tag (default 0.5.0-phase7)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# ENV_FILE HA config source (default environment.d/school-service.env) # ENV_FILE HA config source (default environment.d/school-service.env)
# SCHOOL_TIMEZONE family timezone (default Australia/Sydney) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney)
@@ -25,7 +25,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}" IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}"

111
scripts/morning-brief.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Phase 7: the morning run (AGENTS.md §4 FR-9, FR-14, §11 Phase 7).
#
# This is the bounded command the 6:00am n8n schedule runs:
# 1. scan Compass for both children (read-only; skipped with --no-scan),
# 2. build one brief per boy and one combined brief to Jo and Sam,
# 3. deliver them through the house Apprise API on `.35:8210`,
# 4. record every delivery in `notifications` and every exit code loudly.
#
# A failed scan is never reported as "nothing due" (FR-12): the brief opens with
# the failure, and this script exits non-zero so the n8n execution is visibly
# failed.
#
# Secrets: no address, phone number or password is passed on the command line.
# The Apprise configuration key comes from environment.d/school-service.env and
# is only exported into the container environment.
#
# Usage:
# scripts/morning-brief.sh # scan, then send
# scripts/morning-brief.sh --no-scan # send from the latest scan
# scripts/morning-brief.sh --dry-run # build and print, send nothing
# scripts/morning-brief.sh --channel email # limit the channels
#
# Environment overrides:
# IMAGE image tag (default 0.5.0-phase7)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# ENV_FILE service env file (default environment.d/school-service.env)
# SCHOOL_TIMEZONE family timezone (default Australia/Sydney)
# DOCKER_TIMEOUT per docker command timeout (default 600)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}"
DOCKER_TIMEOUT="${DOCKER_TIMEOUT:-600}"
NO_SCAN=0
NOTIFY_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--no-scan) NO_SCAN=1; shift ;;
--dry-run) NOTIFY_ARGS+=(--dry-run); shift ;;
--channel) NOTIFY_ARGS+=(--channel "$2"); shift 2 ;;
--only) NOTIFY_ARGS+=(--only "$2"); shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
# Take only the notification variables from the service env file, so the
# service's bcrypt hash (which contains "$$") is never re-expanded here.
if [[ -f "$ENV_FILE" ]]; then
while IFS= read -r line; do
case "$line" in
SCHOOL_APPRISE_URL=*|SCHOOL_APPRISE_KEY=*|SCHOOL_APPRISE_TOKEN=*|SCHOOL_APPRISE_TIMEOUT=*|\
SCHOOL_APPRISE_CHANNELS=*|SCHOOL_APPRISE_TAGS=*|SCHOOL_APPRISE_PARENTS=*|SCHOOL_NOTIFY_TOKEN=*)
key="${line%%=*}"
val="${line#*=}"
if [[ -z "${!key:-}" ]]; then
export "$key=$val"
fi
;;
esac
done < "$ENV_FILE"
fi
if [[ -z "${SCHOOL_APPRISE_KEY:-}" ]]; then
echo "SCHOOL_APPRISE_KEY is not set in $ENV_FILE: the briefs cannot be sent" >&2
exit 2
fi
run_school() {
timeout "$DOCKER_TIMEOUT" docker run --rm --network host \
-v "$VOLUME:/data" \
-e SCHOOL_DB_PATH=/data/school.db \
-e SCHOOL_TIMEZONE="$SCHOOL_TIMEZONE" \
-e SCHOOL_APPRISE_URL="${SCHOOL_APPRISE_URL:-}" \
-e SCHOOL_APPRISE_KEY="${SCHOOL_APPRISE_KEY:-}" \
-e SCHOOL_APPRISE_TOKEN="${SCHOOL_APPRISE_TOKEN:-}" \
-e SCHOOL_APPRISE_TIMEOUT="${SCHOOL_APPRISE_TIMEOUT:-}" \
-e SCHOOL_APPRISE_CHANNELS="${SCHOOL_APPRISE_CHANNELS:-}" \
-e SCHOOL_APPRISE_TAGS="${SCHOOL_APPRISE_TAGS:-}" \
-e SCHOOL_APPRISE_PARENTS="${SCHOOL_APPRISE_PARENTS:-}" \
"$IMAGE" "$@"
}
# 1. Scan both children. A failed scan is already loud in the scan log; keep
# going so the brief can carry the failure notice instead of silence.
scan_rc=0
if [[ "$NO_SCAN" -eq 0 ]]; then
echo "== scan both children =="
if ! IMAGE="$IMAGE" VOLUME="$VOLUME" timeout "$DOCKER_TIMEOUT" "$REPO_ROOT/scripts/scan-compass.sh"; then
scan_rc=$?
echo "scan reported a failure (exit $scan_rc); the briefs will say so" >&2
fi
fi
# 2. Build and send the briefs.
echo "== send the morning briefs =="
notify_rc=0
if ! run_school notify run "${NOTIFY_ARGS[@]+"${NOTIFY_ARGS[@]}"}"; then
notify_rc=$?
fi
if [[ "$scan_rc" -ne 0 || "$notify_rc" -ne 0 ]]; then
echo "morning brief FAILED: scan_rc=$scan_rc notify_rc=$notify_rc" >&2
exit 1
fi
echo "morning brief complete: both boys + the combined brief to Jo and Sam"

View File

@@ -9,7 +9,7 @@
# scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto] # scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto]
# #
# Environment overrides: # Environment overrides:
# IMAGE image tag (default 0.4.0-phase6) # IMAGE image tag (default 0.5.0-phase7)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -22,7 +22,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}" IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"

View File

@@ -21,7 +21,7 @@
# scripts/scan-compass.sh --child Harry --child Finn --classify none # scripts/scan-compass.sh --child Harry --child Finn --classify none
# #
# Environment overrides: # Environment overrides:
# IMAGE image tag (default compass-teams-nhs/school:0.4.0-phase6) # IMAGE image tag (default compass-teams-nhs/school:0.5.0-phase7)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -33,7 +33,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}" IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"