diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b870dfc6..9f49b07f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -399,6 +399,8 @@ them - the most common "why can't I see those times". ## 9. Booking lifecycle +New public and assistant bookings recheck selected external calendars before committing. This uses the same buffer orientation and own-event subtraction as the slots page and checks each required host; round-robin routing filters busy candidates and busy optional guests are omitted. A failed provider check rejects the request with HTTP 503 and a retry message; a busy slot returns HTTP 409. Assistant and MCP bookings keep the same distinction. The external check and database write cannot share a transaction. Rescheduling still uses the existing validation path. + Google Meet and Teams event types can opt into `allow_phone_call`. Their booking page and widget then accept an optional `phone` value. A valid number selects a telephone appointment; leaving it empty preserves video. The booking stores its own location type so management pages, calendar retries, and paid confirmation do not generate a video link for a telephone appointment. Event duplication preserves the setting. `internal/booking/service.go` (transactions) + `internal/handler/booking_handler.go` diff --git a/internal/handler/booking_assistant.go b/internal/handler/booking_assistant.go index 4cbe5125..ae64b74c 100644 --- a/internal/handler/booking_assistant.go +++ b/internal/handler/booking_assistant.go @@ -433,6 +433,8 @@ func (h *Handler) runAssistantTool(ctx context.Context, slug, tz, lang, name, ar // the model can relay. func assistantBookError(err error) string { switch { + case errors.Is(err, errCalendarUnavailable): + return errCalendarUnavailable.Error() case errors.Is(err, errEventTypeNotFound): return "this event type is no longer available" case errors.Is(err, booking.ErrDoubleBooked), errors.Is(err, errNoHostAvailable), errors.Is(err, errSlotUnavailable): diff --git a/internal/handler/booking_calendar_check.go b/internal/handler/booking_calendar_check.go new file mode 100644 index 00000000..f39d5d8a --- /dev/null +++ b/internal/handler/booking_calendar_check.go @@ -0,0 +1,82 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/calnode/calnode/internal/slots" +) + +var errCalendarUnavailable = errors.New("calendar availability could not be checked; please try again shortly") + +func (h *Handler) calendarFreeHosts(ctx context.Context, et *bookableEventType, candidates, required, optional []string, start, end time.Time) ([]string, []string, error) { + gc := h.getCal() + if gc == nil { + return candidates, optional, nil + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + window := slots.Interval{ + Start: start.Add(-time.Duration(et.BufferAfterMinutes) * time.Minute), + End: end.Add(time.Duration(et.BufferBeforeMinutes) * time.Minute), + } + free := map[string]bool{} + check := func(id string) (bool, error) { + if ok, seen := free[id]; seen { + return ok, nil + } + ownEvents, err := h.ownCalendarEvents(ctx, id, window.Start.Format(time.RFC3339Nano), window.End.Format(time.RFC3339Nano)) + if err != nil { + return false, fmt.Errorf("load own calendar events: %w", err) + } + busy, err := gc.FreeBusy(ctx, id, window.Start, window.End) + if err != nil { + return false, fmt.Errorf("%w: %w", errCalendarUnavailable, err) + } + busy = slots.SubtractIntervals(busy, ownEvents) + remaining := slots.SubtractIntervals([]slots.Interval{window}, busy) + if len(remaining) != 1 || remaining[0] != window { + free[id] = false + return false, nil + } + free[id] = true + return true, nil + } + for _, id := range required { + ok, err := check(id) + if err != nil { + return nil, nil, err + } + if !ok { + return nil, nil, errSlotUnavailable + } + } + available := make([]string, 0, len(candidates)) + for _, id := range candidates { + ok, err := check(id) + if err != nil { + return nil, nil, err + } + if ok { + available = append(available, id) + } else if et.RoutingMode != "round_robin" { + return nil, nil, errSlotUnavailable + } + } + if len(available) == 0 { + return nil, nil, errSlotUnavailable + } + guests := make([]string, 0, len(optional)) + for _, id := range optional { + ok, err := check(id) + if err != nil { + return nil, nil, err + } + if ok { + guests = append(guests, id) + } + } + return available, guests, nil +} diff --git a/internal/handler/booking_calendar_check_test.go b/internal/handler/booking_calendar_check_test.go new file mode 100644 index 00000000..30cc7411 --- /dev/null +++ b/internal/handler/booking_calendar_check_test.go @@ -0,0 +1,273 @@ +package handler_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/slots" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type conflictCalendar struct { + calendar.Provider + name string + busy map[string][]slots.Interval + err error + calls int +} + +func (p *conflictCalendar) Name() string { return p.name } +func (p *conflictCalendar) FreeBusy(_ context.Context, id string, from, to time.Time) ([]slots.Interval, error) { + p.calls++ + var busy []slots.Interval + for _, iv := range p.busy[id] { + if iv.Start.Before(to) && iv.End.After(from) { + busy = append(busy, iv) + } + } + return busy, p.err +} + +func calendarCheckRequest(h *handler.Handler, slug string, start time.Time) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(fmt.Sprintf(`{"event_type_slug":%q,"start_at":%q,"name":"Test","email":"test@example.com"}`, slug, start.Format(time.RFC3339)))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.CreateBooking(rec, req) + return rec +} + +func calendarCheckExec(t *testing.T, database *sql.DB, query string, args ...any) { + t.Helper() + if _, err := database.Exec(query, args...); err != nil { + t.Fatal(err) + } +} + +func TestBookingCalendarHostSelection(t *testing.T) { + start := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name, mode string + roles []string + busy []int + providerError bool + wantStatus int + wantHosts []int + }{ + {"required_busy", "fixed", []string{"required"}, []int{0}, false, 409, nil}, + {"provider_unavailable", "fixed", []string{"required"}, nil, true, 503, nil}, + {"rotation_filters_busy", "round_robin", []string{"rotation", "rotation"}, []int{0}, false, 201, []int{1}}, + {"all_rotation_busy", "round_robin", []string{"rotation", "rotation"}, []int{0, 1}, false, 409, nil}, + {"round_robin_required_busy", "round_robin", []string{"required", "rotation"}, []int{0}, false, 409, nil}, + {"round_robin_required_attends", "round_robin", []string{"required", "rotation"}, nil, false, 201, []int{0, 1}}, + {"collective_required_busy", "collective", []string{"required", "required"}, []int{1}, false, 409, nil}, + {"optional_busy_dropped", "fixed", []string{"required", "optional"}, []int{1}, false, 201, []int{0}}, + {"optional_free_attends", "fixed", []string{"required", "optional"}, nil, false, 201, []int{0, 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + h, database, key, owner := setupWorkspaceWithDB(t) + slug, etID := seedEventTypeHTTP(t, h, key) + calendarCheckExec(t, database, `UPDATE event_types SET routing_mode = ? WHERE id = ?`, tc.mode, etID) + calendarCheckExec(t, database, `DELETE FROM event_type_hosts WHERE event_type_id = ?`, etID) + hosts := []string{owner} + for i, role := range tc.roles { + if i > 0 { + id := fmt.Sprintf("host-%d", i) + calendarCheckExec(t, database, `INSERT INTO users (id,email,name,iana_timezone) VALUES (?,?,?,'UTC')`, id, id+"@example.com", id) + seedFullAvailabilityDB(t, database, id) + hosts = append(hosts, id) + } + calendarCheckExec(t, database, `INSERT INTO event_type_hosts (id,event_type_id,user_id,role,priority) VALUES (?,?,?,?,?)`, fmt.Sprintf("seat-%d", i), etID, hosts[i], role, i) + } + provider := &conflictCalendar{name: "google", busy: map[string][]slots.Interval{}} + for _, i := range tc.busy { + provider.busy[hosts[i]] = []slots.Interval{{Start: start, End: start.Add(time.Hour)}} + } + if tc.providerError { + provider.err = errors.New("private provider diagnostic") + } + svc := calendar.NewService(database) + svc.Register(provider) + if tc.providerError { + svc.Register(&conflictCalendar{name: "other"}) + } + h.SetCalendar(svc) + rec := calendarCheckRequest(h, slug, start) + if rec.Code != tc.wantStatus { + t.Fatalf("status %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body) + } + if provider.calls == 0 { + t.Fatal("booking did not check the provider") + } + if tc.providerError && (!strings.Contains(rec.Body.String(), "try again") || strings.Contains(rec.Body.String(), "private provider")) { + t.Fatalf("provider error not safely mapped: %s", rec.Body) + } + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&count); err != nil { + t.Fatal(err) + } + if tc.wantStatus != 201 { + if count != 0 { + t.Fatalf("created %d bookings after failed check", count) + } + return + } + if count != 1 { + t.Fatalf("created %d bookings", count) + } + rows, err := database.Query(`SELECT user_id FROM booking_hosts ORDER BY user_id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var got []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + got = append(got, id) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + var want []string + for _, i := range tc.wantHosts { + want = append(want, hosts[i]) + } + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("hosts = %v, want %v", got, want) + } + }) + } +} + +func TestBookingCalendarBuffersMatchSlots(t *testing.T) { + start := time.Date(2026, 6, 15, 9, 30, 0, 0, time.UTC) + for _, tc := range []struct { + name string + before, after, busyOffset int + wantFree bool + }{ + {"before_blocks_later_event", 15, 0, 30, false}, + {"after_blocks_earlier_event", 0, 30, -45, false}, + {"before_allows_earlier_event", 15, 0, -30, true}, + {"after_allows_later_event", 0, 30, 45, true}, + {"before_touching_boundary", 15, 0, 45, true}, + {"after_touching_boundary", 0, 30, -60, true}, + } { + t.Run(tc.name, func(t *testing.T) { + h, database, key, owner := setupWorkspaceWithDB(t) + slug, etID := seedEventTypeHTTP(t, h, key) + calendarCheckExec(t, database, `UPDATE event_types SET buffer_before_minutes = ?, buffer_after_minutes = ? WHERE id = ?`, tc.before, tc.after, etID) + busyStart := start.Add(time.Duration(tc.busyOffset) * time.Minute) + busy := []slots.Interval{{Start: busyStart, End: busyStart.Add(30 * time.Minute)}} + generated, err := slots.Generate(slots.Request{ + Event: slots.EventConfig{DurationMinutes: 30, SlotIntervalMinutes: 30, BufferBeforeMinutes: tc.before, BufferAfterMinutes: tc.after, RoutingMode: "fixed"}, + Hosts: []slots.HostAvailability{{HostID: owner, Location: time.UTC, Rules: []slots.AvailabilityRule{{DayOfWeek: time.Monday, StartTime: "00:00", EndTime: "23:59"}}, Busy: busy}}, + DateFrom: start, DateTo: start, BookerTZ: time.UTC, Now: start.Add(-24 * time.Hour), + }) + if err != nil { + t.Fatal(err) + } + offered := slices.ContainsFunc(generated, func(s slots.Slot) bool { return s.Start.Equal(start) }) + if offered != tc.wantFree { + t.Fatalf("slot offered = %v, want %v", offered, tc.wantFree) + } + svc := calendar.NewService(database) + svc.Register(&conflictCalendar{name: "google", busy: map[string][]slots.Interval{owner: busy}}) + h.SetCalendar(svc) + rec := calendarCheckRequest(h, slug, start) + want := http.StatusConflict + if offered { + want = http.StatusCreated + } + if rec.Code != want { + t.Fatalf("status %d, want %d: %s", rec.Code, want, rec.Body) + } + }) + } +} + +func TestBookingCalendarCancelledOwnEvent(t *testing.T) { + start := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) + for _, otherEvent := range []bool{false, true} { + t.Run(fmt.Sprintf("otherEvent=%v", otherEvent), func(t *testing.T) { + h, database, key, owner := setupWorkspaceWithDB(t) + slug, etID := seedEventTypeHTTP(t, h, key) + calendarCheckExec(t, database, `INSERT INTO bookings (id,event_type_id,host_id,start_at,end_at,status) VALUES ('cancelled',?,?,?,?,'cancelled')`, etID, owner, start.Add(-time.Hour).Format(time.RFC3339), start.Add(time.Hour).Format(time.RFC3339)) + calendarCheckExec(t, database, `INSERT INTO booking_hosts (booking_id,user_id,external_event_id) VALUES ('cancelled',?,'old-event')`, owner) + busyEnd := start.Add(time.Hour) + bookingStart := start + if otherEvent { + busyEnd = busyEnd.Add(30 * time.Minute) + bookingStart = start.Add(time.Hour) + } + svc := calendar.NewService(database) + svc.Register(&conflictCalendar{name: "google", busy: map[string][]slots.Interval{owner: {{Start: start.Add(-time.Hour), End: busyEnd}}}}) + h.SetCalendar(svc) + rec := calendarCheckRequest(h, slug, bookingStart) + want := http.StatusCreated + if otherEvent { + want = http.StatusConflict + } + if rec.Code != want { + t.Fatalf("status %d, want %d: %s", rec.Code, want, rec.Body) + } + }) + } +} + +func TestMCPBookingCalendarErrors(t *testing.T) { + start := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) + for _, outage := range []bool{false, true} { + t.Run(fmt.Sprintf("outage=%v", outage), func(t *testing.T) { + h, database, key, owner := setupWorkspaceWithDB(t) + slug, _ := seedEventTypeHTTP(t, h, key) + provider := &conflictCalendar{name: "google", busy: map[string][]slots.Interval{owner: {{Start: start, End: start.Add(time.Hour)}}}} + want := "this slot is no longer available" + if outage { + provider.err = errors.New("private provider diagnostic") + want = "calendar availability could not be checked; please try again shortly" + } + svc := calendar.NewService(database) + svc.Register(provider) + h.SetCalendar(svc) + client := connectMCP(t, h) + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{Name: "create_booking", Arguments: map[string]any{ + "event_type_id": slug, "slot_start": start.Format(time.RFC3339), "attendee_name": "Test", "attendee_email": "test@example.com", + }}) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(result.Content) + if err != nil { + t.Fatal(err) + } + if !result.IsError || !strings.Contains(string(body), want) || strings.Contains(string(body), "private provider") { + t.Fatalf("MCP error: %s", body) + } + if provider.calls == 0 { + t.Fatal("MCP booking did not check the provider") + } + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("created %d bookings", count) + } + }) + } +} diff --git a/internal/handler/booking_calendar_error_test.go b/internal/handler/booking_calendar_error_test.go new file mode 100644 index 00000000..878c0cd7 --- /dev/null +++ b/internal/handler/booking_calendar_error_test.go @@ -0,0 +1,21 @@ +package handler + +import ( + "errors" + "fmt" + "testing" +) + +func TestAssistantCalendarErrors(t *testing.T) { + for _, tc := range []struct { + err error + want string + }{ + {errSlotUnavailable, "that time was just taken — please choose another slot"}, + {fmt.Errorf("%w: %w", errCalendarUnavailable, errors.New("private provider diagnostic")), "calendar availability could not be checked; please try again shortly"}, + } { + if got := assistantBookError(tc.err); got != tc.want { + t.Fatalf("message = %q, want %q", got, tc.want) + } + } +} diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 1e88c1bd..04dec0de 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -547,6 +547,13 @@ func (h *Handler) createBookingForSlug(ctx context.Context, slug string, startAt if err := h.validateBookingTime(ctx, et, et.RoutingMode, candidates, required, startAt.UTC(), endAt); err != nil { return nil, err } + candidates, optional, err = h.calendarFreeHosts(ctx, et, candidates, required, optional, startAt.UTC(), endAt) + if err != nil { + if !errors.Is(err, errSlotUnavailable) { + h.logger.ErrorContext(ctx, "booking calendar check failed", "error", err) + } + return nil, err + } b, err := h.bookingSvc.Create(ctx, booking.CreateParams{ EventTypeID: et.ID, HostIDs: candidates, @@ -852,6 +859,20 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { return } + candidates, optional, err = h.calendarFreeHosts(r.Context(), et, candidates, required, optional, startAt.UTC(), endAt) + if err != nil { + switch { + case errors.Is(err, errSlotUnavailable): + h.writeError(w, http.StatusConflict, "this slot is no longer available; please select another time") + case errors.Is(err, errCalendarUnavailable): + h.logger.ErrorContext(r.Context(), "booking calendar check failed", "error", err) + h.writeError(w, http.StatusServiceUnavailable, errCalendarUnavailable.Error()) + default: + h.logger.ErrorContext(r.Context(), "booking calendar check failed", "error", err) + h.writeError(w, http.StatusInternalServerError, "could not complete the booking") + } + return + } b, err := h.bookingSvc.Create(r.Context(), booking.CreateParams{ EventTypeID: et.ID, HostIDs: candidates, diff --git a/internal/handler/mcp.go b/internal/handler/mcp.go index d211727b..f7953a50 100644 --- a/internal/handler/mcp.go +++ b/internal/handler/mcp.go @@ -401,6 +401,8 @@ func (h *Handler) mcpCreateBooking(ctx context.Context, _ *mcp.CallToolRequest, booking.Attendee{Name: in.AttendeeName, Email: in.AttendeeEmail, IANATimezone: tz}, raw) if err != nil { switch { + case errors.Is(err, errCalendarUnavailable): + return nil, bookingJSON{}, errCalendarUnavailable case errors.Is(err, errEventTypeNotFound): return nil, bookingJSON{}, fmt.Errorf("event type not found: %s", in.EventTypeID) case errors.Is(err, booking.ErrDoubleBooked), errors.Is(err, errNoHostAvailable), errors.Is(err, errSlotUnavailable): diff --git a/internal/handler/slots_handler.go b/internal/handler/slots_handler.go index 52f094ef..d4eeef4f 100644 --- a/internal/handler/slots_handler.go +++ b/internal/handler/slots_handler.go @@ -464,33 +464,10 @@ func (h *Handler) hostAvailability(ctx context.Context, userID, eventTypeID stri // only while we believe a Google event still exists (it's cleared on a successful // cancel, inline or via the reconciler), so this targets exactly our own events. // Materialise fully before the free/busy call (single-connection pool). - var ownEvents []slots.Interval - ownRows, err := h.db.QueryContext(ctx, ` - SELECT b.start_at, b.end_at FROM bookings b - JOIN booking_hosts bh ON bh.booking_id = b.id - WHERE bh.user_id = ? AND COALESCE(bh.external_event_id, '') != '' - AND b.start_at >= ? AND b.start_at < ?`, - userID, busyFrom, busyTo) + ownEvents, err := h.ownCalendarEvents(ctx, userID, busyFrom, busyTo) if err != nil { return slots.HostAvailability{}, err } - for ownRows.Next() { - var startStr, endStr string - if err := ownRows.Scan(&startStr, &endStr); err != nil { - ownRows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable - return slots.HostAvailability{}, err - } - s, err1 := time.Parse(time.RFC3339Nano, startStr) - e, err2 := time.Parse(time.RFC3339Nano, endStr) - if err1 != nil || err2 != nil { - continue - } - ownEvents = append(ownEvents, slots.Interval{Start: s, End: e}) - } - ownRows.Close() // #nosec G104 -- rows already fully consumed above; nothing actionable on close error - if err := ownRows.Err(); err != nil { - return slots.HostAvailability{}, err - } // Merge Google Calendar free/busy (check_conflicts connections only), minus our // own events. Non-fatal. @@ -505,6 +482,32 @@ func (h *Handler) hostAvailability(ctx context.Context, userID, eventTypeID stri return slots.HostAvailability{HostID: userID, Location: hostLoc, Rules: rules, Overrides: overrides, Busy: busy}, nil } +func (h *Handler) ownCalendarEvents(ctx context.Context, userID, from, to string) ([]slots.Interval, error) { + rows, err := h.db.QueryContext(ctx, ` + SELECT b.start_at, b.end_at FROM bookings b + JOIN booking_hosts bh ON bh.booking_id = b.id + WHERE bh.user_id = ? AND COALESCE(bh.external_event_id, '') != '' + AND b.start_at < ? AND b.end_at > ?`, userID, to, from) + if err != nil { + return nil, err + } + defer rows.Close() + var events []slots.Interval + for rows.Next() { + var startStr, endStr string + if err := rows.Scan(&startStr, &endStr); err != nil { + return nil, err + } + start, startErr := time.Parse(time.RFC3339Nano, startStr) + end, endErr := time.Parse(time.RFC3339Nano, endStr) + if startErr != nil || endErr != nil { + continue + } + events = append(events, slots.Interval{Start: start, End: end}) + } + return events, rows.Err() +} + // parseDateRangeStr resolves from/to date strings (either may be "") to UTC-midnight times. // Returns (from, to, ok). ok=false means the params were malformed. // maxFutureDays=0 is treated as 365 (no configured limit). The resolved