diff --git a/Makefile b/Makefile index 67d2b72..5197cb1 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.3.0-phase5 -BUILD_VERSION ?= 0.3.0-phase5 +IMAGE ?= compass-teams-nhs/school:0.4.0-phase6 +BUILD_VERSION ?= 0.4.0-phase6 # Parse options (see scripts/parse-compass.sh). CHILD ?= Harry @@ -16,7 +16,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)" -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -v "$$PWD":/src -w /src $(GO_IMAGE) -.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan +.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar help: @echo "make test run go test ./... in golang:1.25-alpine" @@ -31,6 +31,7 @@ help: @echo "make gen-secret print a fresh session secret" @echo "make parse parse stored Compass pages into SQLite (Phase 3)" @echo "make scan fetch + parse one or both children (Phase 4; SCAN_CHILD=Harry|Finn|all)" + @echo "make calendar push important events to the HA calendar (Phase 6; CALENDAR_ARGS=...)" @echo "make verify-phase1 prove /health, the login and the Caddy route" test: @@ -78,6 +79,10 @@ parse: scan: CLASSIFY=$(CLASSIFY) IMAGE=$(IMAGE) scripts/scan-compass.sh $(if $(filter-out all,$(SCAN_CHILD)),--child $(SCAN_CHILD)) +# Phase 6: Home Assistant calendar. CALENDAR_ARGS defaults to 'status'. +calendar: + IMAGE=$(IMAGE) scripts/calendar-push.sh $(if $(CALENDAR_ARGS),$(CALENDAR_ARGS),status) + # Phase 1 definition of done: /health 200, login page + session login, and the # Caddy route on .35. verify-phase1: diff --git a/README.md b/README.md index bae099c..0c752a0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,30 @@ A school service for Harry and Finn: **Compass** for the daily schedule and notices, **Teams** for homework and assignments. It stores what is due per child, puts overdue work first, and tells the family before work is late. -> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 5** (the -> per-child dashboard). See §11 of the spec for the phase order. +> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 6** (the +> calendar write). See §11 of the spec for the phase order. + +## Status — Phase 6 + +Phase 6 writes the **important** school events to **Sam's Home Assistant +calendar** through the Home Assistant REST API (FR-8). It is idempotent (a +re-run never duplicates an event) and it fails loudly (a failed write is shown +on the dashboard and recorded in `calendar_pushes`). See +[`docs/phase-6-calendar-notes.md`](docs/phase-6-calendar-notes.md). + +**Open decision:** the brief says *not Google*, and Home Assistant has no +non-Google writable calendar yet. The target entity is configurable +(`SCHOOL_HA_CALENDAR_ENTITY`) and the question is on +[AUD-110](/AUD/issues/AUD-110). + +```bash +scripts/calendar-push.sh entities # list the writable calendar entities +scripts/calendar-push.sh status # target, reachability, pending, last push +scripts/calendar-push.sh push --dry-run +scripts/calendar-push.sh push # write the pending important events +scripts/calendar-push.sh events # read the calendar back +make calendar CALENDAR_ARGS='push --dry-run' +``` ## Status — Phase 5 @@ -55,9 +77,9 @@ Built and deployed on `.13`: - Its own container and port **8792**, behind Caddy at `school.lab.audasmedia.com.au`. -Not built yet (later phases): the calendar write, notifications and the Hub API. -The service itself does **not** scan the school; n8n triggers the scan later. -Teams (FR-15) is a separate source. +Not built yet (later phases): notifications and the Hub API. The calendar write +is Phase 6. The service itself does **not** scan the school; n8n triggers the +scan later. Teams (FR-15) is a separate source. ### Fail loudly (FR-12) @@ -76,10 +98,11 @@ internal/config/ environment configuration internal/store/ SQLite: schema, migrations, queries internal/parse/ HTML -> structured items (tasks, messages, events) internal/llm/ OmniRoute client (importance classification only) +internal/calendar/ Home Assistant calendar client + the idempotent sync internal/auth/ session login (signed cookies, CSRF) internal/web/ HTTP handlers, templates, static assets deploy/ Caddy snippet, deployment notes, verify script -scripts/ Compass fetchers and the parse wrapper +scripts/ Compass fetchers, the parse wrapper and the calendar push environment.d/ env example (the real file is gitignored) ``` @@ -94,8 +117,8 @@ docker compose up -d --build Generate secrets with the built image: ```bash -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 gen-secret -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 hash-password 'the-password' +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 gen-secret +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 hash-password 'the-password' ``` Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash diff --git a/cmd/school/calendar.go b/cmd/school/calendar.go new file mode 100644 index 0000000..35aaf83 --- /dev/null +++ b/cmd/school/calendar.go @@ -0,0 +1,364 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "sort" + "strings" + "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +// calendarCommand is the `school calendar` entry point (AGENTS.md §11 Phase 6). +// It writes important school events to Sam's Home Assistant calendar and reads +// the calendar back. The school stays read-only. +func calendarCommand(args []string) { + if len(args) == 0 { + usage("school calendar entities | status | push [--child NAME] [--item N] [--limit N] [--dry-run] [--json] | events [--days N]") + } + switch args[0] { + case "entities": + calendarEntities(args[1:]) + case "status": + calendarStatus(args[1:]) + case "push": + calendarPush(args[1:]) + case "events": + calendarEvents(args[1:]) + default: + usage("school calendar entities | status | push | events") + } +} + +// openCalendar builds the store and the Home Assistant client. The token is +// never printed: the caller only ever sees the host and the entity id. +func openCalendar(ctx context.Context) (*store.Store, *config.DBConfig, *calendar.Client, error) { + cfg, err := config.LoadDB() + if err != nil { + return nil, nil, nil, err + } + st, err := store.Open(cfg.DBPath) + if err != nil { + return nil, nil, nil, err + } + if err := st.Migrate(ctx); err != nil { + _ = st.Close() + return nil, nil, nil, err + } + client, err := calendar.New(cfg.Calendar, nil) + if err != nil { + _ = st.Close() + return nil, nil, nil, err + } + return st, cfg, client, nil +} + +// calendarEntities lists the calendar entities Home Assistant reports, so the +// operator can choose SCHOOL_HA_CALENDAR_ENTITY from real values. +func calendarEntities(args []string) { + fs := flag.NewFlagSet("calendar entities", flag.ContinueOnError) + _ = fs.Parse(args) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + st, cfg, client, err := openCalendar(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(2) + } + defer st.Close() + + if err := client.Healthy(ctx); err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + entities, err := client.Calendars(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL)) + fmt.Printf("configured %s\n", cfg.Calendar.EntityID) + for _, e := range entities { + writable := "read-only" + if e.CanWrite() { + writable = "writable" + } + marker := " " + if e.EntityID == cfg.Calendar.EntityID { + marker = "*" + } + fmt.Printf("%s %-45s %-10s %s\n", marker, e.EntityID, writable, e.Name) + } +} + +// calendarStatus shows the configured target (never the token) and the last +// recorded push, so a failure is visible outside the dashboard too (FR-12). +func calendarStatus(args []string) { + fs := flag.NewFlagSet("calendar status", flag.ContinueOnError) + var asJSON bool + fs.BoolVar(&asJSON, "json", false, "print the status as JSON") + _ = fs.Parse(args) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + st, cfg, client, err := openCalendar(ctx) + if err != nil { + // A missing target is itself the status: report it, do not crash. + fmt.Fprintf(os.Stderr, "school calendar: %v\n", err) + os.Exit(2) + } + defer st.Close() + + summary, err := st.CalendarPushSummary(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + now := time.Now().In(cfg.Location) + candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + + if asJSON { + out := struct { + BaseURL string `json:"base_url"` + Entity string `json:"entity"` + TokenSet bool `json:"token_set"` + Pending int `json:"pending_events"` + ReloadedAt string `json:"checked_at"` + HomeAssist string `json:"ha_status"` + LastPush *store.CalendarPush `json:"last_push,omitempty"` + PushSummary store.CalendarPushStatus `json:"pushes"` + }{ + BaseURL: haHost(cfg.Calendar.BaseURL), + Entity: cfg.Calendar.EntityID, + TokenSet: cfg.Calendar.Token != "", + Pending: len(candidates), + ReloadedAt: now.Format(time.RFC3339), + PushSummary: summary, + } + if err := client.Healthy(ctx); err != nil { + out.HomeAssist = "unreachable: " + err.Error() + } else { + out.HomeAssist = "ok" + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + return + } + + fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL)) + fmt.Printf("entity %s\n", cfg.Calendar.EntityID) + fmt.Printf("token set\n") + if err := client.Healthy(ctx); err != nil { + fmt.Printf("reachable no (%v)\n", err) + } else { + fmt.Printf("reachable yes\n") + } + fmt.Printf("pending events %d (important, not in the calendar yet)\n", len(candidates)) + fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed) + if summary.Last != nil { + state := "ok" + if !summary.Last.OK { + state = "FAILED: " + summary.Last.Error + } + fmt.Printf("last push item %d %s %s\n", summary.Last.ItemID, summary.Last.CreatedAt.In(cfg.Location).Format("2006-01-02 15:04"), state) + } else { + fmt.Printf("last push never run\n") + } +} + +// calendarPush writes the pending important events and records every attempt. +func calendarPush(args []string) { + fs := flag.NewFlagSet("calendar push", flag.ContinueOnError) + var ( + child string + itemID int64 + limit int + dryRun bool + asJSON bool + ) + fs.StringVar(&child, "child", "", "limit to one child (Harry or Finn)") + fs.Int64Var(&itemID, "item", 0, "push exactly one stored item id") + fs.IntVar(&limit, "limit", 0, "cap how many events are written") + fs.BoolVar(&dryRun, "dry-run", false, "select and report without writing") + fs.BoolVar(&asJSON, "json", false, "print the result as JSON") + _ = fs.Parse(args) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + st, cfg, client, err := openCalendar(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(2) + } + defer st.Close() + + opts := calendar.Options{Limit: limit, DryRun: dryRun, Now: time.Now().In(cfg.Location)} + if child != "" { + c, err := st.ChildByName(ctx, child) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + if c == nil { + fmt.Fprintf(os.Stderr, "school calendar: unknown child %q\n", child) + os.Exit(2) + } + opts.ChildID = &c.ID + } + if itemID > 0 { + opts.ItemID = &itemID + } + + res, err := calendar.Sync(ctx, st, client, opts, nil) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(res); err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + } else { + printCalendarResult(res, cfg.Location) + } + + if res.Failed > 0 { + os.Exit(1) + } +} + +func printCalendarResult(res calendar.Result, loc *time.Location) { + verb := "created" + if res.DryRun { + verb = "would create" + } + fmt.Printf("entity %s\n", res.Entity) + fmt.Printf("candidates %d\n", res.Candidates) + fmt.Printf("%-11s %d\n", verb, res.Created) + fmt.Printf("skipped %d (already in the calendar)\n", res.Skipped) + fmt.Printf("failed %d\n", res.Failed) + for _, ev := range res.Events { + start := ev.Start + if t, err := time.Parse(time.RFC3339, ev.Start); err == nil { + start = t.In(loc).Format("Mon 02 Jan 15:04") + } + uid := ev.UID + if uid == "" { + uid = "-" + } + fmt.Printf(" item %-5d %-6s %-10s %-45s uid=%s\n", ev.ItemID, ev.Child, start, truncate(ev.Title, 45), uid) + } + for _, msg := range res.Errors { + fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg) + } +} + +// calendarEvents reads the calendar back, which is the evidence that an event +// really is in Sam's Home Assistant calendar. +func calendarEvents(args []string) { + fs := flag.NewFlagSet("calendar events", flag.ContinueOnError) + var ( + days int + asJSON bool + ) + fs.IntVar(&days, "days", 14, "how many days ahead to read") + fs.BoolVar(&asJSON, "json", false, "print the events as JSON") + _ = fs.Parse(args) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + st, cfg, client, err := openCalendar(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(2) + } + defer st.Close() + + now := time.Now().In(cfg.Location) + end := now.AddDate(0, 0, days) + events, err := client.Events(ctx, now.Add(-24*time.Hour), end) + if err != nil { + fmt.Fprintln(os.Stderr, "school calendar:", err) + os.Exit(1) + } + sort.Slice(events, func(i, j int) bool { + return eventStart(events[i]).Before(eventStart(events[j])) + }) + + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(events) + return + } + if len(events) == 0 { + fmt.Printf("no events on %s between %s and %s\n", + cfg.Calendar.EntityID, + now.Format("2006-01-02 15:04"), + end.Format("2006-01-02 15:04")) + return + } + for _, ev := range events { + start := eventStart(ev) + fmt.Printf("%s %-45s %s\n", + start.In(cfg.Location).Format("Mon 02 Jan 15:04"), + truncate(ev.Summary, 45), + shortMarker(ev.Description)) + } +} + +func eventStart(ev calendar.Event) time.Time { + if ev.Start != nil { + return *ev.Start + } + return time.Time{} +} + +// shortMarker shows only the compass-nhs marker of a description, so a read-back +// stays a one-line list. +func shortMarker(desc string) string { + const open = "[compass-nhs:" + i := strings.Index(desc, open) + if i < 0 { + return "" + } + j := strings.Index(desc[i:], "]") + if j < 0 { + return "" + } + return desc[i : i+j+1] +} + +// haHost prints the Home Assistant host only. The token and any query string +// stay out of the output (AGENTS.md §10 rule 3). +func haHost(base string) string { + u, err := url.Parse(base) + if err != nil || u.Host == "" { + return base + } + return u.Scheme + "://" + u.Host +} diff --git a/cmd/school/main.go b/cmd/school/main.go index 1e46fbc..86fd8e6 100644 --- a/cmd/school/main.go +++ b/cmd/school/main.go @@ -56,6 +56,9 @@ func main() { case "brief": briefCommand(os.Args[2:]) return + case "calendar": + calendarCommand(os.Args[2:]) + return case "version": fmt.Println(version) return diff --git a/docker-compose.yml b/docker-compose.yml index 2e1a7de..352f904 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.3.0-phase5" - image: compass-teams-nhs/school:0.3.0-phase5 + VERSION: "0.4.0-phase6" + image: compass-teams-nhs/school:0.4.0-phase6 container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/phase-6-calendar-notes.md b/docs/phase-6-calendar-notes.md new file mode 100644 index 0000000..620ecc0 --- /dev/null +++ b/docs/phase-6-calendar-notes.md @@ -0,0 +1,130 @@ +# Phase 6 — the calendar write (Sam's Home Assistant calendar) + +Status: **code complete and tested; the calendar target needs Sam's decision** +(AUD-110). The service now writes important school events through the Home +Assistant REST API. Nothing is written until a calendar entity is chosen. + +## What it does + +`school calendar push` selects the important school events that are not in the +calendar yet and writes them with the Home Assistant service +`calendar.create_event`. The school is never touched (AGENTS.md §10 rule 1); the +only write is to the calendar (rule 7 — no approval needed). + +- **Important** is the Phase 3 classifier: `internal/parse/importance.go` marks + an event `high` when it is a **school event**, and `normal` when it is a + routine class period. A Compass week grid writes a class as + `5 - 11HLEGB5 - D8 - LHT` and an event as + `End of Year Senior Music Concert - HALL (Senior Music Concert) - KSH, CFR`. + The first part of a lesson is a period label (`5`, `C`, `RLE GROUP D`). +- Only **future** events are written: a past lesson is history, not a plan. +- Which events qualify is visible before anything is written: + +```bash +scripts/calendar-push.sh push --dry-run +``` + +## Idempotency (a re-run never duplicates) + +Two independent guards: + +1. The item stores the Home Assistant event id (`items.ha_event_uid`). An item + that already has one is not a candidate again. +2. The event description carries a marker, `source: [compass-nhs:]`. A + run lists the calendar first and adopts any event that already carries the + marker for that item, even when Home Assistant reports no event id. + +`items.ha_pushed_at` records when the item was written. + +## Fail loudly (FR-12) + +A failed calendar write is never swallowed. + +- Every attempt is appended to `calendar_pushes` (`ok = 0` plus the error). +- The dashboard shows a **Calendar** banner: `CALENDAR NOT CONFIGURED`, + `CALENDAR WRITE FAILED` with the Home Assistant reason, or the healthy state. +- The item screen shows the failure in place of a confirmation. +- `scripts/calendar-push.sh push` exits non-zero when any write failed. + +A failed write leaves the item as a candidate, so the next run retries it. + +## Configuration (names only — never the values) + +| Variable | Meaning | +|---|---| +| `SCHOOL_HA_URL` | Home Assistant base URL, for example `http://192.168.20.30:8123` | +| `SCHOOL_HA_TOKEN` | long-lived access token (**secret**) | +| `SCHOOL_HA_CALENDAR_ENTITY` | the calendar entity to write to, for example `calendar.school` | +| `SCHOOL_HA_INSECURE` | `true` to skip TLS verification for the internal `.lab` certificate | +| `SCHOOL_HA_TIMEOUT` | per-request timeout (default `20s`) | + +The values live in `environment.d/school-service.env`, which is **gitignored** +(AGENTS.md §10 rule 3). The token is never logged, never printed by the CLI and +never stored in SQLite. + +## Commands + +```bash +scripts/calendar-push.sh entities # list calendar.* entities and whether they are writable +scripts/calendar-push.sh status # target, reachability, pending events, last push +scripts/calendar-push.sh push # write the pending important events +scripts/calendar-push.sh events # read the calendar back (evidence) +scripts/calendar-push.sh push --dry-run --json +``` + +`make calendar CALENDAR_ARGS='push --dry-run'` runs the same thing. + +The item screen has an **Add to calendar** button for an important future event, +and shows `in the calendar` with the Home Assistant event id once it is written. + +## The open decision: which calendar entity? + +The Home Assistant instance (`192.168.20.30`, HA 2026.9.4) has three calendar +entities, and only two are writable: + +| Entity | Writable | Backing | +|---|---|---| +| `calendar.samuelrolfe_googlemail_com` | yes | Google, synced into Home Assistant | +| `calendar.birthdays` | yes | Google | +| `calendar.holidays_in_australia` | no | Google | + +The brief says **Sam's Home Assistant calendar (not Google)**. Today Home +Assistant has **no non-Google writable calendar**: the `local_calendar` +integration is not installed, and no CalDAV/remote calendar is configured. + +So the target must be chosen before the event is written: + +1. **Add a Local Calendar to Home Assistant** (recommended). One Home Assistant + config-flow entry creates `calendar.school`. It is Home-Assistant-local, never + touches Google, and is entirely additive. The service then uses + `SCHOOL_HA_CALENDAR_ENTITY=calendar.school`. +2. **Use `calendar.samuelrolfe_googlemail_com`.** No Home Assistant change, but + every school event also appears in Sam's personal Google Calendar. + +The question is posted on AUD-110. The code needs only the configuration line +changed once the answer is in. + +## Where the code lives + +``` +internal/calendar/ha.go Home Assistant REST client (states, create, get, delete) +internal/calendar/sync.go selection, idempotency, the push log +internal/store/calendar.go candidates, mark-synced, the append-only push log +internal/store/migrations/003_calendar.sql +cmd/school/calendar.go `school calendar entities|status|push|events` +internal/web/server.go the item action + the fail-loud calendar banner +scripts/calendar-push.sh one-off container runner (no service restart) +``` + +## Verification + +```bash +make test # go test ./... +``` + +The tests cover the Home Assistant client against a fake Home Assistant (create, +read-back, error mapping), the sync run (write once, adopt by marker, dry run, +loud failure, refuse-to-write-blind), the store (candidates, the push log) and +the web surface (the action, the CSRF guard, the failure banner). + +Not tested here: the live Home Assistant write. That needs the entity decision. diff --git a/environment.d/school-service.env.example b/environment.d/school-service.env.example index f12abaa..09b9c02 100644 --- a/environment.d/school-service.env.example +++ b/environment.d/school-service.env.example @@ -28,3 +28,20 @@ SCHOOL_TIMEZONE=Australia/Sydney SCHOOL_LOG_LEVEL=info SCHOOL_APP_ENV=production + +# ---- Home Assistant calendar (AGENTS.md §4 FR-8, Phase 6) ------------------- +# Important school events are written to Sam's Home Assistant calendar through +# the Home Assistant REST API. Nothing is written until all three of URL, token +# and entity are set; the dashboard shows CALENDAR NOT CONFIGURED until then. +# +# The token is a long-lived access token created on Sam's Home Assistant +# profile page. It is a SECRET: keep it in this file only. +SCHOOL_HA_URL=http://192.168.20.30:8123 +SCHOOL_HA_TOKEN= +# The calendar entity to write to. Discover the real ids with: +# scripts/calendar-push.sh entities +SCHOOL_HA_CALENDAR_ENTITY=calendar.school +# The internal .lab Caddy certificate is not in the container trust store. +# Set true only when SCHOOL_HA_URL is the https://homeassistant.home.lab URL. +SCHOOL_HA_INSECURE=false +SCHOOL_HA_TIMEOUT=20s diff --git a/internal/calendar/ha.go b/internal/calendar/ha.go new file mode 100644 index 0000000..a96abc7 --- /dev/null +++ b/internal/calendar/ha.go @@ -0,0 +1,393 @@ +// Package calendar writes important school events to Sam's Home Assistant +// calendar (AGENTS.md §4 FR-8, §8, §11 Phase 6). It talks to the Home Assistant +// REST API only; the school itself stays read-only. The token lives in the +// environment and is never logged. +package calendar + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" +) + +// ErrorID returns the Home Assistant error id from a failed call, plus the +// HTTP status. It exists so a caller can tell an expired token from a missing +// entity without parsing a message. +type Error struct { + // Status is the HTTP status code, or 0 for a transport error. + Status int + // ID is the Home Assistant error id such as "unauthorized" or "not_found". + ID string + // Message is the human-readable detail. + Message string +} + +func (e *Error) Error() string { + switch { + case e.ID != "" && e.Message != "": + return fmt.Sprintf("home assistant %s: %s", e.ID, e.Message) + case e.ID != "": + return "home assistant: " + e.ID + case e.Message != "": + return "home assistant: " + e.Message + default: + return "home assistant call failed" + } +} + +// Client is a Home Assistant REST client for one calendar entity. +type Client struct { + baseURL string + token string + entity string + http *http.Client + log *slog.Logger +} + +// New builds a client from configuration. It fails loudly when the target is +// incomplete, so the caller never silently writes nowhere. +func New(cfg config.CalendarConfig, log *slog.Logger) (*Client, error) { + if log == nil { + log = slog.Default() + } + if missing := cfg.Missing(); len(missing) > 0 { + return nil, fmt.Errorf("home assistant calendar is not configured: set %s", strings.Join(missing, ", ")) + } + if _, err := url.Parse(cfg.BaseURL); err != nil { + return nil, fmt.Errorf("SCHOOL_HA_URL %q: %w", cfg.BaseURL, err) + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + transport := http.DefaultTransport.(*http.Transport).Clone() + if cfg.Insecure { + // Internal CA only (the .lab Caddy cert). Never enabled by default. + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + } + return &Client{ + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + token: cfg.Token, + entity: cfg.EntityID, + http: &http.Client{Timeout: timeout, Transport: transport}, + log: log, + }, nil +} + +// Entity is the configured calendar entity id. +func (c *Client) Entity() string { return c.entity } + +// CalendarEntity is one calendar.* entity reported by Home Assistant. +type CalendarEntity struct { + EntityID string + Name string + State string + // Features is the raw supported_features bit field. + Features int +} + +// CanWrite reports whether the entity advertises the create-event feature +// (Home Assistant CalendarEntityFeature.CREATE_EVENT = 1). +func (e CalendarEntity) CanWrite() bool { return e.Features&1 != 0 } + +// Healthy verifies the base URL and the token with GET /api/. +func (c *Client) Healthy(ctx context.Context) error { + var out struct { + Message string `json:"message"` + } + if err := c.do(ctx, http.MethodGet, "/api/", nil, &out); err != nil { + return fmt.Errorf("home assistant ping: %w", err) + } + if !strings.Contains(strings.ToLower(out.Message), "running") { + return fmt.Errorf("home assistant ping: unexpected reply %q", out.Message) + } + return nil +} + +// Calendars lists every calendar entity, so the operator can pick one. It is +// the discovery step behind `school calendar entities`. +func (c *Client) Calendars(ctx context.Context) ([]CalendarEntity, error) { + var states []struct { + EntityID string `json:"entity_id"` + State string `json:"state"` + Attributes struct { + FriendlyName string `json:"friendly_name"` + SupportedFeatures int `json:"supported_features"` + } `json:"attributes"` + } + if err := c.do(ctx, http.MethodGet, "/api/states", nil, &states); err != nil { + return nil, fmt.Errorf("home assistant states: %w", err) + } + var out []CalendarEntity + for _, s := range states { + if !strings.HasPrefix(s.EntityID, "calendar.") { + continue + } + name := s.Attributes.FriendlyName + if name == "" { + name = s.EntityID + } + out = append(out, CalendarEntity{ + EntityID: s.EntityID, + Name: name, + State: s.State, + Features: s.Attributes.SupportedFeatures, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].EntityID < out[j].EntityID }) + return out, nil +} + +// Event is one calendar event. UID is Home Assistant's id, when it reports one. +type Event struct { + UID string `json:"uid,omitempty"` + Summary string `json:"summary"` + Description string `json:"description,omitempty"` + Location string `json:"location,omitempty"` + Start *time.Time `json:"-"` + End *time.Time `json:"-"` + StartRaw string `json:"-"` + EndRaw string `json:"-"` +} + +// CreateEvent writes one event with calendar.create_event and returns the Home +// Assistant event id when the service reports one. It asks for the service +// response, so the id can be stored on the item and a re-run cannot duplicate +// the event. +func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) { + if strings.TrimSpace(ev.Summary) == "" { + return "", errors.New("calendar: event summary is empty") + } + if ev.Start == nil || ev.End == nil { + return "", errors.New("calendar: event start and end are required") + } + body := map[string]any{ + "entity_id": c.entity, + "summary": ev.Summary, + "start_date_time": ev.Start.Format(time.RFC3339), + "end_date_time": ev.End.Format(time.RFC3339), + } + if ev.Description != "" { + body["description"] = ev.Description + } + if ev.Location != "" { + body["location"] = ev.Location + } + + resp, err := c.callService(ctx, "calendar", "create_event", body) + if err != nil { + return "", err + } + return serviceUID(resp, c.entity), nil +} + +// DeleteEvent removes an event by its Home Assistant uid. It exists so a test +// event can be cleaned up, and so a wrong write can be undone. +func (c *Client) DeleteEvent(ctx context.Context, uid string) error { + if strings.TrimSpace(uid) == "" { + return errors.New("calendar: delete needs a uid") + } + _, err := c.callService(ctx, "calendar", "delete_event", map[string]any{ + "entity_id": c.entity, + "uid": uid, + }) + return err +} + +// Events lists the events on the calendar between start and end (exclusive). +// The sync uses it to see whether a marker is already present, so a re-run is +// idempotent even when Home Assistant reports no uid. +func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, error) { + resp, err := c.callService(ctx, "calendar", "get_events", map[string]any{ + "entity_id": c.entity, + "start_date_time": start.Format(time.RFC3339), + "end_date_time": end.Format(time.RFC3339), + }) + if err != nil { + return nil, err + } + return serviceEvents(resp, c.entity), nil +} + +// ---------- transport ---------- + +// callService posts to /api/services// with return_response, +// so a service that reports data (the created event id) answers with it. +func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any) (map[string]any, error) { + path := fmt.Sprintf("/api/services/%s/%s?return_response", domain, service) + var out map[string]any + if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil { + return nil, err + } + return out, nil +} + +// do performs one authenticated request. A non-2xx answer becomes an *Error +// carrying the Home Assistant error id, so the caller can act (and report) with +// the real reason instead of a bare status code. +func (c *Client) do(ctx context.Context, method, path string, body any, out any) error { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + reader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return &Error{Message: fmt.Sprintf("request to %s failed: %v", path, err)} + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return &Error{Status: resp.StatusCode, Message: "reading the reply failed: " + err.Error()} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return haError(resp.StatusCode, raw) + } + if out == nil || len(bytes.TrimSpace(raw)) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected reply body: %v", err)} + } + return nil +} + +// haError turns a Home Assistant error body into an *Error. +func haError(status int, raw []byte) *Error { + var shape struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + _ = json.Unmarshal(raw, &shape) + msg := shape.Error.Message + if msg == "" { + msg = shape.Message + } + if msg == "" { + msg = strings.TrimSpace(string(raw)) + } + return &Error{Status: status, ID: shape.Error.Code, Message: msg} +} + +// serviceUID digs the created event id out of a service response. Home +// Assistant answers with {"service_response": {"calendar.x": {"uid": "..."}}}; +// some integrations nest the event under "result" or "event", so all three are +// tried. An empty result is not an error: the description marker keeps the +// write idempotent. +func serviceUID(resp map[string]any, entity string) string { + for _, payload := range servicePayloads(resp, entity) { + if uid, ok := payload["uid"].(string); ok && uid != "" { + return uid + } + for _, key := range []string{"result", "event"} { + if nested, ok := payload[key].(map[string]any); ok { + if uid, ok := nested["uid"].(string); ok && uid != "" { + return uid + } + } + } + } + return "" +} + +// serviceEvents reads the event list out of a calendar.get_events response. +func serviceEvents(resp map[string]any, entity string) []Event { + var out []Event + for _, payload := range servicePayloads(resp, entity) { + raw, ok := payload["events"].([]any) + if !ok { + continue + } + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + ev := Event{ + UID: str(m["uid"]), + Summary: str(m["summary"]), + Description: str(m["description"]), + Location: str(m["location"]), + } + // Home Assistant reports a date or a date-time; try date-time first. + ev.Start, ev.StartRaw = parseHATime(m, "start") + ev.End, ev.EndRaw = parseHATime(m, "end") + out = append(out, ev) + } + } + return out +} + +// servicePayloads returns the per-entity payload in a service response, +// tolerating both the wrapped ({"service_response": {...}}) and the bare +// shape. The entity key wins; other keys are only a fallback when Home +// Assistant renamed it. +func servicePayloads(resp map[string]any, entity string) []map[string]any { + if resp == nil { + return nil + } + root := resp + if sr, ok := resp["service_response"].(map[string]any); ok { + root = sr + } + if payload, ok := root[entity].(map[string]any); ok { + return []map[string]any{payload} + } + var out []map[string]any + for _, v := range root { + if payload, ok := v.(map[string]any); ok { + out = append(out, payload) + } + } + return out +} + +func parseHATime(m map[string]any, prefix string) (*time.Time, string) { + if raw := str(m[prefix+"_date_time"]); raw != "" { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return &t, raw + } + } + if raw := str(m[prefix+"_date"]); raw != "" { + if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil { + return &t, raw + } + return nil, raw + } + return nil, "" +} + +func str(v any) string { + s, _ := v.(string) + return s +} diff --git a/internal/calendar/ha_test.go b/internal/calendar/ha_test.go new file mode 100644 index 0000000..8d58ce3 --- /dev/null +++ b/internal/calendar/ha_test.go @@ -0,0 +1,311 @@ +package calendar + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" +) + +// fakeHA is a minimal Home Assistant REST API for the calendar tests. It +// records what the client sent, so a test can prove the exact service calls. +type fakeHA struct { + *httptest.Server + + mu sync.Mutex + entity string + events []map[string]any + createCalls int + getCalls int + failCreate bool + returnUID bool + lastCreate map[string]any + lastGet map[string]any +} + +func newFakeHA(t *testing.T, entity string) *fakeHA { + t.Helper() + f := &fakeHA{entity: entity, returnUID: true} + mux := http.NewServeMux() + mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) { + if !f.authOK(w, r) { + return + } + writeJSON(w, map[string]any{"message": "API running."}) + }) + mux.HandleFunc("GET /api/states", func(w http.ResponseWriter, r *http.Request) { + if !f.authOK(w, r) { + return + } + writeJSON(w, []map[string]any{ + {"entity_id": f.entity, "state": "off", "attributes": map[string]any{ + "friendly_name": "School", "supported_features": 3, + }}, + {"entity_id": "calendar.holidays", "state": "off", "attributes": map[string]any{ + "friendly_name": "Holidays", + }}, + {"entity_id": "sensor.temperature", "state": "21", "attributes": map[string]any{}}, + }) + }) + mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) { + if !f.authOK(w, r) { + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + f.mu.Lock() + defer f.mu.Unlock() + f.createCalls++ + f.lastCreate = body + if f.failCreate { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]any{"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"}}) + return + } + uid := "" + if f.returnUID { + uid = "uid-" + str(body["summary"]) + } + f.events = append(f.events, map[string]any{ + "uid": uid, + "summary": body["summary"], + "description": body["description"], + "start_date_time": body["start_date_time"], + "end_date_time": body["end_date_time"], + }) + writeJSON(w, map[string]any{ + "changed_entities": map[string]any{}, + "service_response": map[string]any{f.entity: map[string]any{"uid": uid}}, + }) + }) + mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) { + if !f.authOK(w, r) { + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + defer f.mu.Unlock() + f.getCalls++ + f.lastGet = body + evs := f.events + if evs == nil { + evs = []map[string]any{} + } + writeJSON(w, map[string]any{ + "service_response": map[string]any{f.entity: map[string]any{"events": evs}}, + }) + }) + mux.HandleFunc("POST /api/services/calendar/delete_event", func(w http.ResponseWriter, r *http.Request) { + if !f.authOK(w, r) { + return + } + writeJSON(w, map[string]any{"service_response": map[string]any{}}) + }) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + return f +} + +func (f *fakeHA) authOK(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer test-token") { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, map[string]any{"message": "Unauthorized"}) + return false + } + return true +} + +func (f *fakeHA) created() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.createCalls +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func testClient(t *testing.T, baseURL, entity string) *Client { + t.Helper() + c, err := New(config.CalendarConfig{ + BaseURL: baseURL, + Token: "test-token", + EntityID: entity, + Timeout: 5 * time.Second, + }, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + return c +} + +func TestClientRejectsIncompleteConfig(t *testing.T) { + if _, err := New(config.CalendarConfig{}, nil); err == nil { + t.Fatal("New accepted an empty config") + } else if !strings.Contains(err.Error(), "SCHOOL_HA_URL") { + t.Errorf("error does not name the missing variable: %v", err) + } +} + +func TestHealthyAndCalendars(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + if err := c.Healthy(context.Background()); err != nil { + t.Fatalf("Healthy: %v", err) + } + entities, err := c.Calendars(context.Background()) + if err != nil { + t.Fatalf("Calendars: %v", err) + } + if len(entities) != 2 { + t.Fatalf("want 2 calendar entities, got %d (%v)", len(entities), entities) + } + if entities[0].EntityID != "calendar.holidays" || entities[1].EntityID != "calendar.school" { + t.Errorf("entities not sorted: %v", entities) + } + if !entities[1].CanWrite() { + t.Error("calendar.school should advertise CREATE_EVENT") + } + if entities[0].CanWrite() { + t.Error("calendar.holidays should read-only") + } +} + +func TestHealthRejectsBadToken(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + c, err := New(config.CalendarConfig{ + BaseURL: ha.URL, Token: "wrong", EntityID: "calendar.school", Timeout: 5 * time.Second, + }, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := c.Healthy(context.Background()); err == nil { + t.Fatal("Healthy accepted a bad token") + } +} + +func TestCreateEventReturnsUID(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC) + uid, err := c.CreateEvent(context.Background(), Event{ + Summary: "Harry — End of Year Senior Music Concert", + Start: &start, + End: ptrTime(start.Add(time.Hour)), + Description: "HALL\n\nsource: " + Marker(29), + }) + if err != nil { + t.Fatalf("CreateEvent: %v", err) + } + if uid == "" { + t.Fatal("CreateEvent returned no uid") + } + created := ha.lastCreate + if got := str(created["entity_id"]); got != "calendar.school" { + t.Errorf("entity_id = %q", got) + } + if got := str(created["start_date_time"]); got != "2026-10-15T18:00:00Z" { + t.Errorf("start_date_time = %q", got) + } + if got := str(created["end_date_time"]); got != "2026-10-15T19:00:00Z" { + t.Errorf("end_date_time = %q", got) + } + if !strings.Contains(str(created["description"]), Marker(29)) { + t.Errorf("description lost the marker: %q", created["description"]) + } +} + +func TestCreateEventWithoutUIDIsNotAnError(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + ha.returnUID = false + c := testClient(t, ha.URL, "calendar.school") + + start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC) + uid, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)}) + if err != nil { + t.Fatalf("CreateEvent: %v", err) + } + if uid != "" { + t.Errorf("want no uid, got %q", uid) + } +} + +func TestCreateEventErrorCarriesHomeAssistantCode(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + ha.failCreate = true + c := testClient(t, ha.URL, "calendar.school") + + start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC) + _, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)}) + if err == nil { + t.Fatal("CreateEvent ignored a 500") + } + haErr, ok := err.(*Error) + if !ok { + t.Fatalf("want *Error, got %T", err) + } + if haErr.Status != http.StatusInternalServerError || haErr.ID != "unknown_error" { + t.Errorf("error = %+v", haErr) + } + if !strings.Contains(err.Error(), "calendar is busy") { + t.Errorf("error loses the message: %v", err) + } +} + +func TestEventsReadBack(t *testing.T) { + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC) + if _, err := c.CreateEvent(context.Background(), Event{ + Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)), + Description: "source: " + Marker(29), + }); err != nil { + t.Fatalf("CreateEvent: %v", err) + } + events, err := c.Events(context.Background(), start.Add(-time.Hour), start.Add(24*time.Hour)) + if err != nil { + t.Fatalf("Events: %v", err) + } + if len(events) != 1 { + t.Fatalf("want 1 event, got %d", len(events)) + } + if events[0].Summary != "Concert" || !strings.Contains(events[0].Description, Marker(29)) { + t.Errorf("event = %+v", events[0]) + } + if events[0].Start == nil || !events[0].Start.Equal(start) { + t.Errorf("start = %v, want %v", events[0].Start, start) + } + body := ha.lastGet + if got := str(body["start_date_time"]); got == "" { + t.Error("get_events did not send a start window") + } +} + +func TestFindMarker(t *testing.T) { + cases := map[string]string{ + "source: [compass-nhs:29]": "[compass-nhs:29]", + "line\nsource: [compass-nhs:7]\nmore": "[compass-nhs:7]", + "no marker here": "", + "[compass-nhs:abc]": "", + "[compass-nhs:]": "", + } + for in, want := range cases { + if got := findMarker(in); got != want { + t.Errorf("findMarker(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/calendar/sync.go b/internal/calendar/sync.go new file mode 100644 index 0000000..ca4fbf6 --- /dev/null +++ b/internal/calendar/sync.go @@ -0,0 +1,275 @@ +package calendar + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +// DefaultDuration is how long a school event is blocked out when Compass gives +// only a start time. The week grid stores a start, not an end. +const DefaultDuration = time.Hour + +// Marker builds the idempotency marker stored in an event's description. It +// carries the SQLite item id, so a re-run can recognise its own event even when +// Home Assistant reports no event id. +func Marker(itemID int64) string { + return fmt.Sprintf("[compass-nhs:%d]", itemID) +} + +// ItemMarker is the full description tag written to the calendar. +func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) } + +// Options narrows one sync run. +type Options struct { + // ChildID limits the run to one child (nil means all children). + ChildID *int64 + // ItemID pushes exactly one item (used by the item screen). + ItemID *int64 + // Limit caps how many events are written in one run (0 means no cap). + Limit int + // DryRun selects the candidates and reports them without writing. + DryRun bool + // Now is the clock, so tests are deterministic. + Now time.Time + // Duration overrides DefaultDuration. + Duration time.Duration +} + +// Created is one event that the run wrote (or would write on a dry run). +type Created struct { + ItemID int64 `json:"item_id"` + Child string `json:"child"` + Title string `json:"title"` + Start string `json:"start"` + UID string `json:"uid,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Summary string `json:"summary"` +} + +// Result is the outcome of one sync run. Failed is non-zero when a write +// failed; the caller then reports loudly (FR-12). +type Result struct { + Entity string `json:"entity"` + Candidates int `json:"candidates"` + Created int `json:"created"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Events []Created `json:"events,omitempty"` + Errors []string `json:"errors,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +// Sync pushes every important school event that is not in the calendar yet. +// +// It is idempotent in two ways: an item that already carries a Home Assistant +// event id is not a candidate, and an event whose description carries this +// item's marker is adopted instead of written again. Every attempt is recorded +// in the append-only calendar_pushes table, so a failure is visible and loud +// rather than swallowed (FR-12). +func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) { + if st == nil { + return Result{}, errors.New("calendar: store is required") + } + if c == nil { + return Result{}, errors.New("calendar: client is required") + } + if log == nil { + log = slog.Default() + } + now := opts.Now + if now.IsZero() { + now = time.Now() + } + duration := opts.Duration + if duration <= 0 { + duration = DefaultDuration + } + + res := Result{Entity: c.Entity(), DryRun: opts.DryRun} + + candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, now, opts.Limit) + if err != nil { + return res, err + } + res.Candidates = len(candidates) + if len(candidates) == 0 { + return res, nil + } + + // One window covers every candidate, so the marker scan is a single call. + windowEnd := now.Add(24 * time.Hour) + for _, it := range candidates { + if it.DueAt != nil && it.DueAt.After(windowEnd) { + windowEnd = it.DueAt.Add(24 * time.Hour) + } + } + existing, err := c.Events(ctx, now.Add(-24*time.Hour), windowEnd) + if err != nil { + // A failed read is not a licence to write blind: it would risk + // duplicates, so record one loud failure and stop. + msg := fmt.Sprintf("calendar: reading existing events failed: %v", err) + res.Failed = len(candidates) + res.Errors = append(res.Errors, msg) + for _, it := range candidates { + if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, msg); rerr != nil { + log.Error("record calendar push", "item", it.ID, "err", rerr) + } + } + log.Error("calendar sync aborted", "entity", c.Entity(), "err", err) + return res, nil + } + known := indexByMarker(existing) + + for _, it := range candidates { + if it.DueAt == nil { + continue + } + marker := Marker(it.ID) + + if ev, ok := known[marker]; ok { + // Adopt an event this run (or an earlier one) already wrote. + res.Skipped++ + uid := ev.UID + if uid == "" { + // No id from Home Assistant: keep the item out of the next + // candidate set by adopting the marker as its reference. + uid = marker + } + if !opts.DryRun { + if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil { + log.Error("mark calendar item", "item", it.ID, "err", err) + } + if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil { + log.Error("record calendar push", "item", it.ID, "err", err) + } + } + res.Events = append(res.Events, Created{ + ItemID: it.ID, Child: it.Child, Title: it.Title, + Start: it.DueAt.In(time.Local).Format(time.RFC3339), UID: uid, + Summary: "already in the calendar", + }) + continue + } + + created := Created{ + ItemID: it.ID, Child: it.Child, Title: it.Title, + Start: it.DueAt.In(time.Local).Format(time.RFC3339), + DryRun: opts.DryRun, + } + if opts.DryRun { + res.Created++ + res.Events = append(res.Events, created) + continue + } + + uid, err := c.CreateEvent(ctx, Event{ + Summary: eventSummary(it), + Description: eventDescription(it), + Start: it.DueAt, + End: ptrTime(it.DueAt.Add(duration)), + }) + if err != nil { + res.Failed++ + msg := fmt.Sprintf("item %d (%s %q): %v", it.ID, it.Child, it.Title, err) + res.Errors = append(res.Errors, msg) + log.Error("calendar write failed", "item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity(), "err", err) + if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, err.Error()); rerr != nil { + log.Error("record calendar push", "item", it.ID, "err", rerr) + } + continue + } + if uid == "" { + // Home Assistant did not report an event id. The marker in the + // description still makes the next run idempotent, but say so. + log.Warn("home assistant returned no event id; relying on the description marker", + "item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity()) + } + if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil { + log.Error("mark calendar item", "item", it.ID, "err", err) + } + if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil { + log.Error("record calendar push", "item", it.ID, "err", err) + } + res.Created++ + created.UID = uid + res.Events = append(res.Events, created) + } + + sort.Slice(res.Events, func(i, j int) bool { return res.Events[i].Start < res.Events[j].Start }) + return res, nil +} + +// eventSummary is the calendar entry title. The child's name is prefixed, so a +// shared family calendar says who the event belongs to. +func eventSummary(it store.CalendarItem) string { + title := strings.TrimSpace(it.Title) + if it.Child != "" { + return it.Child + " — " + title + } + return title +} + +// eventDescription carries the school detail, the Compass link and the +// idempotency marker. The marker is what makes a re-run safe. +func eventDescription(it store.CalendarItem) string { + var b strings.Builder + body := strings.TrimSpace(it.Body) + if body != "" && body != strings.TrimSpace(it.Title) { + b.WriteString(body) + b.WriteString("\n") + } + if it.URL != "" { + b.WriteString(it.URL) + b.WriteString("\n") + } + if it.Subject != "" { + b.WriteString("Subject: " + it.Subject + "\n") + } + b.WriteString("\n" + ItemMarker(it.ID)) + return b.String() +} + +// indexByMarker maps a marker to the event that carries it. +func indexByMarker(events []Event) map[string]Event { + out := make(map[string]Event, len(events)) + for _, ev := range events { + if m := findMarker(ev.Description); m != "" { + out[m] = ev + } + } + return out +} + +// ptrTime returns a pointer to t, so an event end can hold a computed time. +func ptrTime(t time.Time) *time.Time { return &t } + +// findMarker returns the first compass-nhs marker in a text, or "". +func findMarker(text string) string { + const open, closeTok = "[compass-nhs:", "]" + i := strings.Index(text, open) + if i < 0 { + return "" + } + start := i + len(open) + j := strings.Index(text[start:], closeTok) + if j < 0 { + return "" + } + value := text[start : start+j] + if value == "" { + return "" + } + for _, r := range value { + if r < '0' || r > '9' { + return "" + } + } + return open + value + closeTok +} diff --git a/internal/calendar/sync_test.go b/internal/calendar/sync_test.go new file mode 100644 index 0000000..e76a164 --- /dev/null +++ b/internal/calendar/sync_test.go @@ -0,0 +1,238 @@ +package calendar + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +func testStore(t *testing.T) (*store.Store, int64) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "school.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + if err := st.SeedChildren(context.Background(), "Harry"); err != nil { + t.Fatalf("seed: %v", err) + } + child, err := st.ChildByName(context.Background(), "Harry") + if err != nil || child == nil { + t.Fatalf("child: %v %v", child, err) + } + return st, child.ID +} + +// seedEvent stores one calendar event for a child. +func seedEvent(t *testing.T, st *store.Store, childID int64, title string, due time.Time, importance string, ref string) store.Item { + t.Helper() + item := store.Item{ + Source: "compass", + Type: "event", + Title: title, + Body: title + "\nTeacher(s): KSH", + DueAt: &due, + URL: "https://example.invalid/Organise/Calendar/", + SourcePage: "calendar", + Importance: importance, + ExternalRef: ref, + } + if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil { + t.Fatalf("upsert: %v", err) + } + items, err := st.ItemsForChild(context.Background(), childID) + if err != nil { + t.Fatalf("items: %v", err) + } + for _, it := range items { + if it.ExternalRef == ref { + return it + } + } + t.Fatalf("seeded item %q not found", ref) + return store.Item{} +} + +func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func TestSyncIsIdempotent(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute) + seedEvent(t, st, childID, "End of Year Senior Music Concert - HALL - KSH", due, "high", "concert") + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger()) + if err != nil { + t.Fatalf("first Sync: %v", err) + } + if first.Candidates != 1 || first.Created != 1 || first.Failed != 0 { + t.Fatalf("first result = %+v", first) + } + if ha.created() != 1 { + t.Fatalf("first Sync made %d create calls", ha.created()) + } + + // The item now carries the Home Assistant event id, so it is no longer a + // candidate: a re-run writes nothing. + second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger()) + if err != nil { + t.Fatalf("second Sync: %v", err) + } + if second.Candidates != 0 || second.Created != 0 { + t.Fatalf("second result = %+v", second) + } + if ha.created() != 1 { + t.Fatalf("re-run duplicated the event (%d create calls)", ha.created()) + } +} + +func TestSyncAdoptsAnEventByItsMarker(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute) + item := seedEvent(t, st, childID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly") + ha := newFakeHA(t, "calendar.school") + // The event exists in Home Assistant, but the item has no id (a crashed + // run, or a Home Assistant that reports no uid). The marker adopts it. + ha.events = []map[string]any{{ + "uid": "uid-existing", + "summary": "Harry — Year 11 Assembly - HALL - CMA", + "description": "source: " + Marker(item.ID), + "start_date_time": due.Format(time.RFC3339), + }} + c := testClient(t, ha.URL, "calendar.school") + + res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger()) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if res.Created != 0 || res.Skipped != 1 { + t.Fatalf("result = %+v", res) + } + if ha.created() != 0 { + t.Fatalf("Sync wrote a duplicate (%d create calls)", ha.created()) + } + ci, err := st.CalendarItemByID(ctx, item.ID) + if err != nil { + t.Fatalf("item: %v", err) + } + if ci.HAEventUID != "uid-existing" { + t.Errorf("adopted uid = %q", ci.HAEventUID) + } +} + +func TestSyncRecordsALoudFailure(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute) + item := seedEvent(t, st, childID, "Tour to China photo - THEATRE", due, "high", "photo") + ha := newFakeHA(t, "calendar.school") + ha.failCreate = true + c := testClient(t, ha.URL, "calendar.school") + + res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger()) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if res.Failed != 1 || len(res.Errors) != 1 { + t.Fatalf("result = %+v", res) + } + summary, err := st.CalendarPushSummary(ctx) + if err != nil { + t.Fatalf("summary: %v", err) + } + if summary.Failed != 1 || summary.Last == nil || summary.Last.OK { + t.Fatalf("push summary = %+v", summary) + } + if !strings.Contains(summary.Last.Error, "calendar is busy") { + t.Errorf("recorded error loses the reason: %q", summary.Last.Error) + } + // A failed write leaves the item as a candidate, so a later run retries it. + ci, err := st.CalendarItemByID(ctx, item.ID) + if err != nil { + t.Fatalf("item: %v", err) + } + if ci.HAEventUID != "" { + t.Errorf("failed item should not carry an event id, got %q", ci.HAEventUID) + } +} + +func TestSyncDryRunWritesNothing(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute) + seedEvent(t, st, childID, "Senior Concert Band rehearsal - ENS", due, "high", "band") + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + res, err := Sync(ctx, st, c, Options{Now: time.Now(), DryRun: true}, silentLogger()) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if res.Created != 1 || !res.DryRun { + t.Fatalf("result = %+v", res) + } + if ha.created() != 0 { + t.Fatalf("dry run wrote %d events", ha.created()) + } +} + +func TestSyncIgnoresRoutineLessonsAndPastEvents(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + now := time.Now() + seedEvent(t, st, childID, "5 - 11HLEGB5 - D8 - LHT", now.Add(3*time.Hour), "normal", "lesson") + seedEvent(t, st, childID, "Year 11 Assembly - HALL", now.Add(-2*time.Hour), "high", "past") + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0) + if err != nil { + t.Fatalf("candidates: %v", err) + } + // The store selects on importance; the lesson and the past event are + // excluded because the parser never marks a lesson high and the past event + // is behind `now`. + if len(candidates) != 0 { + t.Fatalf("candidates = %+v", candidates) + } + res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger()) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if res.Created != 0 || ha.created() != 0 { + t.Fatalf("result = %+v", res) + } +} + +func TestSyncReportsAReadFailureInsteadOfWritingBlind(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute) + seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly") + ha := newFakeHA(t, "calendar.school") + ha.Close() // the calendar is unreachable + c := testClient(t, ha.URL, "calendar.school") + + res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger()) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if res.Failed != 1 || res.Created != 0 { + t.Fatalf("result = %+v", res) + } + if !strings.Contains(res.Errors[0], "reading existing events failed") { + t.Errorf("error = %q", res.Errors[0]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b1016cc..85cbffa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,52 @@ type Config struct { // 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. @@ -90,6 +136,7 @@ func Load() (*Config, error) { return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) } cfg.Location = loc + cfg.Calendar = loadCalendar() return cfg, nil } @@ -100,6 +147,7 @@ func Load() (*Config, error) { type DBConfig struct { DBPath string Location *time.Location + Calendar CalendarConfig } // LoadDB reads the database path and the family timezone only. @@ -111,9 +159,29 @@ func LoadDB() (*DBConfig, error) { 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 diff --git a/internal/parse/importance.go b/internal/parse/importance.go index 256329b..af67afb 100644 --- a/internal/parse/importance.go +++ b/internal/parse/importance.go @@ -46,7 +46,7 @@ func ClassifyDeterministic(items []Item, now time.Time) { func deterministicImportance(it Item, week time.Time) string { // Only work carries a deadline that makes it important. A calendar entry's // due_at is its start time, and a lesson that starts this week is not - // overdue work, so events and messages stay normal under the offline rule. + // overdue work, so meetings stay normal under the offline rule. if it.Type == TypeTask { if it.DueAt != nil && it.DueAt.Before(week) { return importanceHigh @@ -55,9 +55,27 @@ func deterministicImportance(it Item, week time.Time) string { return importanceHigh } } + // A school event is important; a routine class period is not (Phase 6 + // writes only the important ones to the calendar, AGENTS.md §4 FR-8). + if it.Type == TypeEvent && eventIsSchoolEvent(it.Title) { + return importanceHigh + } return importanceNormal } +// eventIsSchoolEvent reports whether a calendar entry is a school event rather +// than a routine class period. Compass writes a lesson as +// "5 - 11HLEGB5 - D8 - LHT" and a school event as +// "End of Year Senior Music Concert - HALL (...) - KSH, CFR". The first part of +// a lesson is a period label ("5", "C" or "RLE GROUP D"). +func eventIsSchoolEvent(title string) bool { + parts := splitDash(title) + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + return false + } + return !periodLabel.MatchString(parts[0]) +} + // ClassifyModel asks a model to classify importance for every item (FR-4). It // fails, loudly, on a model error or an unparseable answer; it never silently // leaves a wrong label. Items are updated in place. now lets the model judge diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go index 1b7cdab..55817dc 100644 --- a/internal/parse/parse_test.go +++ b/internal/parse/parse_test.go @@ -304,6 +304,26 @@ func TestClassifyDeterministic(t *testing.T) { func ptr(t time.Time) *time.Time { return &t } +// TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school +// event is important, a routine class period is not. +func TestClassifyDeterministicEvents(t *testing.T) { + now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + at := now.Add(4 * time.Hour) + items := []Item{ + {Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at}, + {Type: TypeEvent, Title: "Year 11 Assembly - HALL - CMA", DueAt: &at}, + {Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at}, + {Type: TypeEvent, Title: "C - 11TUTB - M6 - ANE", DueAt: &at}, + {Type: TypeEvent, Title: "RLE GROUP D - IM1 - RLE", DueAt: &at}, + } + ClassifyDeterministic(items, now) + for i, want := range []string{importanceHigh, importanceHigh, importanceNormal, importanceNormal, importanceNormal} { + if items[i].Importance != want { + t.Errorf("items[%d] %q = %q, want %q", i, items[i].Title, items[i].Importance, want) + } + } +} + // tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with // the per-child tasks. Task 1 is overdue and carries its due date at the task // level; task 2 is on time and carries it on the student; task 3 belongs to the diff --git a/internal/store/calendar.go b/internal/store/calendar.go new file mode 100644 index 0000000..eb14679 --- /dev/null +++ b/internal/store/calendar.go @@ -0,0 +1,221 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// This file holds the Phase 6 calendar helpers (AGENTS.md §4 FR-8, §11 Phase 6): +// the events that are worth writing to Sam's Home Assistant calendar, the mark +// that records the Home Assistant event id on the item, and the append-only +// push log that makes a failed write loud (FR-12). + +// calendarSelect extends the shared item column list with the Home Assistant +// columns, so a candidate carries its own push state. +const calendarSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type, + COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title, + COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''), + COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, ''), + COALESCE(i.ha_event_uid, ''), i.ha_pushed_at + FROM items i LEFT JOIN children ch ON ch.id = i.child_id` + +// CalendarItem is one item plus its Home Assistant push state. +type CalendarItem struct { + Item + // HAEventUID is the Home Assistant event id once the item is in the + // calendar ("" until then). + HAEventUID string + // HAPushedAt is when the item was pushed, or nil. + HAPushedAt *time.Time +} + +// CalendarCandidates returns the important school events that belong in the +// calendar: Compass events marked "high" by the Phase 3 classifier, starting at +// or after from, newest-first excluded. childID and itemID narrow the set, and +// limit caps it (0 means no cap). +// +// An item that already carries a Home Assistant event id is not a candidate: +// the write is idempotent, so a re-run must not duplicate it. +func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64, from time.Time, limit int) ([]CalendarItem, error) { + q := calendarSelect + ` + WHERE i.type = 'event' + AND i.source = 'compass' + AND i.importance = 'high' + AND COALESCE(i.ha_event_uid, '') = '' + AND i.due_at IS NOT NULL + AND i.due_at >= ?` + args := []any{from.UTC().Format(time.RFC3339)} + if childID != nil { + q += ` AND i.child_id = ?` + args = append(args, *childID) + } + if itemID != nil { + q += ` AND i.id = ?` + args = append(args, *itemID) + } + q += ` ORDER BY i.due_at ASC, i.id ASC` + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("calendar candidates: %w", err) + } + defer rows.Close() + + var out []CalendarItem + for rows.Next() { + var it CalendarItem + var due, pushed sql.NullString + if err := rows.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type, + &it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL, + &it.SourcePage, &it.Importance, &it.ExternalRef, + &it.HAEventUID, &pushed); err != nil { + return nil, fmt.Errorf("scan calendar item: %w", err) + } + if due.Valid { + t := parseTime(due.String) + it.DueAt = &t + } + if pushed.Valid { + t := parseTime(pushed.String) + it.HAPushedAt = &t + } + out = append(out, it) + } + return out, rows.Err() +} + +// CalendarItemByID returns one item with its Home Assistant push state, or nil +// when it does not exist. The item screen uses it to show the calendar status. +func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem, error) { + row := s.db.QueryRowContext(ctx, calendarSelect+` WHERE i.id = ?`, id) + + var it CalendarItem + var due, pushed sql.NullString + err := row.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type, + &it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL, + &it.SourcePage, &it.Importance, &it.ExternalRef, + &it.HAEventUID, &pushed) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("item %d: %w", id, err) + } + if due.Valid { + t := parseTime(due.String) + it.DueAt = &t + } + if pushed.Valid { + t := parseTime(pushed.String) + it.HAPushedAt = &t + } + return &it, nil +} + +// MarkItemCalendarSynced records the Home Assistant event id and the push time +// on one item. After this the item is no longer a calendar candidate. +func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE items SET ha_event_uid = ?, ha_pushed_at = ?, updated_at = datetime('now') WHERE id = ?`, + uid, at.UTC().Format(time.RFC3339), itemID); err != nil { + return fmt.Errorf("mark item %d calendar-synced: %w", itemID, err) + } + return nil +} + +// CalendarPush is one row of the append-only calendar push log. +type CalendarPush struct { + ID int64 `json:"id"` + ItemID int64 `json:"item_id"` + EntityID string `json:"entity_id"` + UID string `json:"uid,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CalendarPushStatus summarises the push log for the dashboard. A failed push is +// shown as an explicit failure and is never swallowed (FR-12). +type CalendarPushStatus struct { + // EverRun is false until the first push is recorded. + EverRun bool `json:"ever_run"` + // Total is the number of push rows. + Total int `json:"total"` + // Failed is the number of rows with ok = 0. + Failed int `json:"failed"` + // Last is the most recent push, when one exists. + Last *CalendarPush `json:"last,omitempty"` +} + +// RecordCalendarPush appends one push attempt. ok is false with an error message +// for a failed write, which the dashboard then shows. +func (s *Store) RecordCalendarPush(ctx context.Context, itemID int64, entityID, uid string, ok bool, errMsg string) (int64, error) { + var errVal any + if errMsg != "" { + errVal = errMsg + } + res, err := s.db.ExecContext(ctx, + `INSERT INTO calendar_pushes (item_id, entity_id, uid, ok, error) VALUES (?, ?, ?, ?, ?)`, + itemID, entityID, uid, ok, errVal) + if err != nil { + return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err) + } + return id, nil +} + +// LastCalendarPush returns the most recent push row, or nil when none exists. +func (s *Store) LastCalendarPush(ctx context.Context) (*CalendarPush, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, item_id, entity_id, COALESCE(uid, ''), ok, COALESCE(error, ''), created_at + FROM calendar_pushes ORDER BY id DESC LIMIT 1`) + p, err := scanCalendarPush(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +// CalendarPushSummary summarises the push log (FR-12). +func (s *Store) CalendarPushSummary(ctx context.Context) (CalendarPushStatus, error) { + var out CalendarPushStatus + row := s.db.QueryRowContext(ctx, + `SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM calendar_pushes`) + if err := row.Scan(&out.Total, &out.Failed); err != nil { + return out, fmt.Errorf("calendar push summary: %w", err) + } + out.EverRun = out.Total > 0 + last, err := s.LastCalendarPush(ctx) + if err != nil { + return out, err + } + out.Last = last + return out, nil +} + +func scanCalendarPush(r rowScanner) (CalendarPush, error) { + var p CalendarPush + var created string + var uid, errMsg sql.NullString + var ok int + if err := r.Scan(&p.ID, &p.ItemID, &p.EntityID, &uid, &ok, &errMsg, &created); err != nil { + return p, err + } + p.UID = uid.String + p.OK = ok != 0 + p.Error = errMsg.String + p.CreatedAt = parseTime(created) + return p, nil +} diff --git a/internal/store/calendar_test.go b/internal/store/calendar_test.go new file mode 100644 index 0000000..73e3fdf --- /dev/null +++ b/internal/store/calendar_test.go @@ -0,0 +1,115 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// TestCalendarCandidatesSelectsImportantFutureEventsOnly proves the Phase 6 +// selection rule: only important, future Compass events that are not in the +// calendar yet are candidates. +func TestCalendarCandidatesSelectsImportantFutureEventsOnly(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + harry, _ := st.ChildByName(ctx, "Harry") + finn, _ := st.ChildByName(ctx, "Finn") + + now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + future := now.Add(4 * time.Hour) + past := now.Add(-4 * time.Hour) + + items := []Item{ + {Source: "compass", Type: "event", Title: "Concert", DueAt: &future, Importance: "high", ExternalRef: "e1"}, + {Source: "compass", Type: "event", Title: "Lesson", DueAt: &future, Importance: "normal", ExternalRef: "e2"}, + {Source: "compass", Type: "event", Title: "Yesterday", DueAt: &past, Importance: "high", ExternalRef: "e3"}, + {Source: "compass", Type: "task", Title: "Essay", DueAt: &future, Importance: "high", ExternalRef: "e4"}, + {Source: "teams", Type: "event", Title: "Teams thing", DueAt: &future, Importance: "high", ExternalRef: "e5"}, + } + if _, _, err := st.UpsertItems(ctx, harry.ID, items); err != nil { + t.Fatalf("upsert: %v", err) + } + if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{ + {Source: "compass", Type: "event", Title: "Finn event", DueAt: &future, Importance: "high", ExternalRef: "f1"}, + }); err != nil { + t.Fatalf("upsert finn: %v", err) + } + + all, err := st.CalendarCandidates(ctx, nil, nil, now, 0) + if err != nil { + t.Fatalf("candidates: %v", err) + } + if len(all) != 2 { + t.Fatalf("want 2 candidates (one per child), got %d: %+v", len(all), all) + } + if all[0].Title != "Concert" || all[1].Title != "Finn event" { + t.Errorf("unexpected candidate order: %+v", all) + } + + onlyHarry, err := st.CalendarCandidates(ctx, &harry.ID, nil, now, 0) + if err != nil { + t.Fatalf("candidates: %v", err) + } + if len(onlyHarry) != 1 || onlyHarry[0].Child != "Harry" { + t.Fatalf("per-child filter failed: %+v", onlyHarry) + } + + // Mark the concert as synced; it must leave the candidate set. + if err := st.MarkItemCalendarSynced(ctx, all[0].ID, "uid-1", now); err != nil { + t.Fatalf("mark: %v", err) + } + after, err := st.CalendarCandidates(ctx, nil, nil, now, 0) + if err != nil { + t.Fatalf("candidates: %v", err) + } + if len(after) != 1 || after[0].Title != "Finn event" { + t.Fatalf("synced item is still a candidate: %+v", after) + } + ci, err := st.CalendarItemByID(ctx, all[0].ID) + if err != nil || ci == nil { + t.Fatalf("item: %v %v", ci, err) + } + if ci.HAEventUID != "uid-1" || ci.HAPushedAt == nil { + t.Errorf("mark did not stick: %+v", ci) + } +} + +// TestCalendarPushSummaryIsAppendOnly proves a failed write stays on record. +func TestCalendarPushSummaryIsAppendOnly(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + harry, _ := st.ChildByName(ctx, "Harry") + due := time.Now().Add(time.Hour).UTC() + if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{ + {Source: "compass", Type: "event", Title: "Concert", DueAt: &due, Importance: "high", ExternalRef: "e1"}, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + items, _ := st.ItemsForChild(ctx, harry.ID) + + empty, err := st.CalendarPushSummary(ctx) + if err != nil { + t.Fatalf("summary: %v", err) + } + if empty.EverRun || empty.Total != 0 || empty.Last != nil { + t.Fatalf("fresh summary = %+v", empty) + } + + if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "", false, "boom"); err != nil { + t.Fatalf("record: %v", err) + } + if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "uid-1", true, ""); err != nil { + t.Fatalf("record: %v", err) + } + + summary, err := st.CalendarPushSummary(ctx) + if err != nil { + t.Fatalf("summary: %v", err) + } + if summary.Total != 2 || summary.Failed != 1 || !summary.EverRun { + t.Fatalf("summary = %+v", summary) + } + if summary.Last == nil || !summary.Last.OK || summary.Last.UID != "uid-1" { + t.Fatalf("last = %+v", summary.Last) + } +} diff --git a/internal/store/migrations/003_calendar.sql b/internal/store/migrations/003_calendar.sql new file mode 100644 index 0000000..9774c6e --- /dev/null +++ b/internal/store/migrations/003_calendar.sql @@ -0,0 +1,24 @@ +-- Migration 003 — Phase 6 calendar write (AGENTS.md §4 FR-8, §11 Phase 6). +-- +-- Important school events are written to Sam's Home Assistant calendar. The +-- write must be idempotent: the Home Assistant event id is stored on the item, +-- so a re-run updates nothing and never duplicates an event. The push log is +-- append-only, so a failed calendar write is visible and loud instead of being +-- swallowed (FR-12). + +ALTER TABLE items ADD COLUMN ha_event_uid TEXT NOT NULL DEFAULT ''; +ALTER TABLE items ADD COLUMN ha_pushed_at TEXT; + +-- One row per calendar write attempt. ok = 0 with an error is a loud failure. +CREATE TABLE IF NOT EXISTS calendar_pushes ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id), + entity_id TEXT NOT NULL, + uid TEXT, + ok INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_pushes_item ON calendar_pushes(item_id); +CREATE INDEX IF NOT EXISTS idx_calendar_pushes_created ON calendar_pushes(created_at); diff --git a/internal/web/server.go b/internal/web/server.go index ec9554a..f08dd73 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -18,6 +18,7 @@ import ( "time" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" @@ -64,6 +65,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard)) mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail)) mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete)) + mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush)) mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild)) mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete)) mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial)) @@ -282,6 +284,63 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView { // "nothing due" instead of "unknown". func (v scanStatusView) OK() bool { return v.Level == "ok" } +// ---------- fail-loudly calendar banner (FR-8, FR-12) ---------- + +// calendarBannerView is the fail-loudly view of the Home Assistant calendar +// target. A missing target or a failed write is shown explicitly, so the +// family never assumes an event was written when it was not. +type calendarBannerView struct { + // Level is "off", "warn", "error" or "ok". + Level string + Message string + Entity string + LastAt string + Written int + Failed int +} + +// OK reports whether the calendar target is healthy. +func (v calendarBannerView) OK() bool { return v.Level == "ok" } + +func (s *Server) calendarBanner(ctx context.Context) calendarBannerView { + cc := s.cfg.Calendar + if !cc.Enabled() { + return calendarBannerView{ + Level: "warn", + Message: "CALENDAR NOT CONFIGURED — important school events are not being written. Set " + strings.Join(cc.Missing(), ", ") + ".", + } + } + v := calendarBannerView{Entity: cc.EntityID} + st, err := s.store.CalendarPushSummary(ctx) + if err != nil { + s.log.Error("calendar push summary", "err", err) + v.Level = "error" + v.Message = "Could not read the calendar log: " + err.Error() + return v + } + v.Written = st.Total - st.Failed + v.Failed = st.Failed + if st.Last != nil { + v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04") + } + if st.Last != nil && !st.Last.OK { + v.Level = "error" + v.Message = "CALENDAR WRITE FAILED — the last write to " + cc.EntityID + " did not complete." + if st.Last.Error != "" { + v.Message += " " + st.Last.Error + } + return v + } + if !st.EverRun { + v.Level = "warn" + v.Message = "No calendar write has run yet for " + cc.EntityID + "." + return v + } + v.Level = "ok" + v.Message = "Last calendar write completed." + return v +} + // ---------- dashboard (FR-6, FR-7) ---------- type itemView struct { @@ -335,6 +394,7 @@ type dashboardData struct { Version string AppEnv string Scan scanStatusView + Calendar calendarBannerView Tabs []childTab Child childPanel HasChild bool @@ -357,6 +417,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, Scan: s.scanStatus(ctx), + Calendar: s.calendarBanner(ctx), ServerUp: time.Since(serverStarted).Round(time.Second).String(), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), } @@ -510,54 +571,163 @@ func typeLabel(t string) string { // ---------- item detail + delete (FR-10, §5 screen 3) ---------- type itemData struct { - Title string - Username string - CSRF string - Version string - AppEnv string - Item itemView - Child store.Child - Scan scanStatusView - ServerUp string + Title string + Username string + CSRF string + Version string + AppEnv string + Item itemView + Child store.Child + Scan scanStatusView + Calendar calendarItemView + Flash string + FlashLevel string + ServerUp string +} + +// calendarItemView is the calendar state of one item on the item screen. +type calendarItemView struct { + Configured bool + Entity string + Eligible bool + InCalendar bool + UID string + PushedAt string +} + +// itemCalendarView describes whether one item can go to the calendar and +// whether it is already there. An important, future Compass event is eligible. +func (s *Server) itemCalendarView(it store.CalendarItem, now time.Time) calendarItemView { + v := calendarItemView{ + Configured: s.cfg.Calendar.Enabled(), + Entity: s.cfg.Calendar.EntityID, + InCalendar: it.HAEventUID != "", + UID: it.HAEventUID, + } + v.Eligible = it.Type == "event" && it.Source == "compass" && it.Importance == "high" && + it.DueAt != nil && it.DueAt.After(now) + if it.HAPushedAt != nil { + v.PushedAt = it.HAPushedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04") + } + return v } func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - id, ok := pathID(r, "id") if !ok { http.NotFound(w, r) return } - it, err := s.store.ItemByID(ctx, id) + s.renderItemDetail(w, r, id, "", "") +} + +// renderItemDetail renders the item screen, optionally with a calendar flash. +func (s *Server) renderItemDetail(w http.ResponseWriter, r *http.Request, id int64, flash, flashLevel string) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + ci, err := s.store.CalendarItemByID(ctx, id) if err != nil { s.log.Error("item", "id", id, "err", err) http.Error(w, "could not read the item", http.StatusInternalServerError) return } - if it == nil { + if ci == nil { http.NotFound(w, r) return } - child, err := s.store.ChildByID(ctx, it.ChildID) + child, err := s.store.ChildByID(ctx, ci.ChildID) if err != nil || child == nil { - child = &store.Child{ID: it.ChildID, Name: it.Child} + child = &store.Child{ID: ci.ChildID, Name: ci.Child} } s.render(w, r, "item.html", itemData{ - Title: it.Title, - Username: s.cfg.AdminUsername, - CSRF: s.auth.EnsureCSRF(w, r), - Version: s.cfg.Version, - AppEnv: s.cfg.AppEnv, - Item: itemViews([]store.Item{*it}, s.cfg.Location)[0], - Child: *child, - Scan: s.scanStatus(ctx), - ServerUp: time.Since(serverStarted).Round(time.Second).String(), + Title: ci.Title, + Username: s.cfg.AdminUsername, + CSRF: s.auth.EnsureCSRF(w, r), + Version: s.cfg.Version, + AppEnv: s.cfg.AppEnv, + Item: itemViews([]store.Item{ci.Item}, s.cfg.Location)[0], + Child: *child, + Scan: s.scanStatus(ctx), + Calendar: s.itemCalendarView(*ci, time.Now()), + Flash: flash, + FlashLevel: flashLevel, + ServerUp: time.Since(serverStarted).Round(time.Second).String(), }) } +// handleItemCalendarPush writes one item to Sam's Home Assistant calendar. The +// school stays read-only: the only write is to the calendar (AGENTS.md §10 +// rule 1). A failure is shown on the page and recorded in calendar_pushes. +func (s *Server) handleItemCalendarPush(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !s.auth.CheckCSRF(r) { + s.log.Warn("calendar push rejected: bad csrf token", "path", r.URL.Path) + http.Error(w, "session expired", http.StatusForbidden) + return + } + id, ok := pathID(r, "id") + if !ok { + http.NotFound(w, r) + return + } + + ci, err := s.store.CalendarItemByID(ctx, id) + if err != nil { + s.log.Error("item", "id", id, "err", err) + http.Error(w, "could not read the item", http.StatusInternalServerError) + return + } + if ci == nil { + http.NotFound(w, r) + return + } + + if missing := s.cfg.Calendar.Missing(); len(missing) > 0 { + s.renderItemDetail(w, r, id, + "Calendar is not configured. Set "+strings.Join(missing, ", ")+".", "error") + return + } + client, err := calendar.New(s.cfg.Calendar, s.log) + if err != nil { + s.log.Error("calendar client", "err", err) + s.renderItemDetail(w, r, id, "Calendar is not usable: "+err.Error(), "error") + return + } + + res, err := calendar.Sync(ctx, s.store, client, + calendar.Options{ItemID: &id, Now: time.Now().In(s.cfg.Location)}, s.log) + if err != nil { + s.log.Error("calendar push", "item", id, "err", err) + s.renderItemDetail(w, r, id, "Calendar write failed: "+err.Error(), "error") + return + } + switch { + case res.Failed > 0: + msg := "Calendar write failed." + if len(res.Errors) > 0 { + msg = "Calendar write failed: " + strings.Join(res.Errors, "; ") + } + s.log.Error("calendar push failed", "item", id, "child", ci.Child, "errors", res.Errors) + s.renderItemDetail(w, r, id, msg, "error") + case res.Created == 0 && res.Skipped > 0: + s.renderItemDetail(w, r, id, "Already in the calendar ("+s.cfg.Calendar.EntityID+").", "ok") + case res.Created == 0: + s.renderItemDetail(w, r, id, + "Nothing to write: only important, future school events go to the calendar.", "error") + default: + s.log.Info("calendar event written", "item", id, "child", ci.Child, "title", ci.Title, "entity", s.cfg.Calendar.EntityID) + s.renderItemDetail(w, r, id, "Added to "+s.cfg.Calendar.EntityID+" on Sam's Home Assistant calendar.", "ok") + } +} + func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 6bf96e1..1ecc89a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -2,6 +2,7 @@ package web import ( "context" + "encoding/json" "io" "log/slog" "net/http" @@ -31,6 +32,11 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`) // newTestServer builds the web handler against a fresh SQLite database. func newTestServer(t *testing.T) (*httptest.Server, *store.Store) { + return newTestServerCalendar(t, config.CalendarConfig{}) +} + +// newTestServerCalendar is newTestServer with a Home Assistant calendar target. +func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) { t.Helper() loc, err := time.LoadLocation("Australia/Sydney") if err != nil { @@ -58,6 +64,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *store.Store) { AppEnv: "test", Version: "test", Location: loc, + Calendar: cal, } am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword) if err != nil { @@ -307,3 +314,187 @@ func snippet(s string) string { } return s } + +// fakeHACalendar is a minimal Home Assistant calendar for the item-screen test. +// It records created events so a read-back can prove the write. +func fakeHACalendar(t *testing.T, failCreate bool) *httptest.Server { + t.Helper() + var events []map[string]any + mux := http.NewServeMux() + mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if failCreate { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": "unknown_error", "message": "calendar is busy"}, + }) + return + } + events = append(events, map[string]any{ + "uid": "uid-1", "summary": body["summary"], "description": body["description"], + "start_date_time": body["start_date_time"], + }) + _ = json.NewEncoder(w).Encode(map[string]any{ + "service_response": map[string]any{"calendar.school": map[string]any{"uid": "uid-1"}}, + }) + }) + mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) { + if events == nil { + events = []map[string]any{} + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "service_response": map[string]any{"calendar.school": map[string]any{"events": events}}, + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestDashboardWarnsWhenTheCalendarIsNotConfigured proves the calendar half of +// FR-12: an unconfigured target is stated, never silently skipped. +func TestDashboardWarnsWhenTheCalendarIsNotConfigured(t *testing.T) { + ts, _ := newTestServer(t) + client := login(t, ts) + body := getBody(t, client, ts.URL+"/") + if !strings.Contains(body, "CALENDAR NOT CONFIGURED") { + t.Errorf("dashboard does not warn about the calendar: %s", snippet(body)) + } + if !strings.Contains(body, "SCHOOL_HA_URL") { + t.Error("warning does not name the missing configuration") + } +} + +// TestItemCalendarPush proves the item screen writes one event and records it. +func TestItemCalendarPush(t *testing.T) { + ha := fakeHACalendar(t, false) + ts, st := newTestServerCalendar(t, config.CalendarConfig{ + BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second, + }) + ctx := context.Background() + harry, _ := st.ChildByName(ctx, "Harry") + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second) + if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{ + Source: "compass", Type: "event", Title: "End of Year Senior Music Concert - HALL", + Body: "End of Year Senior Music Concert - HALL - KSH", DueAt: &due, + Importance: "high", ExternalRef: "e1", + }}); err != nil { + t.Fatalf("upsert: %v", err) + } + items, _ := st.ItemsForChild(ctx, harry.ID) + id := items[0].ID + + client := login(t, ts) + detail := getBody(t, client, ts.URL+"/items/"+itoa(id)) + if !strings.Contains(detail, "Add to calendar") { + t.Fatalf("item screen has no calendar action: %s", snippet(detail)) + } + + resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar", + url.Values{"csrf": {csrfToken(t, detail)}}) + if err != nil { + t.Fatalf("push: %v", err) + } + defer resp.Body.Close() + page, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("push status = %d, want 200", resp.StatusCode) + } + if !strings.Contains(string(page), "Added to calendar.school") { + t.Errorf("push page has no confirmation: %s", snippet(string(page))) + } + if !strings.Contains(string(page), "in the calendar") { + t.Errorf("item screen does not show the calendar state: %s", snippet(string(page))) + } + + summary, err := st.CalendarPushSummary(ctx) + if err != nil { + t.Fatalf("summary: %v", err) + } + if summary.Total != 1 || summary.Failed != 0 { + t.Fatalf("push log = %+v", summary) + } + ci, _ := st.CalendarItemByID(ctx, id) + if ci == nil || ci.HAEventUID != "uid-1" { + t.Fatalf("item does not carry the event id: %+v", ci) + } +} + +// TestItemCalendarPushFailureIsLoud proves a failed write is shown and recorded. +func TestItemCalendarPushFailureIsLoud(t *testing.T) { + ha := fakeHACalendar(t, true) + ts, st := newTestServerCalendar(t, config.CalendarConfig{ + BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second, + }) + ctx := context.Background() + harry, _ := st.ChildByName(ctx, "Harry") + due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second) + if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{ + Source: "compass", Type: "event", Title: "Year 11 Assembly - HALL", + DueAt: &due, Importance: "high", ExternalRef: "e1", + }}); err != nil { + t.Fatalf("upsert: %v", err) + } + items, _ := st.ItemsForChild(ctx, harry.ID) + id := items[0].ID + + client := login(t, ts) + detail := getBody(t, client, ts.URL+"/items/"+itoa(id)) + resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar", + url.Values{"csrf": {csrfToken(t, detail)}}) + if err != nil { + t.Fatalf("push: %v", err) + } + defer resp.Body.Close() + page, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(page), "Calendar write failed") { + t.Errorf("failure is not shown on the page: %s", snippet(string(page))) + } + if !strings.Contains(string(page), "calendar is busy") { + t.Errorf("failure loses the reason: %s", snippet(string(page))) + } + + summary, _ := st.CalendarPushSummary(ctx) + if summary.Failed != 1 { + t.Fatalf("failed write was not recorded: %+v", summary) + } + + // The dashboard repeats the failure, so it is visible without the item page. + body := getBody(t, client, ts.URL+"/") + if !strings.Contains(body, "CALENDAR WRITE FAILED") { + t.Errorf("dashboard does not show the failed write: %s", snippet(body)) + } +} + +// TestItemCalendarPushNeedsCSRF proves the write is not open to a forged post. +func TestItemCalendarPushNeedsCSRF(t *testing.T) { + ha := fakeHACalendar(t, false) + ts, st := newTestServerCalendar(t, config.CalendarConfig{ + BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second, + }) + ctx := context.Background() + harry, _ := st.ChildByName(ctx, "Harry") + due := time.Now().Add(4 * time.Hour).UTC() + if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{ + Source: "compass", Type: "event", Title: "Concert", DueAt: &due, + Importance: "high", ExternalRef: "e1", + }}); err != nil { + t.Fatalf("upsert: %v", err) + } + items, _ := st.ItemsForChild(ctx, harry.ID) + + client := login(t, ts) + resp, err := client.PostForm(ts.URL+"/items/"+itoa(items[0].ID)+"/calendar", url.Values{}) + if err != nil { + t.Fatalf("push: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("tokenless push = %d, want 403", resp.StatusCode) + } + summary, _ := st.CalendarPushSummary(ctx) + if summary.Total != 0 { + t.Errorf("a tokenless push recorded a write: %+v", summary) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 555a65b..80858d4 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -240,6 +240,12 @@ a:hover { text-decoration: underline; } color: var(--sticker-red); background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface)); } +.flash--ok { + border-color: var(--sticker-green); + border-left-width: 3px; + color: #0f7a28; + background: color-mix(in srgb, var(--sticker-green) 8%, var(--surface)); +} /* ---------- Scan status (FR-12: fail loudly) ---------- */ diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 08275a3..95a8daf 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -39,6 +39,8 @@ {{template "scan-status.html" .Scan}} + {{template "calendar-status.html" .Calendar}} + {{if .Tabs}}