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>
173 lines
5.9 KiB
Go
173 lines
5.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
// This file holds the read/write helpers the Phase 5 dashboard needs:
|
|
// per-child item reads, one item, item delete, a per-child scan summary, and
|
|
// the draft summaries (FR-7, FR-10, FR-12).
|
|
|
|
// itemSelect is the shared column list for an item row joined to its child.
|
|
const itemSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
|
|
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
|
|
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
|
|
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, '')
|
|
FROM items i LEFT JOIN children ch ON ch.id = i.child_id`
|
|
|
|
// scanItem reads one item row from the shared column list.
|
|
func scanItem(r rowScanner) (Item, error) {
|
|
var it Item
|
|
var due sql.NullString
|
|
if err := r.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
|
|
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
|
|
&it.SourcePage, &it.Importance, &it.ExternalRef); err != nil {
|
|
return it, err
|
|
}
|
|
if due.Valid {
|
|
t := parseTime(due.String)
|
|
it.DueAt = &t
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// ItemsForChild returns every stored item for one child. The dashboard classifies
|
|
// the rows into overdue / due today / due this week / messages (FR-7); a child
|
|
// never sees another child's rows.
|
|
func (s *Store) ItemsForChild(ctx context.Context, childID int64) ([]Item, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
itemSelect+` WHERE i.child_id = ? ORDER BY i.type, i.due_at, i.id`, childID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("items for child %d: %w", childID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Item
|
|
for rows.Next() {
|
|
it, err := scanItem(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan item: %w", err)
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ItemByID returns one item, or nil when it does not exist. The item detail
|
|
// screen uses it (AGENTS.md §5 screen 3).
|
|
func (s *Store) ItemByID(ctx context.Context, id int64) (*Item, error) {
|
|
row := s.db.QueryRowContext(ctx, itemSelect+` WHERE i.id = ?`, id)
|
|
it, err := scanItem(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("item %d: %w", id, err)
|
|
}
|
|
return &it, nil
|
|
}
|
|
|
|
// DeleteItemByID removes one item. It is the Delete half of FR-10: a deleted
|
|
// item returns on the next parse if the school still lists it. It returns the
|
|
// number of rows removed (0 when the id was already gone).
|
|
func (s *Store) DeleteItemByID(ctx context.Context, id int64) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete item %d: %w", id, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete item %d: %w", id, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ChildByID returns one child, or nil when it does not exist.
|
|
func (s *Store) ChildByID(ctx context.Context, id int64) (*Child, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
|
|
FROM children WHERE id = ?`, id)
|
|
var c Child
|
|
var created string
|
|
if err := row.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("child %d: %w", id, err)
|
|
}
|
|
c.CreatedAt = parseTime(created)
|
|
return &c, nil
|
|
}
|
|
|
|
// ScanSummaryForChild is ScanSummary limited to one child, so a failed scan for
|
|
// Finn is never hidden by a successful scan for Harry (FR-12).
|
|
func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanStatus, error) {
|
|
var st ScanStatus
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0)
|
|
FROM scans WHERE child_id = ?`, childID).
|
|
Scan(&st.Total, &st.Failed); err != nil {
|
|
return st, fmt.Errorf("scan summary for child %d: %w", childID, err)
|
|
}
|
|
st.EverRun = st.Total > 0
|
|
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
|
|
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
|
|
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
|
|
WHERE sc.child_id = ?
|
|
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`, childID)
|
|
sc, err := scanRow(row)
|
|
if err == sql.ErrNoRows {
|
|
return st, nil
|
|
}
|
|
if err != nil {
|
|
return st, err
|
|
}
|
|
st.Last = &sc
|
|
return st, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 (scope, recipient, channel, summary, sent_at)
|
|
VALUES (?, ?, '', ?, NULL)`,
|
|
scope, recipient, text)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// 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 notification %d: %w", id, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete notification %d: %w", id, err)
|
|
}
|
|
return n, nil
|
|
}
|