Files
compass_teams_nhs/cmd/school/main.go
Sam Rolfe f6be17fe4f Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:01:30 +11:00

186 lines
4.4 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 "calendar":
calendarCommand(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)
}