package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "unicode" ) // Cal.com v2 API constants. Each endpoint requires its own `cal-api-version` // value — historically they have all diverged. const ( versionSlots = "2024-09-04" versionEventTypes = "2024-06-14" versionBookings = "2026-02-25" versionMe = "2024-06-11" versionSchedules = "2024-06-11" ) // calcomBaseURL is the Cal.com v2 API root. Declared as a var (not const) so // the integration tests in handler_test.go can override it to point at a fake // httptest.NewServer; in production it is set exactly once at startup and // never mutated. The handlers call NewCalcomClient(apiKey) inline, which reads // this value, so a single var swap reroutes every public handler at once. var calcomBaseURL = "https://api.cal.com/v2" // CalcomClient is a thin wrapper over Cal.com's v2 REST API. Each method sets // the version header appropriate for the endpoint it targets. type CalcomClient struct { http *http.Client apiKey string baseURL string } // NewCalcomClient builds a client with the standard 30s HTTP timeout. func NewCalcomClient(apiKey string) *CalcomClient { return &CalcomClient{ http: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: calcomBaseURL, } } // SlotsResponse mirrors the shape of `GET /v2/slots`: // // { "status": "success", "data": { "YYYY-MM-DD": [{ "time": "..." }] } } type SlotsResponse struct { Status string `json:"status"` Data map[string][]SlotEntry `json:"data"` } // SlotEntry is a single slot row in the SlotsResponse.Data map. // // The slot's timestamp field is cal-api-version dependent. The version this // client pins — versionSlots = "2024-09-04" — returns {"start": ""}, // whereas the older 2024-08-13 shape returned {"time": ""}. Cal.com // has a long history of diverging response shapes across versions (see the // version-const note above), so we decode BOTH fields and resolve the // effective value via When() — keeping the widget working even if the pinned // version is ever rolled forward or back. type SlotEntry struct { Start string `json:"start"` Time string `json:"time"` } // When returns the slot's RFC3339 start timestamp, preferring the 2024-09-04 // `start` field and falling back to the legacy `time` field. func (s SlotEntry) When() string { if s.Start != "" { return s.Start } return s.Time } // GetSlots returns the slots Cal.com would offer for the given event type // between [start, end] in the requested timezone. start/end should be RFC3339. func (c *CalcomClient) GetSlots(ctx context.Context, username, eventTypeSlug, start, end, timeZone string) (SlotsResponse, error) { q := url.Values{} q.Set("username", username) q.Set("eventTypeSlug", eventTypeSlug) q.Set("start", start) q.Set("end", end) if timeZone != "" { q.Set("timeZone", timeZone) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/slots?"+q.Encode(), nil) if err != nil { return SlotsResponse{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionSlots) resp, err := c.http.Do(req) if err != nil { return SlotsResponse{}, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return SlotsResponse{}, statusError("slots", resp) } var out SlotsResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return SlotsResponse{}, fmt.Errorf("decode slots: %w", err) } return out, nil } // ListEventTypes returns the compact summary list used to populate the editor // dropdown. func (c *CalcomClient) ListEventTypes(ctx context.Context, username string) ([]EventTypeSummary, error) { q := url.Values{} q.Set("username", username) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionEventTypes) resp, err := c.http.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, statusError("event-types", resp) } var wrap struct { Status string `json:"status"` Data []EventTypeSummary `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { return nil, fmt.Errorf("decode event-types: %w", err) } return wrap.Data, nil } // MeResponse is the subset of Cal.com's /v2/me response that the plugin uses // to identify the API key's owner so the block editor doesn't have to ask the // admin to type their own username. type MeResponse struct { Username string `json:"username"` Email string `json:"email"` } // GetMe returns the account identity associated with the configured API key. // This is the source of truth for "whose Cal.com is this plugin configured // for", and lets us drop the manual username input from the block editor. func (c *CalcomClient) GetMe(ctx context.Context) (MeResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/me", nil) if err != nil { return MeResponse{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionMe) resp, err := c.http.Do(req) if err != nil { return MeResponse{}, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return MeResponse{}, statusError("me", resp) } var wrap struct { Status string `json:"status"` Data MeResponse `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { return MeResponse{}, fmt.Errorf("decode me: %w", err) } return wrap.Data, nil } // ListSchedules returns the availability schedules of the account the API key // belongs to. Used to resolve the meeting's timezone: an event type either // pins a schedule (ScheduleID) or uses the account's default (IsDefault). func (c *CalcomClient) ListSchedules(ctx context.Context) ([]Schedule, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/schedules", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionSchedules) resp, err := c.http.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, statusError("schedules", resp) } var wrap struct { Status string `json:"status"` Data []Schedule `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { return nil, fmt.Errorf("decode schedules: %w", err) } return wrap.Data, nil } // GetEventType fetches the full event type record — including the configured // `bookingFields` array — so the public form can render dynamic questions. func (c *CalcomClient) GetEventType(ctx context.Context, username, eventTypeSlug string) (EventType, error) { q := url.Values{} q.Set("username", username) q.Set("eventSlug", eventTypeSlug) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil) if err != nil { return EventType{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionEventTypes) resp, err := c.http.Do(req) if err != nil { return EventType{}, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return EventType{}, statusError("event-type", resp) } var wrap struct { Status string `json:"status"` Data []EventType `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { return EventType{}, fmt.Errorf("decode event-type: %w", err) } if len(wrap.Data) == 0 { return EventType{}, fmt.Errorf("event type %q not found for user %q", eventTypeSlug, username) } et := wrap.Data[0] et.BookingFields = normalizeBookingFields(et.BookingFields) return et, nil } // normalizeBookingFields adapts Cal.com's v2 bookingFields to the shape the // booking form template renders. Without it, default/system fields decode with // an empty Name (the v2 identifier is `slug`, not `name`) and an empty Label // (Cal.com leaves system-field labels blank and renders them client-side from // i18n) — producing unlabelled inputs with empty `name` attributes, which are // both invisible to the visitor AND drop their values on submit. Hidden system // fields (e.g. `title`, `rescheduleReason`) are dropped so they never render. func normalizeBookingFields(fields []BookingField) []BookingField { if len(fields) == 0 { return fields } out := make([]BookingField, 0, len(fields)) for _, f := range fields { if f.Hidden || f.Editable == "system-but-hidden" { continue } // Reschedule-only system fields (rescheduleReason carries // views:[{id:"reschedule"}]) must never render on a NEW booking. if isRescheduleOnlyView(f.Views) { continue } // System fields the submit path drops because they have no valid // new-booking home (location → structured top-level object handled by // Cal.com's default; rescheduleReason → reschedule-only; title → // system-managed). Drop them from the form too, so we never render an // input whose value would be silently discarded. (rescheduleReason is // also covered here by slug for event types that omit the views flag.) if systemDropSlugs[f.Slug] || systemDropSlugs[f.Name] { continue } if f.Name == "" { f.Name = f.Slug } if f.Label == "" { f.Label = bookingFieldLabel(f.Name) } out = append(out, f) } return out } // isRescheduleOnlyView reports whether a booking field's `views` restrict it to // the reschedule flow. Cal.com sets views:[{id:"reschedule"}] on rescheduleReason; // such fields are invalid on a create booking and must not be rendered. func isRescheduleOnlyView(views []BookingFieldView) bool { if len(views) == 0 { return false } for _, v := range views { if v.ID != "reschedule" { return false } } return true } // bookingFieldLabel resolves a human-readable label for a booking field whose // Cal.com `label` came back empty — always the case for default/system fields. // Known system slugs map to their conventional booker labels; anything else // (a custom field that somehow lacks a label) is humanized from its slug. func bookingFieldLabel(slug string) string { switch slug { case "name": return "Name" case "email": return "Email" case "location": return "Location" case "notes": return "Additional notes" case "guests": return "Guests" case "rescheduleReason": return "Reason for reschedule" case "attendeePhoneNumber", "phone", "smsReminderNumber": return "Phone number" case "title": return "What is this meeting about?" } return humanizeSlug(slug) } // humanizeSlug turns a field slug like "company-size" or "company_size" into a // sentence-case display label ("Company size"). Empty input yields "Field" so a // label element is never rendered completely blank. func humanizeSlug(slug string) string { s := strings.TrimSpace(strings.NewReplacer("-", " ", "_", " ").Replace(slug)) if s == "" { return "Field" } r := []rune(s) r[0] = unicode.ToUpper(r[0]) return string(r) } // BookingAttendee carries the built-in fields that Cal.com expects every // booking to include. Custom event-type fields travel in BookingFieldsResponses. type BookingAttendee struct { Name string `json:"name"` Email string `json:"email"` TimeZone string `json:"timeZone"` Phone string `json:"phoneNumber,omitempty"` Language string `json:"language,omitempty"` } // BookingRequest is the v2 `POST /v2/bookings` body. The event type is // identified by slug + username — no separate ID lookup. type BookingRequest struct { EventTypeSlug string `json:"eventTypeSlug"` Username string `json:"username"` Start string `json:"start"` Attendee BookingAttendee `json:"attendee"` BookingFieldsResponses map[string]any `json:"bookingFieldsResponses,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` Guests []string `json:"guests,omitempty"` } // BookingResponse is the parsed `POST /v2/bookings` success envelope. type BookingResponse struct { Status string `json:"status"` Data BookingResponseData `json:"data"` } // BookingResponseData carries the Cal.com-provided identifiers and the meeting // location surfaced to the attendee. // // Per the 2026-02-25 POST /v2/bookings response, `location` is the canonical // meeting field and `meetingUrl` is deprecated ("rely on 'location' instead"), // kept here only as a fallback. The response does NOT carry rescheduleUrl / // cancelUrl — those reach the attendee via Cal.com's confirmation email, so we // no longer decode (or surface) them. type BookingResponseData struct { UID string `json:"uid"` StartTime string `json:"start"` EndTime string `json:"end"` Location string `json:"location"` MeetingURL string `json:"meetingUrl"` // deprecated by Cal.com; fallback only } // MeetingLink returns the best available join URL. It prefers the canonical // `location` field, but only when that is an http(s) URL — for non-video event // types `location` can be a physical address or phone number, which must not be // surfaced as a link. Falls back to the deprecated `meetingUrl`. func (d BookingResponseData) MeetingLink() string { if isHTTPURL(d.Location) { return d.Location } if isHTTPURL(d.MeetingURL) { return d.MeetingURL } return "" } // isHTTPURL reports whether s is an http(s) URL. func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } // CreateBooking submits a new booking to Cal.com. For a regular or instant // event type the response `data` is a single object; for a recurring event // type it is an array (one entry per occurrence). Both shapes are resolved — // a recurring booking surfaces its first occurrence. func (c *CalcomClient) CreateBooking(ctx context.Context, br BookingRequest) (BookingResponse, error) { body, err := json.Marshal(br) if err != nil { return BookingResponse{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/bookings", bytes.NewReader(body)) if err != nil { return BookingResponse{}, err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionBookings) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return BookingResponse{}, err } defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return BookingResponse{}, statusErrorWithBody("booking", resp.StatusCode, respBody) } // The bookings `data` is a single object for regular/instant bookings but an // ARRAY for recurring event types. Decode the envelope with a raw `data` so // both shapes resolve instead of hard-failing on the array form. var env struct { Status string `json:"status"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(respBody, &env); err != nil { return BookingResponse{}, fmt.Errorf("decode booking: %w", err) } out := BookingResponse{Status: env.Status} switch trimmed := bytes.TrimSpace(env.Data); { case len(trimmed) == 0: // no data object — leave zero-value Data case trimmed[0] == '[': var arr []BookingResponseData if err := json.Unmarshal(trimmed, &arr); err != nil { return BookingResponse{}, fmt.Errorf("decode booking (recurring): %w", err) } if len(arr) == 0 { return BookingResponse{}, fmt.Errorf("calcom booking: recurring data array was empty") } out.Data = arr[0] default: if err := json.Unmarshal(trimmed, &out.Data); err != nil { return BookingResponse{}, fmt.Errorf("decode booking: %w", err) } } return out, nil } // CancelBooking cancels an existing booking on Cal.com. // // Endpoint (confirmed against the Cal.com v2 docs, 2026-06-21): // // POST /v2/bookings/{bookingUid}/cancel // cal-api-version: 2026-02-25 (the same version the create path pins — // passing the wrong value silently routes to an // older endpoint shape) // body: { "cancellationReason": "" } (reason optional) // // Authorization is UID-based, mirroring Cal.com's own emailed cancel-link // model: the booking UID is an unguessable identifier, so possession of it is // the capability to cancel. The caller is responsible for the abuse guard (the // public handler rate-limits by IP). uid is URL-path-escaped so a UID with // path-significant characters can't break out of the route. // // A non-2xx response becomes a *CalcomAPIError (via statusErrorWithBody) so the // handler can classify it; the raw Cal.com message stays server-side only. func (c *CalcomClient) CancelBooking(ctx context.Context, uid, reason string) error { body, err := json.Marshal(struct { CancellationReason string `json:"cancellationReason"` }{CancellationReason: reason}) if err != nil { return err } endpoint := c.baseURL + "/bookings/" + url.PathEscape(uid) + "/cancel" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("cal-api-version", versionBookings) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return statusErrorWithBody("cancel-booking", resp.StatusCode, respBody) } return nil } // CalcomAPIError is a typed, non-2xx Cal.com API response. It carries the // structured pieces the booking handler needs to classify a failure into // user-actionable, curated copy — the HTTP Status, Cal.com's error Code (e.g. // "BadRequestException"), and the raw Message ("Attempting to book a meeting in // the past.") — WITHOUT the handler having to re-parse a flattened log string. // // Network/transport failures (DNS, timeout, connection reset) are NOT // CalcomAPIErrors — those stay plain wrapped errors so the handler routes them // to the admin-authored Tier-2 message. Only an HTTP response that came back // with a non-2xx status produces a CalcomAPIError. // // Error() reproduces the exact string the previous fmt.Errorf form logged, so // server-side logging is byte-for-byte unchanged. The Message is for // classification + logs only — it is NEVER rendered to a visitor verbatim. type CalcomAPIError struct { Endpoint string // the Cal.com endpoint that failed, e.g. "booking" Status int // HTTP status of the non-2xx response Code string // Cal.com error.code, e.g. "BadRequestException" / "BAD_REQUEST" ("" if absent) Message string // raw Cal.com error.message ("" if the body carried none) } func (e *CalcomAPIError) Error() string { // Preserve the historical log shape: "calcom : status [: : ]". combined := e.Message if e.Code != "" && e.Message != "" { combined = e.Code + ": " + e.Message } else if e.Code != "" { combined = e.Code } if combined == "" { return fmt.Sprintf("calcom %s: status %d", e.Endpoint, e.Status) } return fmt.Sprintf("calcom %s: status %d: %s", e.Endpoint, e.Status, combined) } // statusError builds a sanitized error from a non-2xx response. The status // code is exposed; the body is captured but only included for non-5xx so we // don't leak upstream incident traces to operators. func statusError(endpoint string, resp *http.Response) error { body, _ := io.ReadAll(resp.Body) return statusErrorWithBody(endpoint, resp.StatusCode, body) } // statusErrorWithBody returns a *CalcomAPIError for a non-2xx Cal.com response. // The handler can errors.As it to branch on Status/Code/Message; everything // else (and log output) sees the same flattened string as before. func statusErrorWithBody(endpoint string, status int, body []byte) error { code, msg := extractCalcomError(body) return &CalcomAPIError{Endpoint: endpoint, Status: status, Code: code, Message: msg} } // extractCalcomError pulls the structured (code, message) pair out of a Cal.com // error body. Cal.com v2 nests these under `error` as an OBJECT // ({"error":{"code","message"}}); older/other shapes use a flat top-level // `message` or a string `error`. All three are handled so a failed booking can // be classified (and logged) by its real reason ("Attempting to book a meeting // in the past.") instead of a bare "status 400". Both pieces stay server-side // only — public handlers always render curated visitor-facing copy. func extractCalcomError(body []byte) (code, message string) { var parsed struct { Message string `json:"message"` Error json.RawMessage `json:"error"` } if err := json.Unmarshal(body, &parsed); err != nil { return "", "" } if parsed.Message != "" { return "", parsed.Message } if len(parsed.Error) == 0 { return "", "" } // `error` may be a structured object {code,message} (v2) or a plain string. var errObj struct { Code string `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(parsed.Error, &errObj); err == nil && errObj.Message != "" { return errObj.Code, errObj.Message } var errStr string if err := json.Unmarshal(parsed.Error, &errStr); err == nil && errStr != "" { return "", errStr } return "", "" }