Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
393
internal/calendar/ha.go
Normal file
393
internal/calendar/ha.go
Normal file
@@ -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/<domain>/<service> 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
|
||||
}
|
||||
311
internal/calendar/ha_test.go
Normal file
311
internal/calendar/ha_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
275
internal/calendar/sync.go
Normal file
275
internal/calendar/sync.go
Normal file
@@ -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
|
||||
}
|
||||
238
internal/calendar/sync_test.go
Normal file
238
internal/calendar/sync_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
221
internal/store/calendar.go
Normal file
221
internal/store/calendar.go
Normal file
@@ -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
|
||||
}
|
||||
115
internal/store/calendar_test.go
Normal file
115
internal/store/calendar_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
24
internal/store/migrations/003_calendar.sql
Normal file
24
internal/store/migrations/003_calendar.sql
Normal file
@@ -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);
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ---------- */
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
{{template "scan-status.html" .Scan}}
|
||||
</div>
|
||||
|
||||
{{template "calendar-status.html" .Calendar}}
|
||||
|
||||
{{if .Tabs}}
|
||||
<nav class="child-tabs" aria-label="Children">
|
||||
{{range .Tabs}}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p>
|
||||
|
||||
<article class="detail">
|
||||
{{if .Flash}}<p class="flash flash--{{if eq .FlashLevel "ok"}}ok{{else}}error{{end}}" role="alert">{{.Flash}}</p>{{end}}
|
||||
<header class="detail__head">
|
||||
<h1 class="heading-1">{{.Item.Title}}</h1>
|
||||
<p class="detail__meta">
|
||||
@@ -51,12 +52,23 @@
|
||||
<dt>Child</dt><dd>{{.Child.Name}}</dd>
|
||||
<dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd>
|
||||
{{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}}
|
||||
<dt>Calendar</dt>
|
||||
<dd>{{if .Calendar.InCalendar}}in the calendar{{if .Calendar.PushedAt}} · {{.Calendar.PushedAt}}{{end}}{{if .Calendar.UID}} · {{.Calendar.UID}}{{end}}
|
||||
{{else if not .Calendar.Configured}}not configured
|
||||
{{else if .Calendar.Eligible}}not written yet
|
||||
{{else}}not a calendar item{{end}}</dd>
|
||||
{{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}}
|
||||
{{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}}
|
||||
</dl>
|
||||
|
||||
<div class="detail__actions">
|
||||
{{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}}
|
||||
{{if and .Calendar.Configured .Calendar.Eligible (not .Calendar.InCalendar)}}
|
||||
<form method="post" action="/items/{{.Item.ID}}/calendar">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button-primary" type="submit">Add to calendar</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/items/{{.Item.ID}}/delete"
|
||||
hx-post="/items/{{.Item.ID}}/delete" hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
|
||||
19
internal/web/templates/partials/calendar-status.html
Normal file
19
internal/web/templates/partials/calendar-status.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "calendar-status.html"}}
|
||||
{{/* Fail-loudly calendar banner (FR-8, FR-12). A missing target or a failed
|
||||
write is never hidden: the family must not assume an event was written. */}}
|
||||
<section class="scan scan--{{.Level}}" aria-labelledby="calendar-title">
|
||||
<div class="scan__head">
|
||||
<h2 id="calendar-title" class="heading-2">Calendar</h2>
|
||||
<span class="category-pill scan__pill">
|
||||
{{if eq .Level "ok"}}healthy
|
||||
{{else if eq .Level "error"}}failed
|
||||
{{else if eq .Level "warn"}}attention
|
||||
{{else}}off{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="scan__message">{{.Message}}</p>
|
||||
{{if .Entity}}<p class="scan__meta">{{.Entity}}{{if .LastAt}} · last write {{.LastAt}}{{end}}
|
||||
{{if gt .Written 0}}· {{.Written}} event(s) written{{end}}</p>{{end}}
|
||||
{{if gt .Failed 0}}<p class="scan__meta">{{.Failed}} failed calendar write(s) on record.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user