Files
compass_teams_nhs/cmd/school/main.go
Sam Rolfe 37d82b1a0a Phase 5: per-child dashboard (overdue first), item detail, delete
- internal/dashboard: classify items into overdue / due today / due this week /
  no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
  and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
  POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
  banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
  including the NO SCAN / SCAN FAILED / healthy banner behaviour.

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

183 lines
4.3 KiB
Go

// Command school is the Compass / NHS service (school.lab.audasmedia.com.au).
//
// Phase 1 skeleton: Go service, SQLite schema (AGENTS.md §7), the service's own
// session login, HTMX server-rendered shell, and GET /health. It does not scan
// the school; the Compass login and fetch are Phase 2.
package main
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/web"
)
// version is injected with -ldflags at build time.
var version = "dev"
// seedChildren is the fixed set from AGENTS.md: Harry and Finn.
var seedChildren = []string{"Harry", "Finn"}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "hash-password":
hashPassword(os.Args[2:])
return
case "gen-secret":
genSecret()
return
case "parse":
parseCommand(os.Args[2:])
return
case "session":
sessionCommand(os.Args[2:])
return
case "scan":
scanCommand(os.Args[2:])
return
case "items":
itemsCommand(os.Args[2:])
return
case "brief":
briefCommand(os.Args[2:])
return
case "version":
fmt.Println(version)
return
}
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
}
func run() error {
started := time.Now()
cfg, err := config.Load()
if err != nil {
return err
}
cfg.Version = version
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(log)
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
return err
}
if err := st.SeedChildren(context.Background(), seedChildren...); err != nil {
return err
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil {
return err
}
if !am.Hashed() {
log.Warn("admin password is plaintext; set SCHOOL_ADMIN_PASSWORD_HASH to use bcrypt")
}
srv, err := web.New(cfg, st, am, log)
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("school service listening",
"addr", cfg.Addr, "db", cfg.DBPath, "env", cfg.AppEnv, "version", version)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
if err := httpServer.Shutdown(ctxShutdown); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Info("stopped", "uptime", time.Since(started).Round(time.Second).String())
return nil
}
// hashPassword prints a bcrypt hash for the password given as an argument or
// read from stdin. It never prints the password itself.
func hashPassword(args []string) {
var password string
if len(args) > 0 {
password = args[0]
} else {
b, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
password = string(b)
}
if password == "" {
fmt.Fprintln(os.Stderr, "school: empty password")
os.Exit(2)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
fmt.Println(string(hash))
}
// genSecret prints a fresh 32-byte hex secret. Use it to set
// SCHOOL_SESSION_SECRET without pasting a secret into a shell history file by
// hand. It never reads or stores anything.
func genSecret() {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
fmt.Printf("%x\n", b)
}