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

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