Files
compass_teams_nhs/internal/config/config.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

218 lines
6.7 KiB
Go

// Package config loads the Compass/NHS service configuration from the process
// environment. Every secret comes from the environment (environment.d); nothing
// is compiled into the binary and nothing secret is read from a committed file.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
// Embed the IANA time zone database so SCHOOL_TIMEZONE resolves even in a
// container without tzdata.
_ "time/tzdata"
)
// Config is the fully resolved runtime configuration.
type Config struct {
// Addr is the listen address, for example ":8792".
Addr string
// DBPath is the SQLite database file, for example "/data/school.db".
DBPath string
// SessionSecret signs the session cookie. Required, >= 16 bytes.
SessionSecret []byte
// AdminUsername is the single dashboard login name (Sam).
AdminUsername string
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool
// SessionTTL is how long a session cookie stays valid.
SessionTTL time.Duration
// LogLevel is the slog level.
LogLevel slog.Level
// AppEnv is a label such as "production" or "development".
AppEnv string
// Version is injected at build time.
Version string
// Location is the family timezone. Due dates and briefs are wall-clock, so
// this must be the family's zone, not the container default of UTC.
Location *time.Location
// Calendar is the Home Assistant calendar target for important school
// events (AGENTS.md §4 FR-8).
Calendar CalendarConfig
}
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes
// from the environment (environment.d); the token never reaches git, the
// database or the logs (AGENTS.md §10 rule 3, AUD-110).
//
// Environment variables:
//
// SCHOOL_HA_URL base URL, for example http://192.168.20.30:8123
// SCHOOL_HA_TOKEN long-lived access token (secret)
// SCHOOL_HA_CALENDAR_ENTITY calendar entity id, for example calendar.school
// SCHOOL_HA_INSECURE true to skip TLS verification (internal CA)
// SCHOOL_HA_TIMEOUT per-request timeout, default 20s
//
// Calendar writing stays off until all three of URL, token and entity are set,
// so the service still starts before the calendar is configured.
type CalendarConfig struct {
BaseURL string
Token string
EntityID string
Insecure bool
Timeout time.Duration
}
// Enabled reports whether the calendar target is fully configured.
func (c CalendarConfig) Enabled() bool {
return c.BaseURL != "" && c.Token != "" && c.EntityID != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the target is usable.
func (c CalendarConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_HA_URL")
}
if c.Token == "" {
out = append(out, "SCHOOL_HA_TOKEN")
}
if c.EntityID == "" {
out = append(out, "SCHOOL_HA_CALENDAR_ENTITY")
}
return out
}
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
const DefaultTimezone = "Australia/Sydney"
// Load reads and validates the configuration.
func Load() (*Config, error) {
cfg := &Config{
Addr: env("SCHOOL_ADDR", ":8792"),
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
AdminUsername: env("SCHOOL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("SCHOOL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("SCHOOL_ADMIN_PASSWORD_HASH"),
CookieSecure: envBool("SCHOOL_COOKIE_SECURE", true),
AppEnv: env("SCHOOL_APP_ENV", "production"),
}
secret := os.Getenv("SCHOOL_SESSION_SECRET")
if len(secret) < 16 {
return nil, errors.New("SCHOOL_SESSION_SECRET must be set and at least 16 characters")
}
cfg.SessionSecret = []byte(secret)
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
return nil, errors.New("set SCHOOL_ADMIN_PASSWORD or SCHOOL_ADMIN_PASSWORD_HASH")
}
if cfg.AdminUsername == "" {
return nil, errors.New("SCHOOL_ADMIN_USERNAME must not be empty")
}
ttl, err := time.ParseDuration(env("SCHOOL_SESSION_TTL", "12h"))
if err != nil {
return nil, fmt.Errorf("SCHOOL_SESSION_TTL: %w", err)
}
cfg.SessionTTL = ttl
lvl, err := parseLevel(env("SCHOOL_LOG_LEVEL", "info"))
if err != nil {
return nil, err
}
cfg.LogLevel = lvl
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
cfg.Location = loc
cfg.Calendar = loadCalendar()
return cfg, nil
}
// DBConfig is the minimum a database-only command needs. The parse subcommand
// opens the same SQLite file as the service without requiring the service's
// session secret or login.
type DBConfig struct {
DBPath string
Location *time.Location
Calendar CalendarConfig
}
// LoadDB reads the database path and the family timezone only.
func LoadDB() (*DBConfig, error) {
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
return &DBConfig{
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
Location: loc,
Calendar: loadCalendar(),
}, nil
}
// loadCalendar reads the Home Assistant calendar target. It never fails: an
// empty value simply leaves the target disabled, so the service boots before
// the calendar is configured and the calendar commands fail loudly instead.
func loadCalendar() CalendarConfig {
timeout := 20 * time.Second
if v := strings.TrimSpace(os.Getenv("SCHOOL_HA_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
timeout = d
}
}
return CalendarConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCHOOL_HA_URL")), "/"),
Token: strings.TrimSpace(os.Getenv("SCHOOL_HA_TOKEN")),
EntityID: strings.TrimSpace(os.Getenv("SCHOOL_HA_CALENDAR_ENTITY")),
Insecure: envBool("SCHOOL_HA_INSECURE", false),
Timeout: timeout,
}
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info", "":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("SCHOOL_LOG_LEVEL: unknown level %q", s)
}
}