Repository navigation
fix(booking): check local bookings across a shared calendar #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+291
to
+293
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The shared-calendar identity is Technical details# Cross-account calendar identity is unsound when account_email is blank
## Affected sites
- `internal/booking/service.go:291-293` — the match equates `(provider, account_email, calendar_id)` across users without requiring a non-empty `account_email`.
- `internal/gcal/gcal.go:101` — `email := c.fetchAccountEmail(ctx, tok) // best-effort; "" on failure`; `calendar_id` defaults to `"primary"` at line 99.
- `internal/calendar/microsoft/microsoft.go:114-119` — same default; `accountEmailFromIDToken` returns `""` when the claim is missing.
- `internal/db/migrations/00039_calendar_account_email.sql:6` — pre-existing connections backfill `account_email=''`; `00049` seeds `connection_calendars` from them with `COALESCE(account_email, '')`.
## Required outcome
Two rows that share only a blank `account_email` must not be treated as the same calendar. Require `account_email <> ''` on both `checked` and `destination`, or skip blank-identity rows.
## Suggested regression test
Seed two hosts with `(google, '', 'primary')`; the second create must succeed rather than return `ErrDoubleBooked`.
## Evidence
A scratch test returned `booking: time slot is no longer available` for the second host. `go test ./internal/booking/` passes today only because no fixture seeds a blank `account_email`. |
||
| 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 | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This consults only
connection_calendars, but an account that connected a calendar and never opened the picker has no rows there —ConflictCalendarIDsdeliberately falls back to the connection's boundcalendar_idin that case, andDestinationCalendarIDreturnsok=falseso callers keep the account-level calendar. Those accounts are invisible to this check, so the local race the PR closes stays open for them (the default state of a newly connected Google/Microsoft account). Please mirror the no-rows fallback on both thecheckedanddestinationsides.Technical details