package store import ( "context" "database/sql" "fmt" "time" ) // 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 } // 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 } // 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) { res, err := s.db.ExecContext(ctx, `INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`, recipient, text) if err != nil { return 0, fmt.Errorf("create summary for %q: %w", recipient, err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("create summary 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) { 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) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("delete summary %d: %w", id, err) } return n, nil }