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:
67
internal/store/migrations/001_init.sql
Normal file
67
internal/store/migrations/001_init.sql
Normal 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
271
internal/store/store.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user