diff --git a/Makefile b/Makefile index 5197cb1..50715be 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.4.0-phase6 -BUILD_VERSION ?= 0.4.0-phase6 +IMAGE ?= compass-teams-nhs/school:0.5.0-phase7 +BUILD_VERSION ?= 0.5.0-phase7 # Parse options (see scripts/parse-compass.sh). CHILD ?= Harry @@ -9,6 +9,9 @@ CLASSIFY ?= auto # Scan target: 'all' scans every child; set SCAN_CHILD=Harry to narrow it. 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 # user, so the repository and the module cache stay owned by you. 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 \ -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: @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 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 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" test: @@ -83,6 +89,18 @@ scan: calendar: 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 # Caddy route on .35. verify-phase1: diff --git a/README.md b/README.md index 5d5962c..da5f2bb 100644 --- a/README.md +++ b/README.md @@ -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, 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 -> calendar write). See §11 of the spec for the phase order. +> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 7** (notify +> 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 @@ -77,9 +98,8 @@ Built and deployed on `.13`: - Its own container and port **8792**, behind Caddy at `school.lab.audasmedia.com.au`. -Not built yet (later phases): notifications and the Hub API. The calendar write -is Phase 6. The service itself does **not** scan the school; n8n triggers the -scan later. Teams (FR-15) is a separate source. +Not built yet (later phases): the Hub API. The service itself does **not** scan +the school; n8n triggers the scan. Teams (FR-15) is a separate source. ### Fail loudly (FR-12) @@ -98,11 +118,13 @@ internal/config/ environment configuration internal/store/ SQLite: schema, migrations, queries internal/parse/ HTML -> structured items (tasks, messages, events) 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/auth/ session login (signed cookies, CSRF) internal/web/ HTTP handlers, templates, static assets -deploy/ Caddy snippet, deployment notes, verify script -scripts/ Compass fetchers, the parse wrapper and the calendar push +deploy/ Caddy snippet, the n8n workflow, deployment notes, verify script +scripts/ Compass fetchers, the parse and morning-brief wrappers, the calendar push environment.d/ env example (the real file is gitignored) ``` @@ -117,8 +139,8 @@ docker compose up -d --build Generate secrets with the built image: ```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.4.0-phase6 hash-password 'the-password' +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.5.0-phase7 hash-password 'the-password' ``` Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash diff --git a/cmd/school/brief.go b/cmd/school/brief.go index 0cf4676..6d95da5 100644 --- a/cmd/school/brief.go +++ b/cmd/school/brief.go @@ -7,16 +7,18 @@ import ( "os" "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/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) -// briefCommand is the `school brief` entry point. Phase 5 builds and deletes the -// stored brief (the summary half of FR-10); sending it is Phase 7. +// briefCommand is the `school brief` entry point: build and delete the stored +// 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) { 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] { case "build": @@ -24,26 +26,30 @@ func briefCommand(args []string) { case "list": briefList() 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) { 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.BoolVar(&combined, "combined", false, "build the combined brief for Jo and Sam") _ = fs.Parse(args) - if child == "" { - fmt.Fprintln(os.Stderr, "school brief build: --child is required") + if child == "" && !combined { + fmt.Fprintln(os.Stderr, "school brief build: --child NAME or --combined is required") os.Exit(2) } - if err := runBriefBuild(child); err != nil { + if err := runBriefBuild(child, combined); err != nil { fmt.Fprintln(os.Stderr, "school brief:", err) os.Exit(1) } } -func runBriefBuild(child string) error { +func runBriefBuild(child string, combined bool) error { ctx := context.Background() cfg, err := config.LoadDB() if err != nil { @@ -57,6 +63,33 @@ func runBriefBuild(child string) error { if err := st.Migrate(ctx); err != nil { 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) if err != nil { return err @@ -90,21 +123,26 @@ func briefList() { os.Exit(1) } defer st.Close() - summaries, err := st.ListSummaries(ctx, 20) + rows, err := st.RecentNotifications(ctx, 20) if err != nil { fmt.Fprintln(os.Stderr, "school brief:", err) os.Exit(1) } - if len(summaries) == 0 { + if len(rows) == 0 { fmt.Println("no briefs stored") return } - for _, sm := range summaries { - status := "draft" - if sm.SentAt != nil { - status = "sent " + sm.SentAt.Format("2006-01-02 15:04") + for _, n := range rows { + state := "draft" + switch { + 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)) } } diff --git a/cmd/school/main.go b/cmd/school/main.go index 86fd8e6..39ef74f 100644 --- a/cmd/school/main.go +++ b/cmd/school/main.go @@ -56,6 +56,9 @@ func main() { case "brief": briefCommand(os.Args[2:]) return + case "notify": + notifyCommand(os.Args[2:]) + return case "calendar": calendarCommand(os.Args[2:]) return diff --git a/cmd/school/notify.go b/cmd/school/notify.go new file mode 100644 index 0000000..6726a87 --- /dev/null +++ b/cmd/school/notify.go @@ -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:] +} diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index bd2e39a..361026a 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -56,3 +56,56 @@ on `.35` (sam-ubuntu1) terminates TLS and proxies to `192.168.20.13:8792`. create a project network. - The env file is gitignored. School credentials and harness session state are 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= + ``` + +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). diff --git a/deploy/n8n-morning-brief.json b/deploy/n8n-morning-brief.json new file mode 100644 index 0000000..f71fdb9 --- /dev/null +++ b/deploy/n8n-morning-brief.json @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 352f904..0be27b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.4.0-phase6" - image: compass-teams-nhs/school:0.4.0-phase6 + VERSION: "0.5.0-phase7" + image: compass-teams-nhs/school:0.5.0-phase7 container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/phase-7-notify-notes.md b/docs/phase-7-notify-notes.md new file mode 100644 index 0000000..d005efe --- /dev/null +++ b/docs/phase-7-notify-notes.md @@ -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 `_email` and `_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 +``` + +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. diff --git a/environment.d/school-service.env.example b/environment.d/school-service.env.example index 09b9c02..f1e1b60 100644 --- a/environment.d/school-service.env.example +++ b/environment.d/school-service.env.example @@ -45,3 +45,32 @@ SCHOOL_HA_CALENDAR_ENTITY=calendar.school # Set true only when SCHOOL_HA_URL is the https://homeassistant.home.lab URL. SCHOOL_HA_INSECURE=false 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: _email and _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 gen-secret +SCHOOL_NOTIFY_TOKEN= diff --git a/internal/apprise/apprise.go b/internal/apprise/apprise.go new file mode 100644 index 0000000..e3d8cb8 --- /dev/null +++ b/internal/apprise/apprise.go @@ -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("") + 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("") + 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)" +} diff --git a/internal/apprise/apprise_test.go b/internal/apprise/apprise_test.go new file mode 100644 index 0000000..65e4d3b --- /dev/null +++ b/internal/apprise/apprise_test.go @@ -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 "}, + {"phone 61412345678", "phone "}, + {"short 12 stays", "short 12 stays"}, + {"two: a@b.co and c@d.co", "two: and "}, + } + 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) + } +} diff --git a/internal/brief/brief.go b/internal/brief/brief.go new file mode 100644 index 0000000..dae9a61 --- /dev/null +++ b/internal/brief/brief.go @@ -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) } diff --git a/internal/brief/brief_test.go b/internal/brief/brief_test.go new file mode 100644 index 0000000..af9d5e2 --- /dev/null +++ b/internal/brief/brief_test.go @@ -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") + } +} diff --git a/internal/config/apprise_test.go b/internal/config/apprise_test.go new file mode 100644 index 0000000..f3b5adf --- /dev/null +++ b/internal/config/apprise_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 85cbffa..5c80c39 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,9 @@ type Config struct { // Calendar is the Home Assistant calendar target for important school // events (AGENTS.md §4 FR-8). 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 @@ -91,6 +94,137 @@ func (c CalendarConfig) Missing() []string { 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. const DefaultTimezone = "Australia/Sydney" @@ -137,6 +271,11 @@ func Load() (*Config, error) { } cfg.Location = loc cfg.Calendar = loadCalendar() + apprise, err := loadApprise() + if err != nil { + return nil, err + } + cfg.Apprise = apprise return cfg, nil } @@ -148,21 +287,95 @@ type DBConfig struct { DBPath string Location *time.Location 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) { loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone)) if err != nil { return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) } + apprise, err := loadApprise() + if err != nil { + return nil, err + } return &DBConfig{ DBPath: env("SCHOOL_DB_PATH", "/data/school.db"), Location: loc, Calendar: loadCalendar(), + Apprise: apprise, }, 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 // empty value simply leaves the target disabled, so the service boots before // the calendar is configured and the calendar commands fail loudly instead. diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 727dbaa..6ad7f4a 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -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) } +// 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, // 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 { + 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 { loc = time.UTC } var sb strings.Builder fmt.Fprintf(&sb, "%s — %s.\n", child, b.Headline()) - writeSection(&sb, "OVERDUE", b.Overdue, loc) - writeSection(&sb, "DUE TODAY", b.DueToday, loc) - writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc) - writeSection(&sb, "NO DUE DATE", b.NoDue, loc) + writeSection(&sb, "OVERDUE", b.Overdue, loc, 0) + writeSection(&sb, "DUE TODAY", b.DueToday, loc, 0) + writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc, 0) + writeSection(&sb, "NO DUE DATE", b.NoDue, loc, lim.NoDue) writeMessages(&sb, b.Messages, loc) 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 { return } 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)) } } @@ -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. func line(it store.Item, loc *time.Location) string { subject := strings.TrimSpace(it.Subject) diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 347e3a9..50e1b5d 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -167,3 +167,96 @@ func equal(a, b []string) bool { } 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) + } +} diff --git a/internal/store/dashboard.go b/internal/store/dashboard.go index ffd56c2..4d1becc 100644 --- a/internal/store/dashboard.go +++ b/internal/store/dashboard.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "time" ) // 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 } -// Summary is one stored brief (AGENTS.md §7 notifications): either a draft -// (SentAt nil) or a delivered brief. It is the summary half of FR-10. -type Summary struct { - ID int64 - Recipient string - Text string - SentAt *time.Time - CreatedAt time.Time +// Draft briefs (FR-10). A brief is a row in `notifications`; the delivery log +// and the per-channel rows live in notify.go. CreateDraft stores a brief that +// has not been sent yet, so the Send panel can preview it first. + +// CreateSummary stores a brief for one recipient as a draft (sent_at NULL). It +// is CreateDraft with the scope defaulted to the recipient. +func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) { + return s.CreateDraft(ctx, recipient, recipient, text) } -// CreateSummary stores a brief for one recipient. sent_at stays NULL for a -// draft; Phase 7 sets it when the brief is sent. -func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) { +// CreateDraft stores a brief that has not been sent yet. scope is the child +// name, or "combined" for the brief to Jo and Sam (FR-9, FR-14). +func (s *Store) CreateDraft(ctx context.Context, scope, recipient, text string) (int64, error) { res, err := s.db.ExecContext(ctx, - `INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`, - recipient, text) + `INSERT INTO notifications (scope, recipient, channel, summary, sent_at) + VALUES (?, ?, '', ?, NULL)`, + scope, recipient, text) 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() 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 } -// ListSummaries returns recent briefs, newest first. -func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) { - if limit <= 0 { - 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) { +// DeleteNotification removes one brief or delivery row. It returns the number +// of rows removed. +func (s *Store) DeleteNotification(ctx context.Context, id int64) (int64, error) { res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id) 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() 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 } diff --git a/internal/store/dashboard_test.go b/internal/store/dashboard_test.go index 151a2fa..a3e4012 100644 --- a/internal/store/dashboard_test.go +++ b/internal/store/dashboard_test.go @@ -83,22 +83,22 @@ func TestSummariesRoundTrip(t *testing.T) { if err != nil || id == 0 { 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 { 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]) } - got, err := st.SummaryByID(ctx, id) + got, err := st.NotificationByID(ctx, id) if err != nil || got == nil || got.Text != list[0].Text { 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 { 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") } } diff --git a/internal/store/migrations/004_notifications_send.sql b/internal/store/migrations/004_notifications_send.sql new file mode 100644 index 0000000..1183f13 --- /dev/null +++ b/internal/store/migrations/004_notifications_send.sql @@ -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); diff --git a/internal/store/notify.go b/internal/store/notify.go new file mode 100644 index 0000000..e30815d --- /dev/null +++ b/internal/store/notify.go @@ -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 +} diff --git a/internal/store/notify_test.go b/internal/store/notify_test.go new file mode 100644 index 0000000..ae72461 --- /dev/null +++ b/internal/store/notify_test.go @@ -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: ", + }); 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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 2b787e1..1c1b782 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,11 +1,12 @@ // Package web is the HTTP surface of the Compass/NHS service: /health, the -// service's own session login, and the server-rendered HTMX dashboard -// (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail -// screen, and the Delete actions (FR-7, FR-10, FR-12). +// service's own session login, the server-rendered HTMX dashboard, the Send +// panel and the machine endpoint the 6:00am n8n schedule calls (AGENTS.md §5, +// §9, §11 Phase 7). package web import ( "context" + "crypto/subtle" "embed" "encoding/json" "fmt" @@ -13,11 +14,14 @@ import ( "io/fs" "log/slog" "net/http" + "net/url" "strconv" "strings" "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/brief" "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/dashboard" @@ -37,6 +41,7 @@ type Server struct { auth *auth.Manager tmpl *template.Template log *slog.Logger + brief *brief.Service } // 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 { 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. @@ -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}/calendar", s.requireAuth(s.handleItemCalendarPush)) 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("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") if err != nil { panic("web: embedded static dir missing: " + err.Error()) @@ -380,27 +393,82 @@ type childTab struct { type summaryView struct { ID int64 + Scope string Recipient string + Channel string + Tag string Created string Sent string Draft bool + Failed bool + Error 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 { - Title string - Username string - CSRF string - Version string - AppEnv string - Scan scanStatusView - Calendar calendarBannerView - Tabs []childTab - Child childPanel - HasChild bool - Summaries []summaryView - ServerUp string - ErrMsg string + Title string + Username string + CSRF string + Version string + AppEnv string + Scan scanStatusView + Calendar calendarBannerView + Notify notifyBannerView + Tabs []childTab + Child childPanel + HasChild bool + Summaries []summaryView + Send sendPanelView + ServerUp string + ErrMsg string + Flash string + FlashLevel string } var serverStarted = time.Now() @@ -418,9 +486,19 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { AppEnv: s.cfg.AppEnv, Scan: s.scanStatus(ctx), Calendar: s.calendarBanner(ctx), + Notify: s.notifyBanner(ctx), ServerUp: time.Since(serverStarted).Round(time.Second).String(), 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) 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 { - s.log.Error("list summaries", "err", err) + s.log.Error("list notifications", "err", err) } - for _, sm := range summaries { + for _, n := range notifications { v := summaryView{ - ID: sm.ID, - Recipient: sm.Recipient, - Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"), - Text: sm.Text, - Draft: sm.SentAt == nil, + ID: n.ID, + Scope: n.Scope, + Recipient: n.Recipient, + Channel: n.Channel, + 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 { - v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04") - } else { + switch { + case n.Channel == "": 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) } @@ -489,6 +577,103 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { 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. func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel { 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) 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) if err != nil { 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) return } - items, err := s.store.ItemsForChild(ctx, childID) + text, _, err := s.brief.ChildrenText(ctx, *child) 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) 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) if err != nil { 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)) } +// ---------- 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) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -821,7 +1374,7 @@ func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) 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) http.Error(w, "could not delete the summary", http.StatusInternalServerError) return @@ -871,3 +1424,17 @@ func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name str 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 +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 1ecc89a..6424592 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync" "testing" "time" @@ -32,11 +33,21 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`) // newTestServer builds the web handler against a fresh SQLite database. 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. 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() loc, err := time.LoadLocation("Australia/Sydney") if err != nil { @@ -65,6 +76,7 @@ func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.S Version: "test", Location: loc, Calendar: cal, + Apprise: ap, } am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword) if err != nil { @@ -283,7 +295,7 @@ func TestSummaryBuildAndDelete(t *testing.T) { if resp.StatusCode != http.StatusSeeOther { 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 { t.Fatalf("summaries = %d, %v", len(summaries), err) } @@ -301,7 +313,7 @@ func TestSummaryBuildAndDelete(t *testing.T) { if resp2.StatusCode != http.StatusSeeOther { 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") } } @@ -498,3 +510,357 @@ func TestItemCalendarPushNeedsCSRF(t *testing.T) { 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) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 80858d4..821c156 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -423,7 +423,8 @@ a:hover { text-decoration: underline; } } .item__subject { 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 { display: inline-flex; @@ -507,3 +508,29 @@ a:hover { text-decoration: underline; } .item__actions .button-utility { width: 100%; } .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; } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 95a8daf..de4fddc 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -34,6 +34,7 @@ {{if .ErrMsg}}{{end}} + {{if .Flash}}

{{.Flash}}

{{end}}
{{template "scan-status.html" .Scan}} @@ -41,6 +42,22 @@ {{template "calendar-status.html" .Calendar}} +
+
+

Morning briefs

+ + {{if eq .Notify.Level "ok"}}sending + {{else if eq .Notify.Level "error"}}failed + {{else if eq .Notify.Level "warn"}}attention + {{else}}off{{end}} + +
+

{{.Notify.Message}}

+ {{if or (gt .Notify.Sent 0) (gt .Notify.Failed 0)}} +

{{.Notify.Sent}} delivered · {{.Notify.Failed}} failed{{if .Notify.LastAt}} · last attempt {{.Notify.LastAt}}{{end}}

+ {{end}} +
+ {{if .Tabs}}
{{end}} - {{if .Summaries}} -
+ {{if .Send.Scopes}} +
-

Briefs

+

Send a brief

+ Apprise +
+ {{if not .Send.Enabled}} + + {{else}} + {{range .Send.Scopes}} +
+ {{.Label}} +
+ + +
+ Recipients +
+ {{range .Recipients}} + + {{end}} +
+
+
+ Channels +
+ {{range $.Send.Channels}} + + {{end}} +
+
+ {{if .Warning}}{{end}} +
+ + The preview is exactly what is sent. Overdue first, lists only. +
+
+ preview ({{len .Preview}} bytes) +
{{.Preview}}
+
+
+
+ {{end}} + {{end}} +
+ {{end}} + + {{if .Summaries}} +
+
+

Sends

{{len .Summaries}}
    {{range .Summaries}} -
  • +
  • -

    {{.Recipient}} · {{.Sent}}

    -

    built {{.Created}}

    -
    {{.Text}}
    +

    {{.Recipient}} · {{.Sent}}{{if .Channel}} · {{.Channel}}{{end}}

    +

    + {{.Scope}} + {{if .Tag}}{{.Tag}}{{end}} + {{if .Draft}}built {{.Created}}{{end}} +

    + {{if .Error}}{{end}} +
    + brief text ({{len .Text}} bytes) +
    {{.Text}}
    +
    +
    +
    + {{if .Draft}} +
    + + +
    + {{end}} +
    + + +
    -
    - - -
  • {{end}}
diff --git a/scripts/calendar-push.sh b/scripts/calendar-push.sh index 0400272..868d1f8 100755 --- a/scripts/calendar-push.sh +++ b/scripts/calendar-push.sh @@ -13,7 +13,7 @@ # scripts/calendar-push.sh events [--days 14] # # 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) # ENV_FILE HA config source (default environment.d/school-service.env) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney) @@ -25,7 +25,7 @@ set -euo pipefail 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}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" diff --git a/scripts/morning-brief.sh b/scripts/morning-brief.sh new file mode 100755 index 0000000..b202b7d --- /dev/null +++ b/scripts/morning-brief.sh @@ -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" diff --git a/scripts/parse-compass.sh b/scripts/parse-compass.sh index 250bdf6..61fde2f 100755 --- a/scripts/parse-compass.sh +++ b/scripts/parse-compass.sh @@ -9,7 +9,7 @@ # scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto] # # 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) # RAW_ROOT host path to var/raw (default /var/raw) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) @@ -22,7 +22,7 @@ set -euo pipefail 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}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" diff --git a/scripts/scan-compass.sh b/scripts/scan-compass.sh index fb643da..d95b18b 100755 --- a/scripts/scan-compass.sh +++ b/scripts/scan-compass.sh @@ -21,7 +21,7 @@ # scripts/scan-compass.sh --child Harry --child Finn --classify none # # 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) # RAW_ROOT host path to var/raw (default /var/raw) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) @@ -33,7 +33,7 @@ set -euo pipefail 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}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"