Files
compass_teams_nhs/internal/store/store.go
Sam Rolfe 5d263987dd Phase 4a: fetch and parse the authoritative Compass learning tasks
The Learning Tasks grid is capped at 20 rows and, on the parent page, mixes
both children, so it cannot answer "what is overdue". The parent home page
reported 6 overdue tasks for Harry while the stored grid held 1.

Fetch the per-child task list (read-only) from
POST /Services/LearningTasks.svc/GetAllLearningTasksByUserId, parse it into
task rows, and refuse to store it when the overdue count disagrees with the
home page summary (FR-12).

- fetcher: store learning-tasks-all.json + meta (userId, status, task count)
- parse: new learning-tasks-all page, JSON parser, home-page reconciliation
- store: drop superseded grid-derived task rows before the upsert
- docs: docs/phase-4a-overdue-tasks.md; close the Phase 3 known gap

Verified with live runs: Harry 70 tasks / 6 overdue, Finn 42 tasks / 2 overdue,
both equal to the home page; a tampered home page stops the parse.

Note: the fetcher and store.go files also carry the per-child session work
that was already uncommitted in this shared workspace (AUD-108).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 17:39:11 +11:00

498 lines
15 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()
}
// 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
}