summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 07:18:01 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 07:18:01 +0000
commitaf0d59d78524127f6cc76e43a7e397da01788d97 (patch)
treeb534567450d8b43bf6f687cc4fca645f26b1491e /internal/handlers
parent84252756c687044d73a0ea5cebf8088c7c9ed3e8 (diff)
fix(calendar): split comma-joined GOOGLE_CALENDAR_ID before use
fetchCalendarEvents falls back to config.GoogleCalendarID whenever no source_configs rows exist yet for the gcal source -- which is the current live state (0 rows). GoogleCalendarID is a single env var that itself holds a comma-separated list of calendar IDs, but the whole joined string was being wrapped in a single-element []string{...} and passed straight to SetCalendarIDs. Google's API takes one calendarId per call, so every fetch failed with '404 Not Found' on the literal comma-joined string -- confirmed live in production logs. This broke all calendar events, in both the web dashboard and the widget, silently. Now splits on commas and trims whitespace before use.
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/handlers.go25
-rw-r--r--internal/handlers/timeline_logic_test.go52
2 files changed, 70 insertions, 7 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index d1a7512..0d8b2da 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -314,9 +314,20 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([
}
if len(enabledIDs) == 0 {
- // No source_configs synced yet — fall back to the configured calendar ID
+ // No source_configs synced yet — fall back to the configured calendar
+ // ID(s). GoogleCalendarID is a single env var that itself holds a
+ // comma-separated list (see GOOGLE_CALENDAR_ID in .env) -- it must be
+ // split before use. Passing the raw joined string straight through
+ // as a single calendar ID (the previous behavior) sends Google's API
+ // a calendarId that matches nothing, failing every fetch with a 404 —
+ // this is a real production incident, not a hypothetical: it silently
+ // broke all calendar events (web and widget both) until fixed.
if len(configs) == 0 && h.config.GoogleCalendarID != "" {
- enabledIDs = []string{h.config.GoogleCalendarID}
+ for _, id := range strings.Split(h.config.GoogleCalendarID, ",") {
+ if trimmed := strings.TrimSpace(id); trimmed != "" {
+ enabledIDs = append(enabledIDs, trimmed)
+ }
+ }
} else {
// Configs exist but all disabled — respect that
return nil, nil
@@ -326,10 +337,12 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([
h.googleCalendarClient.SetCalendarIDs(enabledIDs)
fetcher := &CacheFetcher[models.CalendarEvent]{
- Store: h.store,
- CacheKey: store.CacheKeyGoogleCalendar,
- TTLMinutes: h.config.CacheTTLMinutes,
- Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) },
+ Store: h.store,
+ CacheKey: store.CacheKeyGoogleCalendar,
+ TTLMinutes: h.config.CacheTTLMinutes,
+ Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) {
+ return h.googleCalendarClient.GetUpcomingEvents(ctx, 50)
+ },
GetFromCache: h.store.GetCalendarEvents,
SaveToCache: h.store.SaveCalendarEvents,
}
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go
index 3678b62..0b7bc37 100644
--- a/internal/handlers/timeline_logic_test.go
+++ b/internal/handlers/timeline_logic_test.go
@@ -19,6 +19,10 @@ import (
type MockCalendarClient struct {
Events []models.CalendarEvent
Err error
+ // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called
+ // with, for tests that need to assert on how the caller resolved its
+ // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback).
+ SetCalendarIDsCalls [][]string
}
func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) {
@@ -33,7 +37,9 @@ func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.Cale
return nil, m.Err
}
-func (m *MockCalendarClient) SetCalendarIDs(ids []string) {}
+func (m *MockCalendarClient) SetCalendarIDs(ids []string) {
+ m.SetCalendarIDsCalls = append(m.SetCalendarIDsCalls, ids)
+}
func setupTestStore(t *testing.T) *store.Store {
t.Helper()
@@ -335,6 +341,50 @@ func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) {
}
}
+// TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs proves the
+// 2026-07-12 fix: when no source_configs rows exist yet for "gcal" (the
+// normal state before any calendar-discovery sync has run), fetchCalendarEvents
+// falls back to config.GoogleCalendarID -- but that's a single env var that
+// itself holds a comma-separated list of calendar IDs (see GOOGLE_CALENDAR_ID
+// in .env). Previously the whole joined string was passed to SetCalendarIDs
+// as a single one-element slice, which Google's API rejected with a 404
+// ("Not Found") on every single fetch -- a real production incident that
+// broke all calendar events, in both the web dashboard and the widget, until
+// this fix. It must now be split into separate IDs.
+func TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ mock := &MockCalendarClient{Events: []models.CalendarEvent{{ID: "e1"}}}
+ h := &Handler{
+ store: db,
+ googleCalendarClient: mock,
+ config: &config.Config{
+ CacheTTLMinutes: 5,
+ GoogleCalendarID: "cal-a@group.calendar.google.com, cal-b@gmail.com,cal-c@group.calendar.google.com",
+ },
+ renderer: newTestRenderer(),
+ }
+
+ if _, err := h.fetchCalendarEvents(context.Background(), true); err != nil {
+ t.Fatalf("fetchCalendarEvents: %v", err)
+ }
+
+ if len(mock.SetCalendarIDsCalls) == 0 {
+ t.Fatal("expected SetCalendarIDs to be called")
+ }
+ got := mock.SetCalendarIDsCalls[len(mock.SetCalendarIDsCalls)-1]
+ want := []string{"cal-a@group.calendar.google.com", "cal-b@gmail.com", "cal-c@group.calendar.google.com"}
+ if len(got) != len(want) {
+ t.Fatalf("SetCalendarIDs called with %d ids, want %d: got %v", len(got), len(want), got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("id[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
func TestSaveAndGetCalendarEvents(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()