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

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

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

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

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

View File

@@ -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
}

View File

@@ -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")
}
}

View File

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

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

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

View File

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