From 4de3d124fbe2fa5a708ddd6f34bea01dada68284 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Sun, 20 Sep 2026 20:28:10 +0200 Subject: [PATCH] fix(calendar): prefer destination provider for meeting generation --- docs/ARCHITECTURE.md | 2 + internal/calendar/calendar.go | 2 +- .../calendar/destination_provider_test.go | 46 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 internal/calendar/destination_provider_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..6e5dd274 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -449,6 +449,8 @@ committed booking) — which is why the reconciler (§11) exists. ## 10. Calendar integration (provider abstraction) +The connected-provider lookup prefers the destination connection. A conflict-only connection must not select the provider used when deciding how to generate a meeting link. + Calnode talks to calendars through a **provider abstraction**, not a single vendor: - **`internal/calendar`** defines the `Provider` interface (Name, InvitesGuests, diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index afaade9c..dfec7d8b 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -136,7 +136,7 @@ func (s *Service) providerForDestination(ctx context.Context, userID string) Pro func (s *Service) Connected(ctx context.Context, userID string) (bool, string, error) { var name string err := s.db.QueryRowContext(ctx, - `SELECT provider FROM calendar_connections WHERE user_id = ? LIMIT 1`, userID).Scan(&name) + `SELECT provider FROM calendar_connections WHERE user_id = ? ORDER BY is_destination DESC, created_at ASC LIMIT 1`, userID).Scan(&name) if err == sql.ErrNoRows { return false, "", nil } diff --git a/internal/calendar/destination_provider_test.go b/internal/calendar/destination_provider_test.go new file mode 100644 index 00000000..dd5e726b --- /dev/null +++ b/internal/calendar/destination_provider_test.go @@ -0,0 +1,46 @@ +package calendar + +import ( + "context" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +func TestConnectedPrefersDestinationOverOlderCalDAV(t *testing.T) { + database, err := db.Open("sqlite://:memory:") + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := db.Migrate(database); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) + VALUES ('host', 'host@example.com', 'Host', 'Europe/Amsterdam', 0, '2026-09-20T00:00:00Z')`); err != nil { + t.Fatal(err) + } + for _, provider := range []string{"caldav", "google"} { + destination := 0 + if provider == "google" { + destination = 1 + } + if _, err := database.Exec(`INSERT INTO calendar_connections + (id, user_id, provider, access_token_enc, calendar_id, check_conflicts, is_destination, created_at) + VALUES (?, 'host', ?, 'encrypted', 'primary', 1, ?, '2026-09-20T00:00:00Z')`, provider, provider, destination); err != nil { + t.Fatal(err) + } + } + svc := NewService(database) + connected, provider, err := svc.Connected(context.Background(), "host") + if err != nil || !connected || provider != "google" { + t.Fatalf("connected=%v provider=%q error=%v; want Google destination", connected, provider, err) + } + if err := svc.SetDestination(context.Background(), "host", "caldav", ""); err != nil { + t.Fatal(err) + } + _, provider, err = svc.Connected(context.Background(), "host") + if err != nil || provider != "caldav" { + t.Fatalf("provider=%q error=%v; want changed destination", provider, err) + } +}