From 0391583c8f9b7a6aeb11c26da7f8a1f5c7810754 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Sun, 20 Sep 2026 20:33:10 +0200 Subject: [PATCH] fix(booking): check local bookings across a shared calendar --- docs/ARCHITECTURE.md | 2 + internal/booking/service.go | 12 ++++- internal/booking/shared_calendar_test.go | 67 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 internal/booking/shared_calendar_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..0d0b638e 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) +Bookings also check pending local bookings owned by other accounts that use the same selected conflict calendar. This check runs inside the booking transaction, before asynchronous calendar creation can finish. Calendar identity currently includes the provider, account email, and calendar ID. + Calnode talks to calendars through a **provider abstraction**, not a single vendor: - **`internal/calendar`** defines the `Provider` interface (Name, InvitesGuests, diff --git a/internal/booking/service.go b/internal/booking/service.go index 665dc1f5..8f4043d0 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -285,9 +285,17 @@ func hostBusy(ctx context.Context, tx *sql.Tx, hostID, start, end, excludeBookin err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM bookings b JOIN booking_hosts bh ON bh.booking_id = b.id - WHERE bh.user_id = ? AND b.status != 'cancelled' AND b.id != ? + WHERE (bh.user_id = ? OR EXISTS ( + SELECT 1 FROM connection_calendars checked + JOIN connection_calendars destination + ON destination.provider = checked.provider + AND destination.account_email = checked.account_email + AND destination.calendar_id = checked.calendar_id + WHERE checked.user_id = ? AND checked.check_conflicts = 1 + AND destination.user_id = bh.user_id AND destination.is_destination = 1 + )) AND b.status != 'cancelled' AND b.id != ? AND b.start_at < ? AND b.end_at > ?`, - hostID, excludeBookingID, end, start).Scan(&n) + hostID, hostID, excludeBookingID, end, start).Scan(&n) return n > 0, err } diff --git a/internal/booking/shared_calendar_test.go b/internal/booking/shared_calendar_test.go new file mode 100644 index 00000000..5fd12c06 --- /dev/null +++ b/internal/booking/shared_calendar_test.go @@ -0,0 +1,67 @@ +package booking_test + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/calnode/calnode/internal/booking" +) + +func TestSharedCalendarAtomicConflict(t *testing.T) { + database := newTestDB(t) + svc := booking.New(database) + hosts := []string{seedHost(t, database), seedHost(t, database)} + events := []string{seedEventType(t, database, hosts[0]), seedEventType(t, database, hosts[1])} + for _, host := range hosts { + _, err := database.Exec(`INSERT INTO connection_calendars (id,user_id,provider,account_email,calendar_id,check_conflicts,is_destination) VALUES (?,?,'google','shared@example.com','primary',1,1)`, host, host) + if err != nil { + t.Fatal(err) + } + } + start := make(chan struct{}) + results := make(chan error, 2) + ids := make(chan string, 2) + var wg sync.WaitGroup + for i := range hosts { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + b, err := svc.Create(context.Background(), booking.CreateParams{EventTypeID: events[i], HostIDs: []string{hosts[i]}, StartAt: slot(9, 0), EndAt: slot(9, 30), Organizer: booking.Attendee{Name: "Test", Email: "test@example.com", IANATimezone: "UTC"}}) + results <- err + if b != nil { + ids <- b.ID + } + }(i) + } + close(start) + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, booking.ErrDoubleBooked) { + conflicts++ + } else { + t.Fatal(err) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + if err := svc.CancelByID(context.Background(), <-ids, "test cleanup"); err != nil { + t.Fatal(err) + } + for i := range hosts { + b, err := svc.Create(context.Background(), booking.CreateParams{EventTypeID: events[i], HostIDs: []string{hosts[i]}, StartAt: slot(9, 0), EndAt: slot(9, 30), Organizer: booking.Attendee{Name: "Test", Email: "test@example.com", IANATimezone: "UTC"}}) + if err != nil { + t.Fatal(err) + } + if err := svc.CancelByID(context.Background(), b.ID, "test cleanup"); err != nil { + t.Fatal(err) + } + } +}