From f6be17fe4fc0a7990ed39c661e91481db433c06d Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Thu, 15 Oct 2026 18:01:30 +1100 Subject: [PATCH] Phase 6: write important school events to the Home Assistant calendar Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip --- Makefile | 11 +- README.md | 39 +- cmd/school/calendar.go | 364 ++++++++++++++++ cmd/school/main.go | 3 + docker-compose.yml | 4 +- docs/phase-6-calendar-notes.md | 130 ++++++ environment.d/school-service.env.example | 17 + internal/calendar/ha.go | 393 ++++++++++++++++++ internal/calendar/ha_test.go | 311 ++++++++++++++ internal/calendar/sync.go | 275 ++++++++++++ internal/calendar/sync_test.go | 238 +++++++++++ internal/config/config.go | 68 +++ internal/parse/importance.go | 20 +- internal/parse/parse_test.go | 20 + internal/store/calendar.go | 221 ++++++++++ internal/store/calendar_test.go | 115 +++++ internal/store/migrations/003_calendar.sql | 24 ++ internal/web/server.go | 220 ++++++++-- internal/web/server_test.go | 191 +++++++++ internal/web/static/app.css | 6 + internal/web/templates/dashboard.html | 2 + internal/web/templates/item.html | 12 + .../templates/partials/calendar-status.html | 19 + scripts/calendar-push.sh | 61 +++ scripts/parse-compass.sh | 4 +- scripts/scan-compass.sh | 4 +- 26 files changed, 2729 insertions(+), 43 deletions(-) create mode 100644 cmd/school/calendar.go create mode 100644 docs/phase-6-calendar-notes.md create mode 100644 internal/calendar/ha.go create mode 100644 internal/calendar/ha_test.go create mode 100644 internal/calendar/sync.go create mode 100644 internal/calendar/sync_test.go create mode 100644 internal/store/calendar.go create mode 100644 internal/store/calendar_test.go create mode 100644 internal/store/migrations/003_calendar.sql create mode 100644 internal/web/templates/partials/calendar-status.html create mode 100755 scripts/calendar-push.sh 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}}