Phase 1: Compass/NHS skeleton (Go, SQLite, session login, Docker, Caddy)

Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port
8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login
and GET /health.

- Go service (Go 1.25, modernc SQLite, no cgo) with its own image.
- SQLite schema per AGENTS.md §7: children, items, scans, notifications,
  session (internal/store/migrations/001_init.sql).
- Separate session login (signed cookie, CSRF double-submit), not SSO.
- HTMX server-rendered shell using the Family Home Lab tokens.css: canvas
  #f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs,
  dark mode, phone-friendly.
- Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or
  error banner, never as an empty day.
- Dockerfile + docker-compose.yml (own container, own port 8792; uses
  network_mode bridge because .13 has no free default Docker address pool).
- Caddy snippet for .35 in deploy/caddy/, deployment notes, and
  deploy/verify-phase1.sh.

No school scan in this phase; the Compass login and fetch are Phase 2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 13:21:20 +11:00
parent 2fb223b993
commit 7b98e487c0
29 changed files with 2287 additions and 0 deletions

271
internal/store/store.go Normal file
View File

@@ -0,0 +1,271 @@
// 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"
"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()
}