Files
Sam Rolfe 09639c5125 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>
2026-10-15 19:28:05 +11:00

170 lines
5.6 KiB
Go

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
}