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

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

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

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

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

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

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