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 }