Files
compass_teams_nhs/internal/store/store.go
Sam Rolfe 047af0f31d Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
  school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
  labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
  periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
  on every pass. Identity is the stable per-item key, so a re-run never re-inserts
  (FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.

Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 10:25:55 +11:00

532 lines
16 KiB
Go

// Package store owns the SQLite database (AGENTS.md §7). It is the single
// source of truth for stored items, scans, notifications and session state.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// Child is one school child (AGENTS.md §7).
type Child struct {
ID int64
Name string
Year string
CompassID string
CreatedAt time.Time
}
// SeedChildren inserts the given names if they are not present yet. It is
// idempotent, so it is safe to call on every start. It never deletes a child.
func (s *Store) SeedChildren(ctx context.Context, names ...string) error {
for _, name := range names {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO children (name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name); err != nil {
return fmt.Errorf("seed child %q: %w", name, err)
}
}
return nil
}
// ListChildren returns every child, oldest first.
func (s *Store) ListChildren(ctx context.Context) ([]Child, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
FROM children ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list children: %w", err)
}
defer rows.Close()
var out []Child
for rows.Next() {
var c Child
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
return nil, fmt.Errorf("scan child: %w", err)
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
return out, rows.Err()
}
// Scan is one row of the append-only scan log (FR-13).
type Scan struct {
ID int64 `json:"id"`
ChildID *int64 `json:"child_id,omitempty"`
ChildName string `json:"child,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
PagesFetched int `json:"pages_fetched"`
ItemsFound int `json:"items_found"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// ScanStatus summarises scan health for the dashboard. It exists so that a
// failed scan is shown as an explicit failure and never as an empty day
// (FR-12).
type ScanStatus struct {
// EverRun is false until the first scan is recorded.
EverRun bool `json:"ever_run"`
// Total is the number of scan rows.
Total int `json:"total"`
// Failed is the number of rows with ok = 0.
Failed int `json:"failed"`
// Last is the most recent scan, when one exists.
Last *Scan `json:"last,omitempty"`
}
// LastScan returns the most recent scan row, or nil when none exists.
func (s *Store) LastScan(ctx context.Context) (*Scan, error) {
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
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`)
sc, err := scanRow(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &sc, nil
}
// ScanSummary builds the scan health status shown on the dashboard.
func (s *Store) ScanSummary(ctx context.Context) (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`).
Scan(&st.Total, &st.Failed); err != nil {
return st, fmt.Errorf("scan summary: %w", err)
}
st.EverRun = st.Total > 0
last, err := s.LastScan(ctx)
if err != nil {
return st, err
}
st.Last = last
return st, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanRow(r rowScanner) (Scan, error) {
var sc Scan
var childID sql.NullInt64
var started string
var finished sql.NullString
var ok int
if err := r.Scan(&sc.ID, &childID, &sc.ChildName, &started, &finished,
&sc.PagesFetched, &sc.ItemsFound, &ok, &sc.Error); err != nil {
return sc, err
}
if childID.Valid {
sc.ChildID = &childID.Int64
}
sc.StartedAt = parseTime(started)
if finished.Valid {
t := parseTime(finished.String)
sc.FinishedAt = &t
}
sc.OK = ok == 1
return sc, nil
}
// parseTime accepts SQLite's default datetime format and RFC3339.
func parseTime(s string) time.Time {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t
}
}
return time.Time{}
}
// RecordScan appends one scan row. It exists so later phases and the tests can
// prove the fail-loudly path. A non-empty errMsg records a failure even when
// nothing was fetched.
func (s *Store) RecordScan(ctx context.Context, childID *int64, pages, items int, ok bool, errMsg string) (int64, error) {
okInt := 0
if ok {
okInt = 1
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO scans (child_id, finished_at, pages_fetched, items_found, ok, error)
VALUES (?, datetime('now'), ?, ?, ?, NULLIF(?, ''))`,
childID, pages, items, okInt, errMsg)
if err != nil {
return 0, fmt.Errorf("record scan: %w", err)
}
return res.LastInsertId()
}
// Item is one row of the items table (AGENTS.md §7). It mirrors parse.Item
// without importing the parse package, so the store stays independent of the
// scraper.
type Item struct {
ID int64
ChildID int64
Child string
Source string
Type string
Subject string
Teacher string
Title string
Body string
DueAt *time.Time
URL string
SourcePage string
Importance string
ExternalRef string
}
// UpsertItems writes every item for one child, de-duplicating on
// (child_id, source, type, external_ref). It returns how many rows were
// inserted and how many were refreshed, so a re-parse never duplicates an item.
func (s *Store) UpsertItems(ctx context.Context, childID int64, items []Item) (inserted, updated int, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("upsert items: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, it := range items {
if it.ExternalRef == "" {
return 0, 0, fmt.Errorf("upsert items: %q has no external reference", it.Title)
}
var existing int64
err := tx.QueryRowContext(ctx,
`SELECT id FROM items WHERE child_id = ? AND source = ? AND type = ? AND external_ref = ?`,
childID, it.Source, it.Type, it.ExternalRef).Scan(&existing)
switch {
case err == sql.ErrNoRows:
if _, err := tx.ExecContext(ctx,
`INSERT INTO items (child_id, source, type, subject, teacher, title, body,
due_at, url, source_page, importance, external_ref)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
childID, it.Source, it.Type, it.Subject, it.Teacher, it.Title, it.Body,
formatDue(it.DueAt), it.URL, it.SourcePage, it.Importance, it.ExternalRef); err != nil {
return 0, 0, fmt.Errorf("insert item %q: %w", it.Title, err)
}
inserted++
case err != nil:
return 0, 0, fmt.Errorf("look up item %q: %w", it.Title, err)
default:
if _, err := tx.ExecContext(ctx,
`UPDATE items SET subject = ?, teacher = ?, title = ?, body = ?, due_at = ?,
url = ?, source_page = ?, importance = ?, updated_at = datetime('now')
WHERE id = ?`,
it.Subject, it.Teacher, it.Title, it.Body, formatDue(it.DueAt),
it.URL, it.SourcePage, it.Importance, existing); err != nil {
return 0, 0, fmt.Errorf("update item %q: %w", it.Title, err)
}
updated++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("upsert items: commit: %w", err)
}
return inserted, updated, nil
}
// formatDue stores a due time in UTC RFC3339 so ordering is DST-safe, or NULL
// when there is no due date.
func formatDue(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339)
}
// DeleteChildCompassTasksFromPages removes a child's Compass task rows that came
// from the given source pages. It is used when an authoritative full task list
// replaces a partial grid: the legacy grid is capped
// (LearningTask_Default_Records_Count) and a stale row must not survive as if it
// were the whole set (AUD-114, FR-12). Rows from any other source page are left
// untouched.
func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID int64, sourcePages ...string) (int64, error) {
if len(sourcePages) == 0 {
return 0, nil
}
placeholders := strings.Repeat("?,", len(sourcePages)-1) + "?"
args := make([]any, 0, len(sourcePages)+1)
args = append(args, childID)
for _, p := range sourcePages {
args = append(args, p)
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM items
WHERE child_id = ? AND source = 'compass' AND type = 'task'
AND source_page IN (`+placeholders+`)`, args...)
if err != nil {
return 0, fmt.Errorf("delete partial task rows: %w", err)
}
return res.RowsAffected()
}
// DeleteItemsFromPages is DeleteItems narrowed to the given source pages. It is
// used to drop a stale snapshot (for example a child's previously parsed day
// widget) without touching the same child's other rows.
func (s *Store) DeleteItemsFromPages(ctx context.Context, childID int64, source, itemType string, pages ...string) (int64, error) {
if len(pages) == 0 {
return 0, nil
}
placeholders := strings.Repeat("?, ", len(pages))
placeholders = strings.TrimSuffix(placeholders, ", ")
q := `DELETE FROM items WHERE child_id = ?`
args := []any{childID}
if source != "" {
q += ` AND source = ?`
args = append(args, source)
}
if itemType != "" {
q += ` AND type = ?`
args = append(args, itemType)
}
q += ` AND source_page IN (` + placeholders + `)`
for _, p := range pages {
args = append(args, p)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return 0, fmt.Errorf("delete items for child %d from pages: %w", childID, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return n, nil
}
// TypeCount is the number of items of one type for one child.
type TypeCount struct {
Child string
Type string
Count int
}
// CountItemsByType returns per-child, per-type row counts, ordered by child then
// type. It is the evidence query for Phase 3.
func (s *Store) CountItemsByType(ctx context.Context) ([]TypeCount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT COALESCE(ch.name, '(unknown)'), i.type, COUNT(*)
FROM items i LEFT JOIN children ch ON ch.id = i.child_id
GROUP BY ch.name, i.type
ORDER BY ch.name, i.type`)
if err != nil {
return nil, fmt.Errorf("count items: %w", err)
}
defer rows.Close()
var out []TypeCount
for rows.Next() {
var tc TypeCount
if err := rows.Scan(&tc.Child, &tc.Type, &tc.Count); err != nil {
return nil, fmt.Errorf("scan item count: %w", err)
}
out = append(out, tc)
}
return out, rows.Err()
}
// SampleItems returns up to limit items for a child, newest due date first.
func (s *Store) SampleItems(ctx context.Context, childID int64, limit int) ([]Item, error) {
rows, err := s.db.QueryContext(ctx,
`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
WHERE i.child_id = ?
ORDER BY i.type, i.due_at DESC, i.id
LIMIT ?`, childID, limit)
if err != nil {
return nil, fmt.Errorf("sample items: %w", err)
}
defer rows.Close()
var out []Item
for rows.Next() {
var it Item
var due sql.NullString
if err := rows.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 nil, fmt.Errorf("scan item: %w", err)
}
if due.Valid {
t := parseTime(due.String)
it.DueAt = &t
}
out = append(out, it)
}
return out, rows.Err()
}
// ChildByName returns one child by name.
func (s *Store) ChildByName(ctx context.Context, name string) (*Child, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
FROM children WHERE name = ?`, name)
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 %q: %w", name, err)
}
c.CreatedAt = parseTime(created)
return &c, nil
}
// SetChildCompassID records the child's Compass userId, discovered by the
// fetcher from the parent landing page (AGENTS.md §7).
func (s *Store) SetChildCompassID(ctx context.Context, childID int64, compassID string) error {
if compassID == "" {
return nil
}
if _, err := s.db.ExecContext(ctx,
`UPDATE children SET compass_id = ? WHERE id = ?`, compassID, childID); err != nil {
return fmt.Errorf("set compass id for child %d: %w", childID, err)
}
return nil
}
// DeleteItems removes items for one child, optionally limited to a source and a
// type. It exists so a scan can drop rows that were stored against the wrong
// child (Phase 4: nothing may leak between children) and so a later Delete
// action has a home. It returns how many rows were removed.
func (s *Store) DeleteItems(ctx context.Context, childID int64, source, itemType string) (int64, error) {
q := `DELETE FROM items WHERE child_id = ?`
args := []any{childID}
if source != "" {
q += ` AND source = ?`
args = append(args, source)
}
if itemType != "" {
q += ` AND type = ?`
args = append(args, itemType)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
}
return n, nil
}