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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user