Under wasm the guest has no network, so every Cal.com v2 call was dying at "dial tcp: lookup api.cal.com". Thread deps.OutboundHTTP (http.RoundTripper) from NewCalcomRouter onto CalcomHandler.rt and into NewCalcomClient, building the client with Transport: rt. A nil rt (native/DESCRIBE/test) falls back to the default transport, so bundled/httptest paths are unchanged. Declare allowed_hosts = ["api.cal.com"] (deny-by-default; admin-granted at install per the Phase 3 consent gate) and bump pluginsdk v0.2.4 -> v0.2.5 for CoreServices.OutboundHTTP. Bump 2.0.5 -> 2.0.6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
609 lines
22 KiB
Go
609 lines
22 KiB
Go
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. rt is the
|
|
// host-mediated egress transport (deps.OutboundHTTP, ADR 0023) — under wasm the
|
|
// guest has no network, so every Cal.com request must ride it. A nil rt falls
|
|
// back to http.DefaultTransport, keeping native/DESCRIBE and httptest paths
|
|
// working (tests reach their fake server by overriding calcomBaseURL).
|
|
func NewCalcomClient(apiKey string, rt http.RoundTripper) *CalcomClient {
|
|
return &CalcomClient{
|
|
http: &http.Client{Transport: rt, 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": "<RFC3339>"},
|
|
// whereas the older 2024-08-13 shape returned {"time": "<RFC3339>"}. 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>" } (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 <endpoint>: status <n>[: <code>: <msg>]".
|
|
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 "", ""
|
|
}
|