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) + } + } +}