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>
170 lines
5.6 KiB
Go
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
|
|
}
|