fix: publish SDK-compatible Cal.com slots

This commit is contained in:
Alex Dunmow 2026-08-20 12:09:29 +08:00
parent 70b4c20438
commit 5396956f7e
5 changed files with 77 additions and 7 deletions

4
go.mod
View File

@ -1,9 +1,9 @@
module git.dev.alexdunmow.com/block/calcomblock module git.dev.alexdunmow.com/block/calcomblock
go 1.26.4 go 1.26.6
require ( require (
git.dev.alexdunmow.com/block/pluginsdk v0.3.5 git.dev.alexdunmow.com/block/pluginsdk v0.3.7
github.com/a-h/templ v0.3.1020 github.com/a-h/templ v0.3.1020
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0

4
go.sum
View File

@ -1,7 +1,7 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/pluginsdk v0.3.5 h1:h8YZTgnB/+WQ1yPS5MbJr2aPKuv/kudr93nQowwH8nM= git.dev.alexdunmow.com/block/pluginsdk v0.3.7 h1:PUT1YElhXre43m1UejDabSqRPAnx1BV7BEWKpG++7Rs=
git.dev.alexdunmow.com/block/pluginsdk v0.3.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM= git.dev.alexdunmow.com/block/pluginsdk v0.3.7/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=

View File

@ -15,10 +15,10 @@ import (
"sync" "sync"
"time" "time"
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
"git.dev.alexdunmow.com/block/pluginsdk/auth" "git.dev.alexdunmow.com/block/pluginsdk/auth"
"git.dev.alexdunmow.com/block/pluginsdk/plugin" "git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/rbac" "git.dev.alexdunmow.com/block/pluginsdk/rbac"
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nyaruka/phonenumbers" "github.com/nyaruka/phonenumbers"
) )
@ -500,7 +500,11 @@ func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) {
continue continue
} }
timed = append(timed, timedSlot{at: local, slot: TimeSlot{ timed = append(timed, timedSlot{at: local, slot: TimeSlot{
Start: ts, // UTC (…Z), never the event-timezone offset Cal.com may return:
// this value is carried raw in the slot button's query string, and
// a `+HH:MM` offset decodes to a space server-side, corrupting the
// timestamp Cal.com then rejects on booking.
Start: t.UTC().Format(time.RFC3339),
DisplayTime: local.Format("3:04 PM"), DisplayTime: local.Format("3:04 PM"),
DateKey: dateKey, DateKey: dateKey,
}}) }})

View File

@ -2,7 +2,7 @@
name = "calcomblock" name = "calcomblock"
display_name = "Cal.com Booking" display_name = "Cal.com Booking"
scope = "@ninja" scope = "@ninja"
version = "2.0.12" version = "2.0.13"
description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver." description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver."
kind = "plugin" kind = "plugin"
categories = ["forms"] categories = ["forms"]
@ -11,3 +11,5 @@ allowed_hosts = ["api.cal.com"]
[compatibility] [compatibility]
block_core = ">=0.3.4" block_core = ">=0.3.4"
admin_api = ">=0.1.2"
plugin_sdk = ">=0.3.7"

View File

@ -0,0 +1,64 @@
package main
import (
"html"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"testing"
"time"
)
// formHxGetRe extracts the hx-get URL of a slot button pointing at /form.
var formHxGetRe = regexp.MustCompile(`hx-get="([^"]*/form[^"]*)"`)
// TestHandleGetSlots_OffsetSlotStartSurvivesURLParsing pins the bidbuddy.com.au
// (Australia/Perth) production failure: Cal.com returns slot starts in the
// event's own timezone with a numeric `+08:00` offset. That value is carried in
// the slot button's hx-get query string; a raw `+` in a query string decodes to
// a SPACE server-side, corrupting the timestamp ("2026-06-11T10:00:00 08:00").
// The corrupted value flows form → book → Cal.com, which rejects it with
// "start must be a valid ISO 8601 date string". The slot's machine value must
// therefore round-trip through URL query parsing as a valid RFC3339 timestamp.
func TestHandleGetSlots_OffsetSlotStartSurvivesURLParsing(t *testing.T) {
// 2026-06-11T10:00:00+08:00 (Perth) == 2026-06-11T02:00:00Z.
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":{"2026-06-11":[{"start":"2026-06-11T10:00:00+08:00"}]}
}`)
})
h, _ := newKeyedHandler(t)
req := httptest.NewRequest(http.MethodGet,
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil)
rec := httptest.NewRecorder()
h.HandleGetSlots(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
m := formHxGetRe.FindStringSubmatch(rec.Body.String())
if m == nil {
t.Fatalf("no slot button hx-get to /form found in body: %q", rec.Body.String())
}
// The browser reads the attribute via the DOM, which unescapes & → &.
u, err := url.Parse(html.UnescapeString(m[1]))
if err != nil {
t.Fatalf("parse slot form URL %q: %v", m[1], err)
}
// u.Query() decodes exactly as net/http would on the wire — this is where a
// raw `+` becomes a space.
got := u.Query().Get("start")
parsed, err := time.Parse(time.RFC3339, got)
if err != nil {
t.Fatalf("slot start %q is not a valid ISO 8601 string after URL parsing (Cal.com would reject it): %v", got, err)
}
if want := time.Date(2026, 6, 11, 2, 0, 0, 0, time.UTC); !parsed.Equal(want) {
t.Errorf("slot start instant: got %s, want %s", parsed.UTC(), want)
}
}