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

View File

@@ -0,0 +1,67 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
--
-- Every item is stored with its child, source page, type, subject, due date and
-- link (FR-5). Scans are append-only and record success or an explicit error,
-- so a failed scan can never be mistaken for "nothing due" (FR-12).
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
year TEXT,
compass_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One row per message, task or calendar event read from Compass or Teams.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
child_id INTEGER NOT NULL REFERENCES children(id),
source TEXT NOT NULL DEFAULT 'compass' CHECK (source IN ('compass', 'teams')),
type TEXT NOT NULL CHECK (type IN ('message', 'task', 'event')),
subject TEXT,
teacher TEXT,
title TEXT NOT NULL,
body TEXT,
due_at TEXT,
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_items_child ON items(child_id);
CREATE INDEX IF NOT EXISTS idx_items_due ON items(child_id, due_at);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(child_id, type);
-- Append-only scan log (FR-13). ok = 0 with a message is a loud failure.
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY,
child_id INTEGER REFERENCES children(id),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
pages_fetched INTEGER NOT NULL DEFAULT 0,
items_found INTEGER NOT NULL DEFAULT 0,
ok INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_scans_started ON scans(started_at);
-- One row per delivered brief (FR-9, FR-14).
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
recipient TEXT NOT NULL,
summary TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_sent ON notifications(sent_at);
-- Harness session state per child. SECRET: session cookies and tokens live
-- here, in the database volume only. Never exported, never logged (AGENTS.md
-- §10 rule 3).
CREATE TABLE IF NOT EXISTS session (
child_id INTEGER PRIMARY KEY REFERENCES children(id),
state TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

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()
}