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>
528 lines
14 KiB
Go
528 lines
14 KiB
Go
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:]
|
|
}
|