Extract calcomblock into a standalone wasm plugin repo

Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 03:04:18 +08:00
commit 18b7825592
68 changed files with 196611 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.bnp
*.wasm
/web/node_modules/
/web/pnpm-workspace.yaml

52
CLAUDE.md Normal file
View File

@ -0,0 +1,52 @@
# calcomblock plugin
Standalone **wasm** Cal.com Booking plugin (reactor-mode wasip1, packed into a
`.bnp` and hot-loaded by the CMS). Registers the `calcom:booking` block plus a
chi HTTP handler mounted at `/api/plugins/calcomblock/` (public booking + slots
+ webhook endpoints, admin-gated settings/test/event-types). The block editor is
a Vite / Module-Federation bundle in `web/` (exposed as `remoteEntry.js`),
served host-side from the `.bnp` at `/plugins/calcomblock/`.
Extracted from the former bundled `cms/backend/internal/plugins/calcomblock`.
The old `.so`/Air/`go:embed` workflow is gone — see `~/src/blockninja/plugins/AGENTS.md`
and the `developing-blockninja-plugins` skill for the current build/publish loop.
## SDK boundary
Only `git.dev.alexdunmow.com/block/core/...` is importable — never `block/cms/...`.
Two small things that used to come from `cms/internal` are vendored under
`internal/` with provenance headers:
- `internal/helpers``GetRealIP`, `StartCleanupLoop`, `MaskSecret`,
`GetStringOr`, the `PluginSettingsQuerier` settings helpers, and a
`PluginCrypto` (AES-256-GCM) used only by tests.
- `internal/db` — the minimal `Setting` / `UpsertSettingParams` structs the
settings helpers reference.
## Settings persistence (wasm sandbox)
Settings go through the SDK settings capabilities, NOT direct SQL: a plugin's
Postgres role is sandboxed to its own schema, so the CMS `settings` table
(public schema) is unreachable. `capabilityQuerier` (settings_store.go) adapts
`settings.Settings` / `settings.Updater` to the `PluginSettingsQuerier`
interface and is wired in `NewCalcomRouter`.
## Known limitation
The server-authoritative captcha requirement (`CalcomBookingRequiresCaptcha`,
which scanned published `page_block_snapshots`) has no wasm-ABI capability
equivalent, so `blockConfig` is nil: an enabled booking block still enforces the
honeypot + per-IP rate limit, but the published-content captcha gate is not
re-derived server-side. Restoring it needs a core capability exposing
published-block content across the ABI.
## Frontend
```bash
cd web && node node_modules/vite/bin/vite.js build # rebuild dist (pnpm's run-wrapper
# trips on ignored build scripts)
```
`web/dist` IS committed and packed into the `.bnp`; rebuild it before
`ninja plugin build`. `@block-ninja/ui` installs from the scoped Gitea registry
(`^0.1.0`), not a workspace link.

230
block.go Normal file
View File

@ -0,0 +1,230 @@
package main
import (
"bytes"
"context"
"time"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/settings"
)
// renderSettings is the SettingsManager available to CalcomBookingFunc, which
// renders during the public page pipeline where no HTTP request (and thus no
// visitor timezone) exists. Set once by NewCalcomRouter when the plugin's HTTP
// routes are mounted at startup — before any page render. Nil in editor-only
// or test contexts, where the grid falls back to the server clock.
var renderSettings *SettingsManager
// BookingConfig holds the configuration for rendering the booking widget
type BookingConfig struct {
BlockID string
Username string
EventTypeSlug string
Title string
Description string
ShowTimezone bool
WeeksToShow int
WeekStart string // YYYY-MM-DD — first date in the current grid window
Today string // YYYY-MM-DD — today in the resolved timezone, for past-clamping
TimeZone string // IANA name shown in the "All times shown in" label
RangeLabel string // e.g. "May 27 Jun 10, 2026" — populated by handler
CanGoBack bool // false when the window starts today (no point going earlier)
Dates []DateCell
// UnavailableMessage is the admin-authored copy shown when an online booking
// can't be completed for a non-fixable reason (Tier-2). Emitted as a hidden
// form input so the POST /book handler can read it back; defaults to
// DefaultUnavailableMessage when the admin leaves it blank.
UnavailableMessage string
}
// DateCell represents a single date in the calendar grid
type DateCell struct {
Date string // YYYY-MM-DD format
DayOfMonth string // e.g., "15"
DayOfWeek string // e.g., "Mon"
IsToday bool
IsPast bool
IsSelected bool // server-rendered selected state — set by HandleGetSlots
}
// CalcomBookingFunc renders the Cal.com booking widget
func CalcomBookingFunc(ctx context.Context, content map[string]any) string {
// Extract block ID from context or content
blockID := settings.GetStringOr(content, "_block_id", "")
if blockID == "" {
// Generate a simple ID if not provided
blockID = "calcom-" + time.Now().Format("150405")
}
config := BookingConfig{
BlockID: blockID,
Username: settings.GetStringOr(content, "username", ""),
EventTypeSlug: settings.GetStringOr(content, "eventTypeSlug", ""),
Title: settings.GetStringOr(content, "title", ""),
Description: settings.GetStringOr(content, "description", ""),
ShowTimezone: settings.GetBoolOr(content, "showTimezone", true),
WeeksToShow: settings.GetIntOr(content, "weeksToShow", 2),
UnavailableMessage: settings.GetStringOr(content, "unavailableMessage", DefaultUnavailableMessage),
}
// Check for missing required configuration
if config.Username == "" || config.EventTypeSlug == "" {
if blocks.IsEditor(ctx) {
return renderEditorPlaceholder()
}
return renderConfigError()
}
// Generate date grid for the calendar — initial window starts today on
// the MEETING's calendar: the cached event timezone when warm (filled by
// the first HTMX hit against the plugin endpoints), else the site
// Localization setting. Page render never blocks on a Cal.com round-trip.
// Without this, a container running UTC starts the grid on yesterday's
// date for a Perth schedule until 08:00 AWST.
loc, tzName := time.UTC, "UTC"
if renderSettings != nil {
loc, tzName = renderSettings.resolveLocation(ctx, peekEventTZ(config.Username, config.EventTypeSlug))
}
now := time.Now().In(loc)
today := now.Format("2006-01-02")
config.WeekStart = today
config.Today = today
config.TimeZone = tzName
config.Dates = generateDateGridFrom(now, config.WeeksToShow, today)
config.RangeLabel = formatRangeLabel(now, config.WeeksToShow)
config.CanGoBack = false // initial window is already at today
// Render the booking widget
var buf bytes.Buffer
if err := CalcomBookingWidget(config).Render(ctx, &buf); err != nil {
return renderError("Failed to render booking widget: " + err.Error())
}
return buf.String()
}
// formatRangeLabel renders the human-readable label for the date grid header,
// e.g. "May 27 Jun 10, 2026". If start and end fall in the same month, the
// label collapses to "May 27 31, 2026".
func formatRangeLabel(start time.Time, weeks int) string {
if weeks <= 0 {
weeks = 1
}
end := start.AddDate(0, 0, weeks*7-1)
if start.Year() == end.Year() && start.Month() == end.Month() {
return start.Format("Jan 2") + " " + end.Format("2, 2006")
}
if start.Year() == end.Year() {
return start.Format("Jan 2") + " " + end.Format("Jan 2, 2006")
}
return start.Format("Jan 2, 2006") + " " + end.Format("Jan 2, 2006")
}
// prevWeekStart returns the YYYY-MM-DD string for the previous date-grid
// window — weeks*7 days before the current weekStart, but clamped to today
// (the visitor can't book in the past). Used by the date-grid prev button.
// today is the YYYY-MM-DD date in the resolved timezone (BookingConfig.Today)
// — not the server clock's, which can be a day behind the visitor's.
func prevWeekStart(currentStart string, weeks int, today string) string {
if weeks <= 0 {
weeks = 1
}
t, err := time.Parse("2006-01-02", currentStart)
if err != nil || today > currentStart {
return today
}
prev := t.AddDate(0, 0, -weeks*7)
if prev.Format("2006-01-02") < today {
return today
}
return prev.Format("2006-01-02")
}
// nextWeekStart returns the YYYY-MM-DD string for the next date-grid window —
// weeks*7 days after the current weekStart.
func nextWeekStart(currentStart string, weeks int) string {
if weeks <= 0 {
weeks = 1
}
t, err := time.Parse("2006-01-02", currentStart)
if err != nil {
t = time.Now()
}
return t.AddDate(0, 0, weeks*7).Format("2006-01-02")
}
// generateDateGridFrom produces `weeks*7` consecutive day cells starting at
// `start`. Days strictly before `today` (YYYY-MM-DD in the resolved timezone)
// are flagged IsPast so the templ can render them disabled.
func generateDateGridFrom(start time.Time, weeks int, today string) []DateCell {
if weeks <= 0 {
weeks = 1
}
dates := make([]DateCell, 0, weeks*7)
for i := 0; i < weeks*7; i++ {
date := start.AddDate(0, 0, i)
dateStr := date.Format("2006-01-02")
dates = append(dates, DateCell{
Date: dateStr,
DayOfMonth: date.Format("2"),
DayOfWeek: date.Format("Mon"),
IsToday: dateStr == today,
IsPast: dateStr < today,
})
}
return dates
}
// leadingBlanks returns how many empty spacer cells must precede the first
// date so it lands under its weekday column header in the 7-column grid
// (Sun = column 1). Without these, a window starting mid-week pours into
// column 1 and every date renders under the wrong weekday — the bidbuddy
// "tapped Tue, got Friday" incident.
func leadingBlanks(dates []DateCell) int {
if len(dates) == 0 {
return 0
}
t, err := time.Parse("2006-01-02", dates[0].Date)
if err != nil {
return 0
}
return int(t.Weekday())
}
func renderEditorPlaceholder() string {
return `<div class="p-8 border-2 border-dashed border-border rounded-lg text-center">
<svg class="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-lg font-medium text-foreground">Cal.com Booking</p>
<p class="text-sm text-muted-foreground">Select an event type in the editor</p>
</div>`
}
func renderConfigError() string {
return `<div class="p-6 bg-destructive/10 border border-destructive/20 rounded-lg text-center">
<p class="text-sm text-destructive">Cal.com booking block is not configured properly.</p>
</div>`
}
func renderError(msg string) string {
return `<div class="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p class="text-sm text-destructive">` + msg + `</p>
</div>`
}
// inputTypeFor maps a Cal.com bookingField type to an HTML <input type> value.
// Unknown types fall through to `text`.
func inputTypeFor(t string) string {
switch t {
case "email":
return "email"
case "phone":
return "tel"
case "number":
return "number"
default:
return "text"
}
}

922
booking.templ Normal file
View File

@ -0,0 +1,922 @@
package main
import (
"fmt"
"strconv"
)
// CalcomBookingWidget renders the main booking widget container.
//
// The widget is divided into four regions, each with a stable ID so HTMX can
// swap them independently:
//
// - #calcom-steps-{id} step indicator (OOB-updated on every transition)
// - #calcom-date-grid-{id} the date picker
// - #calcom-slots-{id} time slots, loaded after a date click
// - #calcom-form-{id} booking form, loaded after a slot click
// - #calcom-confirm-{id} success panel, populated after submit
//
// Each partial returned by the server emits hx-swap-oob directives to keep
// the step indicator in sync with the user's progress, and to clear stale
// regions when the user changes their mind (Change date / Change time) or
// finishes (post-confirmation).
templ CalcomBookingWidget(config BookingConfig) {
<div
id={ fmt.Sprintf("calcom-widget-%s", config.BlockID) }
class="calcom-booking-widget bg-card border border-border rounded-xl p-4 sm:p-6 shadow-sm"
data-username={ config.Username }
data-event-type={ config.EventTypeSlug }
>
// Booking times render server-side in the MEETING's timezone (the
// event's Cal.com availability schedule) — the visitor's browser
// timezone is deliberately never detected or sent. Client-side
// timezone plumbing is what caused the 2026-06-10 incident: a hidden
// input's "UTC" default never got updated, every hx-include sent
// timezone=UTC, and visitors saw UTC slot times.
//
// This script only:
// - captures the browser locale for phone dial-code inference
// - wires the date-grid selection highlight
// - drives the phone country combobox
//
// Init is deferred to DOMContentLoaded: the locale input sits after
// this script in the widget and doesn't exist while it parses.
<script>
(function(){
var s = document.currentScript;
if (!s) return;
var w = s.parentElement;
if (!w || !w.classList.contains("calcom-booking-widget")) return;
function init(){
// Capture the browser locale (e.g. "en-AU") so the server can infer the
// phone dial code and convert national numbers to E.164 — Cal.com rejects
// national-format attendee phone numbers as invalid_number.
var loc = "";
try { loc = navigator.language || (navigator.languages && navigator.languages[0]) || ""; } catch(e) {}
var locInput = w.querySelector("input[name=\"locale\"]");
if (locInput) locInput.value = loc;
// Click delegate: mark the clicked date cell aria-pressed=true,
// clearing any previously pressed cell. Visual styling is handled
// by aria-pressed Tailwind variants on the button class.
w.addEventListener("click", function(ev){
var btn = ev.target.closest("[role=\"grid\"][aria-label=\"Booking date picker\"] button[role=\"gridcell\"]");
if (!btn || btn.disabled) return;
w.querySelectorAll("[role=\"grid\"][aria-label=\"Booking date picker\"] button[role=\"gridcell\"][aria-pressed=\"true\"]").forEach(function(b){
b.setAttribute("aria-pressed", "false");
});
btn.setAttribute("aria-pressed", "true");
});
// Country combobox (phone field). Delegated on the widget root so it works
// for the form partial HTMX swaps in after this script first runs.
function ccReset(box){
var hidden = box.querySelector("input[name=\"phoneCountry\"]");
var search = box.querySelector(".cc-search");
if (!hidden || !search) return;
var opt = box.querySelector(".cc-option[data-code=\"" + (hidden.value || "") + "\"]");
search.value = opt ? opt.textContent.trim() : "";
}
function ccClose(except){
w.querySelectorAll("[data-cc-combobox]").forEach(function(box){
var ul = box.querySelector(".cc-options");
if (!ul || ul === except) return;
if (!ul.classList.contains("hidden")) { ul.classList.add("hidden"); ccReset(box); }
var s = box.querySelector(".cc-search"); if (s) s.setAttribute("aria-expanded", "false");
});
}
function ccOpen(box, showAll){
var ul = box.querySelector(".cc-options"); var s = box.querySelector(".cc-search");
if (!ul || !s) return;
if (showAll) ul.querySelectorAll(".cc-option").forEach(function(li){ li.classList.remove("hidden"); });
ul.classList.remove("hidden"); s.setAttribute("aria-expanded", "true");
}
w.addEventListener("input", function(ev){
var s = ev.target.closest(".cc-search"); if (!s) return;
var box = s.closest("[data-cc-combobox]"); var ul = box.querySelector(".cc-options");
var q = s.value.trim().toLowerCase();
ul.querySelectorAll(".cc-option").forEach(function(li){
li.classList.toggle("hidden", q !== "" && li.textContent.toLowerCase().indexOf(q) === -1);
});
ccOpen(box, false);
});
w.addEventListener("click", function(ev){
var opt = ev.target.closest(".cc-option");
if (opt){
var box = opt.closest("[data-cc-combobox]");
box.querySelector("input[name=\"phoneCountry\"]").value = opt.getAttribute("data-code");
box.querySelector(".cc-search").value = opt.textContent.trim();
ccClose(); return;
}
var s = ev.target.closest(".cc-search");
if (s){ var b = s.closest("[data-cc-combobox]"); ccClose(b.querySelector(".cc-options")); ccOpen(b, true); s.select(); return; }
if (!ev.target.closest("[data-cc-combobox]")) ccClose();
});
w.addEventListener("keydown", function(ev){
var s = ev.target.closest(".cc-search"); if (!s) return;
if (ev.key === "Escape") { ccClose(); return; }
if (ev.key === "Enter"){
ev.preventDefault();
var box = s.closest("[data-cc-combobox]");
var first = box.querySelector(".cc-option:not(.hidden)");
if (first){
box.querySelector("input[name=\"phoneCountry\"]").value = first.getAttribute("data-code");
s.value = first.textContent.trim(); ccClose();
}
}
});
// ── "Manage your booking" (localStorage-backed) ───────────────
// After a successful booking, an inline script in the confirmation
// partial persists {uid, when, email, savedAt} to localStorage under
// a STABLE key derived from this widget's username + event type (NOT
// the per-render blockId, which changes each page render). On a later
// visit we read it back here and show a panel offering Cancel / redo,
// hiding the date grid so the visitor manages the existing booking
// instead of accidentally double-booking.
//
// SECURITY: the UID is Cal.com's own unguessable booking identifier
// and lives only in THIS visitor's localStorage — the same capability
// model as Cal.com's emailed cancel link. The server rate-limits the
// cancel POST by IP as the abuse guard.
function bookingKey(){
return "calcom:booking:" + (w.getAttribute("data-username") || "") + ":" + (w.getAttribute("data-event-type") || "");
}
function readSavedBooking(){
try {
var raw = window.localStorage.getItem(bookingKey());
if (!raw) return null;
var b = JSON.parse(raw);
return (b && b.uid) ? b : null;
} catch(e) { return null; }
}
function clearSavedBooking(){
try { window.localStorage.removeItem(bookingKey()); } catch(e) {}
}
var savedPanel = w.querySelector("[data-calcom-saved]");
var dateGrid = w.querySelector(".calcom-date-picker");
function showGrid(){
if (dateGrid) dateGrid.classList.remove("hidden");
if (savedPanel) savedPanel.classList.add("hidden");
}
function showSavedPanel(b){
if (!savedPanel) return;
var whenEl = savedPanel.querySelector("[data-calcom-saved-when]");
if (whenEl) whenEl.textContent = b.when || "";
var emailEl = savedPanel.querySelector("[data-calcom-saved-email]");
if (emailEl) emailEl.textContent = b.email || "";
// Populate the Cancel form's hidden uid so its HTMX POST carries it.
var uidInput = savedPanel.querySelector("input[name=\"uid\"]");
if (uidInput) uidInput.value = b.uid || "";
savedPanel.classList.remove("hidden");
if (dateGrid) dateGrid.classList.add("hidden");
}
function refreshSavedPanel(){
var b = readSavedBooking();
if (b) { showSavedPanel(b); } else { showGrid(); }
}
// Reschedule / redo: cancel the existing booking (so the visitor
// can't double-book), then reveal the grid to book afresh. The
// Cancel form already POSTs /cancel and its afterRequest handler
// clears storage + restores the grid on success, so "redo" just
// triggers that same cancel — the restored grid IS the rebook UI.
var redoBtn = savedPanel ? savedPanel.querySelector("[data-calcom-redo]") : null;
if (redoBtn) {
redoBtn.addEventListener("click", function(){
var cancelBtn = savedPanel.querySelector("[data-calcom-cancel-submit]");
if (cancelBtn) cancelBtn.click();
});
}
// When the Cancel POST succeeds, the server swaps a "cancelled"
// message into #calcom-confirm. Clear the localStorage key, hide the
// saved panel, and re-show the date grid (only CSS-hidden while the
// panel was up — never removed — so the visitor can rebook) so a
// reload no longer shows the saved panel.
w.addEventListener("htmx:afterRequest", function(ev){
var d = ev.detail || {};
var path = (d.requestConfig && d.requestConfig.path) || "";
if (path.indexOf("/api/plugins/calcomblock/cancel") === -1) return;
if (d.successful) {
clearSavedBooking();
showGrid(); // re-show the grid + hide the saved panel
}
});
refreshSavedPanel();
}
// init() queries elements parsed after this script — defer until
// the document is complete. (w is captured outside the callback:
// document.currentScript is null by the time it fires.)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
<input type="hidden" name="locale" value=""/>
// Admin-authored fallback copy for a non-fixable booking failure
// (Tier-2). Stamped once from block content here and hx-included into
// the POST /book request (like locale) so HandleCreateBooking can read
// it back. templ auto-escapes the value. Defaults to
// DefaultUnavailableMessage when the admin leaves it blank (resolved in
// block.go), so it is never empty.
<input type="hidden" name="unavailableMessage" value={ config.UnavailableMessage }/>
if config.Title != "" {
<h3 class="text-2xl font-bold text-foreground mb-2">{ config.Title }</h3>
}
if config.Description != "" {
<p class="text-muted-foreground mb-6">{ config.Description }</p>
}
// Step indicator — wrapped in a stable container so partial responses
// can replace its contents via hx-swap-oob.
<div id={ fmt.Sprintf("calcom-steps-%s", config.BlockID) } class="mb-6">
@calcomStepIndicator(1)
</div>
if config.ShowTimezone {
<div class="text-xs text-muted-foreground mb-4 flex items-center gap-1.5 flex-wrap">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M3 12h18M12 3a14 14 0 010 18M12 3a14 14 0 000 18"></path>
</svg>
<span>All times shown in</span>
<span class="font-medium text-foreground">{ config.TimeZone }</span>
</div>
}
// Saved-booking panel — normally hidden. The widget script reveals it on
// DOMContentLoaded when this visitor's localStorage holds a booking for
// this username/event, hiding the date grid below so they manage the
// existing booking (cancel / redo) instead of double-booking.
@calcomSavedBooking(config.BlockID)
// Date picker section — wrapped so confirmation can OOB-clear it.
<div
id={ fmt.Sprintf("calcom-date-grid-%s", config.BlockID) }
class="calcom-date-picker"
>
@calcomDateGrid(config)
</div>
// Time slots section — populated after a date click.
<div
id={ fmt.Sprintf("calcom-slots-%s", config.BlockID) }
class="calcom-time-slots mt-6"
aria-live="polite"
aria-busy="false"
></div>
// Booking form section — populated after a slot click.
<div
id={ fmt.Sprintf("calcom-form-%s", config.BlockID) }
class="calcom-booking-form"
aria-live="polite"
aria-busy="false"
></div>
// Confirmation section — populated after successful booking.
<div
id={ fmt.Sprintf("calcom-confirm-%s", config.BlockID) }
class="calcom-confirmation"
aria-live="polite"
aria-busy="false"
></div>
// Central loading indicator shown during HTMX requests.
<div
id={ fmt.Sprintf("calcom-loading-%s", config.BlockID) }
class="htmx-indicator flex items-center justify-center py-8"
role="status"
aria-label="Loading"
>
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</div>
}
// calcomStepIndicator renders the three-step progress indicator. `active` is
// the user's current step (1 = Select Date, 2 = Select Time, 3 = Confirm,
// 4 = Done — all three checkmarked). When active > n the step is rendered as
// completed; when active == n it is current; when active < n it is upcoming.
templ calcomStepIndicator(active int) {
<div class="flex items-center gap-2 flex-wrap text-sm">
@calcomStep(1, "Select Date", active)
<div class="h-px flex-1 min-w-[1rem] bg-border"></div>
@calcomStep(2, "Select Time", active)
<div class="h-px flex-1 min-w-[1rem] bg-border"></div>
@calcomStep(3, "Confirm", active)
</div>
}
// calcomStep renders one step pill inside the progress indicator.
templ calcomStep(n int, label string, active int) {
<div class={
"flex items-center gap-2",
templ.KV("opacity-50", active < n),
}>
<span class={
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium shrink-0",
templ.KV("bg-primary text-primary-foreground", active >= n),
templ.KV("bg-muted text-muted-foreground", active < n),
}>
if active > n {
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
} else {
{ strconv.Itoa(n) }
}
</span>
<span class={
"hidden sm:inline",
templ.KV("text-foreground font-medium", active == n),
templ.KV("text-muted-foreground", active != n),
}>{ label }</span>
</div>
}
// calcomStepIndicatorOOB wraps calcomStepIndicator in the OOB-swap envelope
// so partial responses can update the indicator out-of-band.
templ calcomStepIndicatorOOB(blockID string, active int) {
<div id={ fmt.Sprintf("calcom-steps-%s", blockID) } class="mb-6" hx-swap-oob="true">
@calcomStepIndicator(active)
</div>
}
// calcomClearRegion emits an empty container with the given ID and an OOB
// swap directive, used to wipe stale regions when the user transitions
// between flow steps (Change date / Change time / post-confirmation).
templ calcomClearRegion(id, extraClasses string) {
<div id={ id } class={ extraClasses } aria-live="polite" aria-busy="false" hx-swap-oob="innerHTML"></div>
}
// calcomDateGrid renders the calendar date picker. The prev/next buttons fire
// hx-get against /api/plugins/calcomblock/date-grid, which returns a fresh
// instance of this template scoped to a new WeekStart — innerHTML-swapped
// into the #calcom-date-grid-{id} container in the widget root.
templ calcomDateGrid(config BookingConfig) {
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
if config.CanGoBack {
<button
type="button"
aria-label="Previous dates"
class="p-1.5 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/date-grid?blockId=%s&username=%s&eventType=%s&weeks=%d&weekStart=%s",
config.BlockID, config.Username, config.EventTypeSlug, config.WeeksToShow, prevWeekStart(config.WeekStart, config.WeeksToShow, config.Today)) }
hx-target={ fmt.Sprintf("#calcom-date-grid-%s", config.BlockID) }
hx-swap="innerHTML"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</button>
} else {
<span class="p-1.5 w-7 h-7" aria-hidden="true"></span>
}
<span class="text-sm font-medium text-foreground" aria-live="polite">{ config.RangeLabel }</span>
<button
type="button"
aria-label="Next dates"
class="p-1.5 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/date-grid?blockId=%s&username=%s&eventType=%s&weeks=%d&weekStart=%s",
config.BlockID, config.Username, config.EventTypeSlug, config.WeeksToShow, nextWeekStart(config.WeekStart, config.WeeksToShow)) }
hx-target={ fmt.Sprintf("#calcom-date-grid-%s", config.BlockID) }
hx-swap="innerHTML"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
<div class="grid grid-cols-7 gap-1 text-center" role="row">
for _, day := range []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} {
<div class="text-xs font-medium text-muted-foreground py-2" role="columnheader">
{ day }
</div>
}
</div>
<div class="grid grid-cols-7 gap-1" role="grid" aria-label="Booking date picker">
// Offset the first date to its weekday column. Without these pad
// cells a mid-week window pours in at the "Sun" column and every
// date renders under the wrong header — the second bidbuddy
// incident (2026-06-10): tapped the cell under "Tue", got Friday.
for i, pad := 0, leadingBlanks(config.Dates); i < pad; i++ {
<div class="calcom-date-pad" aria-hidden="true"></div>
}
for _, date := range config.Dates {
if date.IsPast {
<button
type="button"
disabled
aria-disabled="true"
class="flex aspect-square w-full items-center justify-center rounded-lg font-medium text-muted-foreground opacity-40 cursor-not-allowed"
role="gridcell"
>
<span class="text-base sm:text-lg">{ date.DayOfMonth }</span>
</button>
} else {
<button
type="button"
role="gridcell"
class={
// aspect-square + flex centring (not fixed p-3): cells track
// the column width, so a narrow phone container yields small
// squares instead of 25×52 stretched pills (iOS report,
// 2026-06-10 21:52).
"flex aspect-square w-full items-center justify-center rounded-lg font-medium transition-all",
"hover:bg-primary hover:text-primary-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:ring-2 aria-pressed:ring-primary",
templ.KV("bg-primary/10 text-primary ring-1 ring-primary", date.IsToday && !date.IsSelected),
templ.KV("text-foreground", !date.IsToday && !date.IsSelected),
}
aria-pressed={ fmt.Sprintf("%t", date.IsSelected) }
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/slots?username=%s&eventType=%s&date=%s&blockId=%s",
config.Username, config.EventTypeSlug, date.Date, config.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", config.BlockID) }
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", config.BlockID) }
>
<span class="text-base sm:text-lg">{ date.DayOfMonth }</span>
</button>
}
}
</div>
</div>
}
// CalcomTimeSlots renders available time slots for a selected date. Returned
// as the body of GET /slots; also emits OOB updates for the step indicator
// (active=2) and an OOB clear of the form region so a previously-rendered
// form for an earlier slot doesn't linger.
templ CalcomTimeSlots(slots []TimeSlot, config SlotConfig) {
<div class="border-t border-border pt-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h4 class="text-lg font-semibold text-foreground">
Available times for { config.SelectedDate }
</h4>
<button
type="button"
class="text-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/reset?blockId=%s", config.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", config.BlockID) }
hx-swap="innerHTML"
>
← Change date
</button>
</div>
if len(slots) == 0 {
<p class="text-muted-foreground py-4">No available times for this date. Please select another date.</p>
} else {
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2" role="grid" aria-label="Available time slots">
for _, slot := range slots {
<button
type="button"
role="gridcell"
class="px-4 py-4 border border-border rounded-lg text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/form?blockId=%s&start=%s&date=%s&username=%s&eventType=%s",
config.BlockID, slot.Start, config.SelectedDate, config.Username, config.EventType) }
hx-target={ fmt.Sprintf("#calcom-form-%s", config.BlockID) }
hx-swap="innerHTML"
hx-include={ fmt.Sprintf("#calcom-widget-%s input[name='locale']", config.BlockID) }
>
{ slot.DisplayTime }
</button>
}
</div>
}
</div>
// OOB: advance step indicator to "Select Time" + clear any stale form
// from a previous slot selection.
@calcomStepIndicatorOOB(config.BlockID, 2)
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", config.BlockID), "calcom-booking-form")
}
// CalcomBookingForm renders the contact form for booking. When the event type
// has custom bookingFields configured, each is rendered via bookingField. When
// the list is empty (typical for default event types or a transient fetch
// error), a built-in name + email pair is rendered so the flow still works.
//
// Returned as the body of GET /form. Also emits an OOB update advancing the
// step indicator to "Confirm".
templ CalcomBookingForm(formConfig FormConfig) {
<div class="border-t border-border pt-6 mt-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<div>
<h4 class="text-lg font-semibold text-foreground" id={ fmt.Sprintf("booking-summary-%s", formConfig.BlockID) }>Your Details</h4>
<p class="text-sm text-muted-foreground" id={ fmt.Sprintf("booking-when-%s", formConfig.BlockID) }>
Booking for { formConfig.SelectedDate } at { formConfig.SelectedTime }
</p>
</div>
<button
type="button"
class="text-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/slots?username=%s&eventType=%s&date=%s&blockId=%s",
formConfig.Username, formConfig.EventType, formConfig.SelectedDate, formConfig.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", formConfig.BlockID) }
hx-swap="innerHTML"
>
← Change time
</button>
</div>
// hx-include pulls the widget-level hidden inputs into the POST: locale
// (phone dial-code inference) and unavailableMessage (admin-authored
// Tier-2 copy). Both live in the widget root, outside this form partial.
<form
hx-post="/api/plugins/calcomblock/book"
hx-target={ fmt.Sprintf("#calcom-confirm-%s", formConfig.BlockID) }
hx-swap="innerHTML"
hx-include={ fmt.Sprintf("#calcom-widget-%s input[name='locale'], #calcom-widget-%s input[name='unavailableMessage']", formConfig.BlockID, formConfig.BlockID) }
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", formConfig.BlockID) }
hx-disabled-elt="find button[type='submit']"
class="space-y-4"
>
<input type="hidden" name="blockId" value={ formConfig.BlockID }/>
<input type="hidden" name="username" value={ formConfig.Username }/>
<input type="hidden" name="eventType" value={ formConfig.EventType }/>
<input type="hidden" name="start" value={ formConfig.StartTime }/>
// Honeypot: a real user can't see this off-screen input; bots and
// over-eager autofill heuristics still hit it. The name is
// deliberately non-semantic so Chrome's autofill doesn't fire on
// "company" / "address" / "website".
<input
type="text"
name="bn_message_extra"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class="absolute -left-[9999px] h-0 w-0 overflow-hidden"
value=""
/>
if len(formConfig.BookingFields) > 0 {
for _, f := range formConfig.BookingFields {
@bookingField(f, formConfig.BlockID, formConfig.DefaultCountryCode)
}
} else {
@bookingField(BookingField{Name: "name", Label: "Name", Type: "name", Required: true, Placeholder: "Your name"}, formConfig.BlockID, "")
@bookingField(BookingField{Name: "email", Label: "Email", Type: "email", Required: true, Placeholder: "your@email.com"}, formConfig.BlockID, "")
}
// Cap proof-of-work widget — rendered only when the block enables
// captcha. On solve it injects a hidden cap-token field into this form,
// which HandleCreateBooking verifies (server-authoritative: same flag).
if formConfig.CaptchaEnabled {
<div class="pt-1">
@CaptchaWidget()
</div>
}
<button
type="submit"
aria-describedby={ fmt.Sprintf("booking-when-%s", formConfig.BlockID) }
class="w-full bg-primary text-primary-foreground px-6 py-3 rounded-lg font-medium hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-60 disabled:cursor-wait"
>
Confirm Booking
</button>
</form>
</div>
// OOB: advance step indicator to "Confirm".
@calcomStepIndicatorOOB(formConfig.BlockID, 3)
}
// bookingField renders a single Cal.com bookingField in the correct widget for
// its type. Unknown types fall back to a text input. defaultCountry is the ISO
// region pre-selected in the phone-field country combobox ("" = none).
templ bookingField(f BookingField, blockID string, defaultCountry string) {
<div class="space-y-2">
<label for={ fmt.Sprintf("%s-%s", f.Name, blockID) } class="block text-sm font-medium text-foreground">
{ f.Label }
if f.Required {
<span class="text-destructive" aria-label="required">*</span>
}
</label>
switch f.Type {
case "textarea":
<textarea
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
rows="3"
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent resize-none"
>{ f.DefaultValue }</textarea>
case "boolean", "checkbox":
<div class="flex items-center gap-2">
<input
type="checkbox"
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value="true"
if f.Required {
required
aria-required="true"
}
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring"
/>
<span class="text-sm text-muted-foreground">{ f.Placeholder }</span>
</div>
case "select":
<select
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
if f.Placeholder != "" {
<option value="">{ f.Placeholder }</option>
}
for _, opt := range f.Options {
<option value={ opt.Value } selected?={ opt.Value == f.DefaultValue }>{ opt.Label }</option>
}
</select>
case "multiselect":
<div class="space-y-1" role="group" aria-label={ f.Label }>
for _, opt := range f.Options {
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
name={ f.Name }
value={ opt.Value }
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring"
/>
{ opt.Label }
</label>
}
</div>
case "radio":
<div class="space-y-1" role="radiogroup" aria-label={ f.Label }>
for _, opt := range f.Options {
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name={ f.Name }
value={ opt.Value }
checked?={ opt.Value == f.DefaultValue }
if f.Required {
required
aria-required="true"
}
class="h-4 w-4 border-border text-primary focus:ring-2 focus:ring-ring"
/>
{ opt.Label }
</label>
}
</div>
case "phone":
<div class="flex gap-2">
<div class="relative w-40 sm:w-48 shrink-0" data-cc-combobox>
<input type="hidden" name="phoneCountry" value={ defaultCountry }/>
<input
type="text"
class="cc-search w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
role="combobox"
aria-expanded="false"
aria-label="Phone country code"
autocomplete="off"
placeholder="Country"
value={ countryLabel(defaultCountry) }
/>
<ul class="cc-options hidden absolute z-50 mt-1 max-h-60 w-64 overflow-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg" role="listbox" aria-label="Select country">
for _, c := range countries {
<li
class="cc-option px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
role="option"
data-code={ c.Code }
data-dial={ c.Dial }
aria-selected={ fmt.Sprintf("%t", c.Code == defaultCountry) }
>
{ countryFlag(c.Code) } { c.Name } ({ c.Dial })
</li>
}
</ul>
</div>
<input
type="tel"
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value={ f.DefaultValue }
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
/>
</div>
default:
<input
type={ inputTypeFor(f.Type) }
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value={ f.DefaultValue }
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
/>
}
</div>
}
// CalcomConfirmation renders the success panel after a booking is created.
// Returned as the body of POST /book; also emits OOB clears that wipe the
// date grid + slots + form so the visitor is presented with only the
// confirmation (preventing accidental double-submits) and an OOB step-indicator
// update marking all three steps complete.
templ CalcomConfirmation(result BookingResult, blockID string) {
<div class="mt-6 text-center p-8 bg-success/10 border border-success/20 rounded-xl">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-success/15 mb-4">
<svg class="h-8 w-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h4 class="text-xl font-semibold text-foreground mb-2">Booking Confirmed!</h4>
<p class="text-muted-foreground mb-4">
Your appointment has been scheduled for { result.FormattedDateTime }.
</p>
<p class="text-sm text-muted-foreground">
A confirmation email has been sent to <span class="font-medium">{ result.Email }</span>.
</p>
if result.MeetingLink != "" {
<a
href={ templ.SafeURL(result.MeetingLink) }
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 mt-4 text-primary hover:underline"
>
<span>Join meeting</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
}
// Machine-readable booking identity for the "manage your booking" feature.
// Hidden from view; the script below reads it and persists the booking to
// this visitor's localStorage so they can cancel / redo it on a later
// visit. templ auto-escapes every attribute value.
<div
data-calcom-booking
data-uid={ result.BookingID }
data-when={ result.FormattedDateTime }
data-email={ result.Email }
hidden
></div>
// Persist {uid, when, email, savedAt} under a STABLE key derived from the
// widget root's data-username + data-event-type (NOT the per-render
// blockId). HTMX evaluates this script when it swaps the confirmation in.
// Skips when uid is empty — the honeypot path renders a fake confirmation
// with no UID, which must never poison the saved-booking panel.
<script>
(function(){
var s = document.currentScript;
if (!s) return;
var holder = s.parentElement ? s.parentElement.querySelector("[data-calcom-booking]") : null;
var w = s.closest ? s.closest(".calcom-booking-widget") : null;
if (!holder || !w) return;
var uid = holder.getAttribute("data-uid") || "";
if (!uid) return; // honeypot / no real booking — do not persist
var key = "calcom:booking:" + (w.getAttribute("data-username") || "") + ":" + (w.getAttribute("data-event-type") || "");
try {
window.localStorage.setItem(key, JSON.stringify({
uid: uid,
when: holder.getAttribute("data-when") || "",
email: holder.getAttribute("data-email") || "",
savedAt: new Date().toISOString()
}));
} catch(e) {}
})();
</script>
</div>
// OOB: mark all three steps complete and clear the upstream regions so
// the visitor cannot re-submit the form (prevents the double-booking
// footgun the adversarial review caught).
@calcomStepIndicatorOOB(blockID, 4)
@calcomClearRegion(fmt.Sprintf("calcom-date-grid-%s", blockID), "calcom-date-picker")
@calcomClearRegion(fmt.Sprintf("calcom-slots-%s", blockID), "calcom-time-slots mt-6")
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", blockID), "calcom-booking-form")
}
// calcomSavedBooking renders the normally-hidden "manage your booking" panel.
// The widget script un-hides it (and hides the date grid) on DOMContentLoaded
// when this visitor's localStorage holds a booking for this username/event,
// filling in the date/time + email from storage. It offers two actions:
//
// - Cancel: an HTMX form POSTing /cancel with a hidden `uid` the script
// populates from localStorage; on a successful swap the script clears the
// storage key (htmx:afterRequest listener in the widget script).
// - Reschedule (redo): cancels the existing booking then reveals the grid to
// rebook — wired in the widget script to trigger the same Cancel POST, so
// the visitor can't end up double-booked.
//
// Cancellation is authorized by the Cal.com booking UID alone (the same
// capability model as Cal.com's emailed cancel link); the server rate-limits
// the POST by IP as the abuse guard.
templ calcomSavedBooking(blockID string) {
<div
id={ fmt.Sprintf("calcom-saved-%s", blockID) }
data-calcom-saved
class="calcom-saved-booking hidden mt-2 p-4 sm:p-6 bg-muted/40 border border-border rounded-xl"
>
<div class="flex items-start gap-3">
<div class="inline-flex items-center justify-center w-10 h-10 shrink-0 rounded-full bg-primary/10 text-primary">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div class="min-w-0">
<h4 class="text-lg font-semibold text-foreground">You have a booking</h4>
<p class="text-sm text-muted-foreground mt-0.5">
<span data-calcom-saved-when class="font-medium text-foreground"></span>
</p>
<p class="text-xs text-muted-foreground mt-0.5">
Confirmation sent to <span data-calcom-saved-email class="font-medium"></span>
</p>
</div>
</div>
<div class="flex flex-wrap gap-3 mt-4">
// Cancel: HTMX form whose hidden uid the widget script fills from
// localStorage. Targets the confirm region; on success the script
// clears the localStorage key.
<form
hx-post="/api/plugins/calcomblock/cancel"
hx-target={ fmt.Sprintf("#calcom-confirm-%s", blockID) }
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", blockID) }
hx-disabled-elt="find button[type='submit']"
>
<input type="hidden" name="blockId" value={ blockID }/>
<input type="hidden" name="uid" value=""/>
<button
type="submit"
data-calcom-cancel-submit
data-action="cancel"
data-entity="booking"
class="inline-flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-60 disabled:cursor-wait"
>
Cancel booking
</button>
</form>
// Reschedule / redo: cancels the existing booking then reveals the
// grid to book again (handled in the widget script).
<button
type="button"
data-calcom-redo
data-action="reschedule"
data-entity="booking"
class="inline-flex items-center gap-2 border border-border text-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
Reschedule
</button>
</div>
</div>
}
// CalcomCancelled is the body returned by POST /cancel on success. It swaps a
// "your booking was cancelled" message into #calcom-confirm-{id} and rewinds
// the step indicator to step 1. The date grid is restored client-side: when
// cancel fires from the saved panel the grid is only CSS-hidden (never removed),
// so the widget script's htmx:afterRequest listener re-shows it (showGrid) —
// avoiding a server-side grid re-render that would need the booking config the
// cancel POST doesn't carry. That same listener also clears the saved booking
// from localStorage and hides the saved panel.
templ CalcomCancelled(blockID string) {
<div class="mt-6 text-center p-8 bg-muted/40 border border-border rounded-xl" role="status">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted mb-4">
<svg class="h-8 w-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h4 class="text-xl font-semibold text-foreground mb-2">Booking cancelled</h4>
<p class="text-muted-foreground">
Your booking has been cancelled. You can book another time below whenever you're ready.
</p>
</div>
// OOB: rewind the step indicator to step 1 (the grid is re-shown client-side).
@calcomStepIndicatorOOB(blockID, 1)
}
// CalcomReset is the body returned by GET /reset — empty slots region with
// an OOB clear of the form (so the user pressing "Change date" loses any
// stale form rendered from an earlier slot click) and an OOB rewind of the
// step indicator to step 1.
templ CalcomReset(blockID string) {
// Main response body — empty, replaces #calcom-slots-{id}.
// OOB: clear the form region and rewind the step indicator.
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", blockID), "calcom-booking-form")
@calcomStepIndicatorOOB(blockID, 1)
}
// CalcomError renders an error message.
templ CalcomError(message string, blockID string, canRetry bool) {
<div class="mt-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg" role="alert">
<p class="text-sm text-destructive">{ message }</p>
if canRetry {
<button
type="button"
class="mt-3 text-sm text-destructive hover:underline focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/reset?blockId=%s", blockID) }
hx-target={ fmt.Sprintf("#calcom-widget-%s", blockID) }
hx-swap="outerHTML"
>
Try again
</button>
}
</div>
}

115
booking_errors.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"errors"
"strings"
)
// DefaultUnavailableMessage is the Tier-2 fallback shown when an online booking
// can't be completed for a reason the visitor can't fix (our auth/config, a
// Cal.com 5xx, a network timeout, or anything we don't recognise). It is the
// default both for the admin-authored block field (booking.templ / the editor's
// textarea default) AND for the handler's fallback when that field is left
// blank, so the two never drift. Admins can override it per block.
const DefaultUnavailableMessage = "Sorry — we couldn't complete your booking online. Please use our contact form and we'll get you booked in."
// Tier-1 curated, user-actionable booking-error copy. Each names the actual
// problem the visitor can act on. The raw Cal.com message is NEVER surfaced —
// these strings are all the visitor ever sees. canRetry decides whether the
// error renders with the "Try again" reset control (true only when picking a
// different slot/time is the fix).
const (
msgSlotUnavailable = "Sorry, that time isn't available anymore. Please choose another slot."
msgEventFullyBooked = "This event is fully booked right now. Please try a different time."
msgInvalidPhone = "That phone number doesn't look valid — please check it and try again."
msgInvalidGuestEmail = "One or more guest email addresses are invalid — please check them."
msgRejectedAnswer = "One of your answers wasn't accepted — please review the form and try again."
)
// bookingErrorResult is the curated, visitor-facing outcome of classifying a
// CreateBooking failure: the message to render and whether to offer the
// retry/reset control.
type bookingErrorResult struct {
message string
canRetry bool
}
// classifyBookingError maps a CreateBooking failure to curated visitor copy.
//
// Tier 1 (user-fixable) → a specific, actionable message, with canRetry=true
// where re-picking a slot/time is the fix. Tier 2 (our-side / non-fixable:
// auth/config 401-403, Cal.com 5xx, network/timeout, or anything unrecognised)
// → the admin-authored adminMessage (already defaulted to
// DefaultUnavailableMessage by the caller when blank).
//
// Cal.com's booking endpoint does NOT return a discriminating error.code — live
// probing (2026-06-21) showed every 400 carrying a generic "BadRequestException"
// or "BAD_REQUEST". The real signal is the message text, so classification is
// driven by case-insensitive substring matching on Status + Message, with Code
// only as a coarse gate. The raw Cal.com message never reaches the visitor.
func classifyBookingError(err error, adminMessage string) bookingErrorResult {
tier2 := bookingErrorResult{message: adminMessage, canRetry: false}
var apiErr *CalcomAPIError
if !errors.As(err, &apiErr) {
// Network/transport error (DNS, timeout, connection reset) — no HTTP
// response came back. Not the visitor's to fix.
return tier2
}
// 401/403/5xx are our side (bad/expired API key, Cal.com outage). Anything
// outside the user-error band (HTTP 400 / 409 / 422) is Tier 2 too.
switch {
case apiErr.Status == 401 || apiErr.Status == 403:
return tier2
case apiErr.Status >= 500:
return tier2
case apiErr.Status != 400 && apiErr.Status != 409 && apiErr.Status != 422:
return tier2
}
msg := strings.ToLower(apiErr.Message)
switch {
// Slot gone: in the past, already taken, host no longer available. All
// recoverable by choosing another slot → render WITH the reset control.
case containsAny(msg, "in the past", "not available", "no longer available",
"already has booking", "already booked", "slot", "no_available_users",
"already_booked", "booking_seats_full_error", "host"):
return bookingErrorResult{message: msgSlotUnavailable, canRetry: true}
// Event-level capacity exhausted (distinct copy; still time-recoverable).
case containsAny(msg, "booking_limit", "fully booked", "no more slots",
"limit reached", "exceeded", "sold out"):
return bookingErrorResult{message: msgEventFullyBooked, canRetry: true}
// Bad attendee phone number (Cal.com: "{attendeePhoneNumber} invalid_number").
case containsAny(msg, "invalid_number", "phone"):
return bookingErrorResult{message: msgInvalidPhone, canRetry: false}
// Invalid guest email(s). Live probe: "responses - {guests}email_validation_error".
case strings.Contains(msg, "guest"):
return bookingErrorResult{message: msgInvalidGuestEmail, canRetry: false}
// A rejected custom-field answer. "responses" / "bookingfields" signal a
// per-field validation rejection; render generic-but-actionable copy.
// (We do not echo Cal.com's field token — it isn't safe curated text.)
case containsAny(msg, "responses", "bookingfields", "booking field",
"required", "email_validation_error", "validation"):
return bookingErrorResult{message: msgRejectedAnswer, canRetry: false}
}
// Recognised as a 400-class API error but no signal we can map to specific,
// safe copy → admin-authored message (never Cal.com's raw text).
return tier2
}
// containsAny reports whether s contains any of the given (already
// lower-cased) substrings.
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}

120
booking_errors_test.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"errors"
"fmt"
"testing"
)
// TestClassifyBookingError_GroundedTaxonomy locks in the mapping against the
// REAL Cal.com error bodies captured by probing the live /book endpoint
// (2026-06-21). Each case feeds a *CalcomAPIError built from an actual response
// and asserts the curated visitor copy + retry affordance. The raw Cal.com
// message must never be the rendered string.
func TestClassifyBookingError_GroundedTaxonomy(t *testing.T) {
const adminMsg = "Call us on 1234."
cases := []struct {
name string
err error
wantMsg string
wantRtry bool
}{
{
// Live: status 400, BadRequestException: "Attempting to book a meeting in the past."
name: "past instant",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "Attempting to book a meeting in the past."},
wantMsg: msgSlotUnavailable,
wantRtry: true,
},
{
// Live: status 400, BadRequestException: "User either already has booking at this time or is not available"
name: "slot taken / host unavailable",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "User either already has booking at this time or is not available"},
wantMsg: msgSlotUnavailable,
wantRtry: true,
},
{
// Live: status 400, BAD_REQUEST: "responses - {guests}email_validation_error, "
name: "invalid guest email",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {guests}email_validation_error, "},
wantMsg: msgInvalidGuestEmail,
wantRtry: false,
},
{
// Cal.com phone rejection: "{attendeePhoneNumber} invalid_number".
name: "invalid phone number",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {attendeePhoneNumber}invalid_number"},
wantMsg: msgInvalidPhone,
wantRtry: false,
},
{
// A rejected custom-field answer (a required field / generic responses error
// that names no guest/phone signal).
name: "rejected custom field answer",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {favourite-colour} is required"},
wantMsg: msgRejectedAnswer,
wantRtry: false,
},
{
name: "event fully booked / booking limit",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "booking_limit reached for this event type"},
wantMsg: msgEventFullyBooked,
wantRtry: true,
},
{
// Live: status 404, NotFoundException — unmapped → Tier-2 admin message.
name: "not found (unmapped 4xx) → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 404, Code: "NotFoundException", Message: "Event type with slug nope not found"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "auth failure (401) → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 401, Code: "UnauthorizedException", Message: "Invalid API Key"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "cal.com 5xx → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 503, Code: "", Message: "service unavailable"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "network/transport error (not a CalcomAPIError) → admin message",
err: fmt.Errorf("post bookings: %w", errors.New("dial tcp: i/o timeout")),
wantMsg: adminMsg,
wantRtry: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyBookingError(tc.err, adminMsg)
if got.message != tc.wantMsg {
t.Errorf("message = %q, want %q", got.message, tc.wantMsg)
}
if got.canRetry != tc.wantRtry {
t.Errorf("canRetry = %v, want %v", got.canRetry, tc.wantRtry)
}
// The raw Cal.com message must never be the rendered copy.
var apiErr *CalcomAPIError
if errors.As(tc.err, &apiErr) && apiErr.Message != "" && got.message == apiErr.Message {
t.Errorf("rendered the raw Cal.com message verbatim: %q", got.message)
}
})
}
}
// TestClassifyBookingError_BlankAdminMessageIsCallerDefaulted documents that the
// classifier renders whatever adminMessage it is given for Tier-2 — the caller
// (HandleCreateBooking) substitutes DefaultUnavailableMessage when the form
// field is blank, so the two stay in sync via the single const.
func TestClassifyBookingError_BlankAdminMessageIsCallerDefaulted(t *testing.T) {
err := &CalcomAPIError{Endpoint: "booking", Status: 404, Message: "nope"}
got := classifyBookingError(err, DefaultUnavailableMessage)
if got.message != DefaultUnavailableMessage {
t.Errorf("message = %q, want DefaultUnavailableMessage", got.message)
}
}

2371
booking_templ.go Normal file

File diff suppressed because one or more lines are too long

36
captcha_widget.templ Normal file
View File

@ -0,0 +1,36 @@
package main
// CaptchaWidget renders the self-hosted Cap (trycap.dev) proof-of-work widget.
// When placed inside a <form>, the widget injects a hidden `cap-token` field on
// solve, which the form handler verifies against the instance's redeem endpoint.
//
// Vendored from the CMS built-in blocks.CaptchaWidget (cms/backend/blocks/
// captcha_widget.templ): a standalone wasm plugin cannot import the CMS-internal
// blocks package, but the widget markup is fully static (served from this
// instance's own origin), so it is reproduced here verbatim. Re-copy if the
// upstream widget changes.
templ CaptchaWidget() {
<script>
window.CAP_CUSTOM_WASM_URL = "/assets/captcha/cap_wasm_bg.wasm";
window.CAP_PAKO_URL = "/assets/captcha/pako_inflate.min.js";
</script>
<cap-widget
data-cap-api-endpoint="/api/captcha/"
data-cap-worker-count="2"
></cap-widget>
<script src="/assets/captcha/cap.min.js" defer></script>
<script>
(function(){
var s = document.currentScript;
var form = s && s.closest ? s.closest('form') : null;
if (!form) return;
// Reset the widget after each htmx submit so a single-use token that
// was already spent (verify succeeded, business logic may have failed)
// is cleared and the visitor can re-solve for a retry.
form.addEventListener('htmx:afterRequest', function(){
var w = form.querySelector('cap-widget');
if (w && typeof w.reset === 'function') w.reset();
});
})();
</script>
}

49
captcha_widget_templ.go Normal file
View File

@ -0,0 +1,49 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package main
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// CaptchaWidget renders the self-hosted Cap (trycap.dev) proof-of-work widget.
// When placed inside a <form>, the widget injects a hidden `cap-token` field on
// solve, which the form handler verifies against the instance's redeem endpoint.
//
// Vendored from the CMS built-in blocks.CaptchaWidget (cms/backend/blocks/
// captcha_widget.templ): a standalone wasm plugin cannot import the CMS-internal
// blocks package, but the widget markup is fully static (served from this
// instance's own origin), so it is reproduced here verbatim. Re-copy if the
// upstream widget changes.
func CaptchaWidget() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\twindow.CAP_CUSTOM_WASM_URL = \"/assets/captcha/cap_wasm_bg.wasm\";\n\t\twindow.CAP_PAKO_URL = \"/assets/captcha/pako_inflate.min.js\";\n\t</script><cap-widget data-cap-api-endpoint=\"/api/captcha/\" data-cap-worker-count=\"2\"></cap-widget><script src=\"/assets/captcha/cap.min.js\" defer></script><script>\n\t\t(function(){\n\t\t\tvar s = document.currentScript;\n\t\t\tvar form = s && s.closest ? s.closest('form') : null;\n\t\t\tif (!form) return;\n\t\t\t// Reset the widget after each htmx submit so a single-use token that\n\t\t\t// was already spent (verify succeeded, business logic may have failed)\n\t\t\t// is cleared and the visitor can re-solve for a retry.\n\t\t\tform.addEventListener('htmx:afterRequest', function(){\n\t\t\t\tvar w = form.querySelector('cap-widget');\n\t\t\t\tif (w && typeof w.reset === 'function') w.reset();\n\t\t\t});\n\t\t})();\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

604
client.go Normal file
View File

@ -0,0 +1,604 @@
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": "<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 "", ""
}

651
client_test.go Normal file
View File

@ -0,0 +1,651 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newFakeCalcomServer returns a httptest server whose handler is fn. The
// returned client is preconfigured to talk to it.
func newFakeCalcomServer(t *testing.T, fn http.HandlerFunc) (*httptest.Server, *CalcomClient) {
t.Helper()
srv := httptest.NewServer(fn)
t.Cleanup(srv.Close)
return srv, &CalcomClient{
http: srv.Client(),
apiKey: "test-key",
baseURL: srv.URL,
}
}
// TestGetSlots_URLAndHeaders verifies the request shape sent by GetSlots:
// path /v2/slots (or just /slots against our fake), correct query string, and
// the per-endpoint cal-api-version header.
func TestGetSlots_URLAndHeaders(t *testing.T) {
var gotPath string
var gotQuery string
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
_ = srv
_, err := c.GetSlots(context.Background(),
"alice", "30min",
"2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z",
"America/Toronto")
if err != nil {
t.Fatalf("GetSlots returned error: %v", err)
}
if gotPath != "/slots" {
t.Errorf("path: got %q, want /slots", gotPath)
}
for _, want := range []string{
"username=alice",
"eventTypeSlug=30min",
"start=2026-05-27T00%3A00%3A00Z",
"end=2026-05-28T00%3A00%3A00Z",
"timeZone=America%2FToronto",
} {
if !strings.Contains(gotQuery, want) {
t.Errorf("query missing %q (got: %q)", want, gotQuery)
}
}
if got := gotHeaders.Get("cal-api-version"); got != versionSlots {
t.Errorf("cal-api-version: got %q, want %q", got, versionSlots)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
}
// TestGetSlots_ParsesResponse asserts the Cal.com nested-by-date shape is
// decoded into SlotsResponse.Data correctly.
func TestGetSlots_ParsesResponse(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
// cal-api-version 2024-09-04 shape: each slot carries `start` (not the
// legacy `time` field). Millisecond precision mirrors the real wire.
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"2026-05-27":[{"start":"2026-05-27T13:00:00.000Z"},{"start":"2026-05-27T13:30:00.000Z"}],
"2026-05-28":[{"start":"2026-05-28T10:00:00.000Z"}]
}
}`)
})
_ = srv
got, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-29T00:00:00Z", "UTC")
if err != nil {
t.Fatalf("GetSlots: %v", err)
}
if len(got.Data["2026-05-27"]) != 2 {
t.Errorf("expected 2 slots for 2026-05-27, got %d", len(got.Data["2026-05-27"]))
}
if got.Data["2026-05-27"][0].When() != "2026-05-27T13:00:00.000Z" {
t.Errorf("first slot start mismatch: %q", got.Data["2026-05-27"][0].When())
}
if len(got.Data["2026-05-28"]) != 1 {
t.Errorf("expected 1 slot for 2026-05-28, got %d", len(got.Data["2026-05-28"]))
}
}
// TestGetSlots_LegacyTimeFieldFallback asserts that if Cal.com (or a rolled-back
// cal-api-version) replies with the older {"time":...} slot shape, SlotEntry.When()
// still resolves the timestamp. Guards the dual-shape decode that protects the
// widget from Cal.com's documented response-shape drift across API versions.
func TestGetSlots_LegacyTimeFieldFallback(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"2026-05-27":[{"time":"2026-05-27T13:00:00Z"}]}}`)
})
_ = srv
got, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z", "UTC")
if err != nil {
t.Fatalf("GetSlots: %v", err)
}
if len(got.Data["2026-05-27"]) != 1 {
t.Fatalf("expected 1 slot, got %d", len(got.Data["2026-05-27"]))
}
if got.Data["2026-05-27"][0].When() != "2026-05-27T13:00:00Z" {
t.Errorf("legacy time field not resolved by When(): %q", got.Data["2026-05-27"][0].When())
}
}
// TestListEventTypes_HeaderAndParsing asserts the event-types header and the
// EventTypeSummary parsing including lengthInMinutes.
func TestListEventTypes_HeaderAndParsing(t *testing.T) {
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header
_, _ = io.WriteString(w, `{
"status":"success",
"data":[
{"id":1,"slug":"30min","title":"30 Minute Meeting","lengthInMinutes":30},
{"id":2,"slug":"60min","title":"60 Minute Meeting","lengthInMinutes":60}
]
}`)
})
_ = srv
ets, err := c.ListEventTypes(context.Background(), "alice")
if err != nil {
t.Fatalf("ListEventTypes: %v", err)
}
if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
t.Errorf("cal-api-version: got %q, want %q", got, versionEventTypes)
}
if len(ets) != 2 {
t.Fatalf("expected 2 event types, got %d", len(ets))
}
if ets[0].LengthInMinutes != 30 {
t.Errorf("lengthInMinutes not parsed: %+v", ets[0])
}
if ets[1].Slug != "60min" {
t.Errorf("second slug wrong: %+v", ets[1])
}
}
// TestGetEventType_BookingFieldsParsing verifies the full EventType shape
// including custom bookingFields with options is parsed correctly.
func TestGetEventType_BookingFieldsParsing(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":42,
"slug":"intake",
"title":"Intake Call",
"lengthInMinutes":15,
"bookingFields":[
{"slug":"name","label":"Your name","type":"name","required":true},
{"slug":"company_size","label":"Company size","type":"select","required":true,
"options":[{"label":"1-10","value":"small"},{"label":"11-50","value":"medium"}]}
]
}]
}`)
})
_ = srv
et, err := c.GetEventType(context.Background(), "alice", "intake")
if err != nil {
t.Fatalf("GetEventType: %v", err)
}
if et.ID != 42 {
t.Errorf("ID: got %d, want 42", et.ID)
}
if len(et.BookingFields) != 2 {
t.Fatalf("expected 2 booking fields, got %d", len(et.BookingFields))
}
if et.BookingFields[1].Type != "select" || len(et.BookingFields[1].Options) != 2 {
t.Errorf("select field options not parsed: %+v", et.BookingFields[1])
}
if et.BookingFields[1].Options[0].Value != "small" {
t.Errorf("first option value wrong: %+v", et.BookingFields[1].Options[0])
}
}
// TestGetEventType_RealV2ShapeNormalizesFields pins the ACTUAL Cal.com v2
// /event-types bookingFields shape (cal-api-version 2024-06-14), which differs
// from what the plugin originally assumed:
//
// - the field identifier is "slug", NOT "name"
// - default/system fields return an empty "label" (Cal.com's own booker
// derives the visible label client-side from i18n keyed on the field type)
// - some system fields are "hidden":true and must not be rendered
//
// Decoding this with the old struct produced inputs with name="" and blank
// labels — invisible AND unsubmittable. GetEventType must normalize:
// slug→Name, a resolved label for system fields, and drop hidden fields.
func TestGetEventType_RealV2ShapeNormalizesFields(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,
"slug":"30min",
"title":"30 Min Meeting",
"lengthInMinutes":30,
"bookingFields":[
{"type":"name","slug":"name","required":true,"label":"","isDefault":true},
{"type":"email","slug":"email","required":true,"label":"","isDefault":true},
{"type":"phone","slug":"attendeePhoneNumber","required":true,"label":"","placeholder":"","isDefault":true},
{"type":"textarea","slug":"notes","required":false,"label":"","isDefault":true},
{"type":"text","slug":"company","required":false,"label":"Company name","isDefault":false},
{"type":"text","slug":"title","required":false,"label":"","hidden":true,"isDefault":true}
]
}]
}`)
})
_ = srv
et, err := c.GetEventType(context.Background(), "bidbuddy", "30min")
if err != nil {
t.Fatalf("GetEventType: %v", err)
}
// The hidden "title" system field must be dropped.
if len(et.BookingFields) != 5 {
t.Fatalf("expected 5 visible booking fields (hidden dropped), got %d: %+v", len(et.BookingFields), et.BookingFields)
}
want := []struct {
name string
label string
}{
{"name", "Name"},
{"email", "Email"},
{"attendeePhoneNumber", "Phone number"},
{"notes", "Additional notes"},
{"company", "Company name"}, // custom field: explicit label preserved
}
for i, w := range want {
got := et.BookingFields[i]
if got.Name != w.name {
t.Errorf("field %d Name: got %q, want %q (slug must populate Name)", i, got.Name, w.name)
}
if got.Label != w.label {
t.Errorf("field %d Label: got %q, want %q", i, got.Label, w.label)
}
}
}
// TestGetEventType_NotFound asserts a descriptive error is returned when the
// event type isn't found in the response.
func TestGetEventType_NotFound(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":[]}`)
})
_ = srv
_, err := c.GetEventType(context.Background(), "alice", "nope")
if err == nil {
t.Fatalf("expected error for missing event type")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %v", err)
}
}
// TestCreateBooking_RequestShape verifies the WO-008 body contract:
// uses eventTypeSlug + username (no eventTypeId); attendee has timeZone;
// bookingFieldsResponses carries custom fields; cal-api-version header is the
// bookings-specific 2026-02-25.
func TestCreateBooking_RequestShape(t *testing.T) {
var gotBody []byte
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"abc","rescheduleUrl":"https://cal.com/reschedule/abc","cancelUrl":"https://cal.com/booking/abc?cancel=true"}}`)
})
_ = srv
_, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "30min",
Username: "alice",
Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{
Name: "Bob", Email: "bob@example.com", TimeZone: "America/Toronto",
},
BookingFieldsResponses: map[string]any{"q1": "yes"},
})
if err != nil {
t.Fatalf("CreateBooking: %v", err)
}
if got := gotHeaders.Get("cal-api-version"); got != versionBookings {
t.Errorf("cal-api-version: got %q, want %q", got, versionBookings)
}
var parsed map[string]any
if err := json.Unmarshal(gotBody, &parsed); err != nil {
t.Fatalf("decode body: %v", err)
}
if parsed["eventTypeSlug"] != "30min" {
t.Errorf("eventTypeSlug missing/wrong: %v", parsed["eventTypeSlug"])
}
if parsed["username"] != "alice" {
t.Errorf("username missing/wrong: %v", parsed["username"])
}
if _, present := parsed["eventTypeId"]; present {
t.Errorf("body must NOT contain eventTypeId (old shape); body=%s", string(gotBody))
}
att, _ := parsed["attendee"].(map[string]any)
if att["timeZone"] != "America/Toronto" {
t.Errorf("attendee.timeZone wrong: %v", att["timeZone"])
}
resps, _ := parsed["bookingFieldsResponses"].(map[string]any)
if resps["q1"] != "yes" {
t.Errorf("bookingFieldsResponses.q1 wrong: %v", resps)
}
}
// TestCreateBooking_ParsesLocationAndUID verifies the 2026-02-25 response: the
// booking UID and the canonical `location` meeting URL parse, and MeetingLink()
// resolves to `location`. The response carries no reschedule/cancel URLs, so
// those fields were intentionally removed from BookingResponseData.
func TestCreateBooking_ParsesLocationAndUID(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{
"id":123,
"uid":"xyz",
"start":"2026-05-27T10:00:00Z",
"end":"2026-05-27T10:30:00Z",
"location":"https://meet.example.com/xyz"
}}`)
})
_ = srv
resp, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "30min", Username: "alice", Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{Name: "Bob", Email: "b@x.com", TimeZone: "UTC"},
})
if err != nil {
t.Fatalf("CreateBooking: %v", err)
}
if resp.Data.UID != "xyz" {
t.Errorf("UID: got %q, want xyz", resp.Data.UID)
}
if resp.Data.MeetingLink() != "https://meet.example.com/xyz" {
t.Errorf("MeetingLink: got %q, want the `location` URL", resp.Data.MeetingLink())
}
}
// TestBookingMeetingLink_Resolution covers MeetingLink()'s field precedence and
// the URL guard that prevents a physical-address/phone `location` (used by
// non-video event types) from being surfaced as a clickable link.
func TestBookingMeetingLink_Resolution(t *testing.T) {
cases := []struct {
name string
data BookingResponseData
want string
}{
{"location url preferred", BookingResponseData{Location: "https://a/x", MeetingURL: "https://b/y"}, "https://a/x"},
{"falls back to meetingUrl", BookingResponseData{Location: "", MeetingURL: "https://b/y"}, "https://b/y"},
{"non-url location is not a link", BookingResponseData{Location: "123 Main St, Perth", MeetingURL: ""}, ""},
{"non-url location falls back to meetingUrl", BookingResponseData{Location: "+61 8 1234 5678", MeetingURL: "https://b/y"}, "https://b/y"},
{"nothing", BookingResponseData{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.data.MeetingLink(); got != tc.want {
t.Errorf("MeetingLink() = %q, want %q", got, tc.want)
}
})
}
}
// TestCreateBooking_RecurringArrayData asserts that a recurring event type —
// whose response `data` is an ARRAY of occurrences — no longer hard-fails the
// booking. The first occurrence is surfaced (its UID + location).
func TestCreateBooking_RecurringArrayData(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":[
{"uid":"occ-1","start":"2026-05-27T10:00:00Z","location":"https://meet.example.com/occ-1"},
{"uid":"occ-2","start":"2026-06-03T10:00:00Z","location":"https://meet.example.com/occ-2"}
]}`)
})
_ = srv
resp, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "weekly", Username: "alice", Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{Name: "Bob", Email: "b@x.com", TimeZone: "UTC"},
})
if err != nil {
t.Fatalf("CreateBooking (recurring): %v", err)
}
if resp.Data.UID != "occ-1" {
t.Errorf("recurring booking should surface first occurrence UID; got %q", resp.Data.UID)
}
if resp.Data.MeetingLink() != "https://meet.example.com/occ-1" {
t.Errorf("recurring MeetingLink: got %q", resp.Data.MeetingLink())
}
}
// TestCancelBooking_RequestShape verifies CancelBooking sends the confirmed v2
// shape: POST /bookings/{uid}/cancel with the bookings cal-api-version, the
// Bearer token, and a {"cancellationReason": ...} body. The uid must be
// URL-path-escaped so a path-significant character can't break out of the route.
func TestCancelBooking_RequestShape(t *testing.T) {
var gotMethod, gotEscapedPath string
var gotHeaders http.Header
var gotBody []byte
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
// EscapedPath() preserves the on-the-wire escaping (r.URL.Path is decoded
// by the server, hiding whether the uid was escaped). This is what proves
// the uid was path-escaped and couldn't break out of the route.
gotEscapedPath = r.URL.EscapedPath()
gotHeaders = r.Header
gotBody, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"status":"success","data":{"id":1,"uid":"abc","status":"cancelled"}}`)
})
_ = srv
// A uid with a path-significant character (slash) must be escaped into a
// single path segment, not split the route.
if err := c.CancelBooking(context.Background(), "uid/with space", "Cancelled by attendee"); err != nil {
t.Fatalf("CancelBooking: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method: got %q, want POST", gotMethod)
}
if gotEscapedPath != "/bookings/uid%2Fwith%20space/cancel" {
t.Errorf("escaped path: got %q, want /bookings/uid%%2Fwith%%20space/cancel (uid path-escaped)", gotEscapedPath)
}
if got := gotHeaders.Get("cal-api-version"); got != versionBookings {
t.Errorf("cal-api-version: got %q, want %q", got, versionBookings)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
var parsed map[string]any
if err := json.Unmarshal(gotBody, &parsed); err != nil {
t.Fatalf("decode body: %v", err)
}
if parsed["cancellationReason"] != "Cancelled by attendee" {
t.Errorf("cancellationReason: got %v, want %q", parsed["cancellationReason"], "Cancelled by attendee")
}
}
// TestCancelBooking_NonOKReturnsAPIError confirms a non-2xx cancel response
// surfaces as a *CalcomAPIError carrying the status (so the handler can classify
// it / log it) and is never silently swallowed.
func TestCancelBooking_NonOKReturnsAPIError(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Booking not found"}}`)
})
_ = srv
err := c.CancelBooking(context.Background(), "missing-uid", "reason")
if err == nil {
t.Fatalf("expected error from 404 cancel response")
}
var apiErr *CalcomAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *CalcomAPIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusNotFound {
t.Errorf("status: got %d, want 404", apiErr.Status)
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should mention status code, got: %v", err)
}
}
// TestCancelBooking_OmitsReasonWhenEmpty confirms an empty reason still sends a
// well-formed body (cancellationReason is optional per the v2 docs; we always
// send the field, empty when no reason is supplied).
func TestCancelBooking_AcceptsEmptyReason(t *testing.T) {
var gotBody []byte
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"status":"success","data":{"status":"cancelled"}}`)
})
_ = srv
if err := c.CancelBooking(context.Background(), "abc", ""); err != nil {
t.Fatalf("CancelBooking with empty reason: %v", err)
}
if !strings.Contains(string(gotBody), `"cancellationReason":""`) {
t.Errorf("body should carry an (empty) cancellationReason field; got %s", string(gotBody))
}
}
// TestClient_ErrorResponseSanitization confirms 4xx responses surface as
// errors (not panics) and that the error string includes the status code so
// callers can branch on it.
func TestClient_ErrorResponseSanitization(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"unauthorized","message":"Invalid API key"}`)
})
_ = srv
_, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z", "UTC")
if err == nil {
t.Fatalf("expected error from 401 response")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error should mention status code, got: %v", err)
}
}
// TestClient_HandlesNetworkFailure ensures a connection-level error surfaces
// without panicking.
func TestClient_HandlesNetworkFailure(t *testing.T) {
c := &CalcomClient{http: &http.Client{}, apiKey: "k", baseURL: "http://127.0.0.1:1"}
_, err := c.GetSlots(context.Background(), "alice", "30min", "x", "y", "UTC")
if err == nil {
t.Fatalf("expected error from unreachable server")
}
}
// Compile-time guard: ensure SlotEntry retains BOTH timestamp fields. Catches
// accidental renames during refactors — the dual shape (2024-09-04 `start` plus
// legacy `time`) is what keeps the widget resilient to Cal.com version drift.
var _ = SlotEntry{Start: "", Time: ""}
// Compile-time guard for the legacy fmt import in test diagnostics.
var _ = ""
// TestGetEventType_SendsEventSlugQuery verifies that GetEventType sends BOTH
// `username=` and `eventSlug=` as query params. The existing booking-fields
// parsing test (TestGetEventType_BookingFieldsParsing) does not capture the
// outbound URL — without this assertion the client could silently regress to
// omitting one or both params and the broken request would still parse the
// stub response in tests.
func TestGetEventType_SendsEventSlugQuery(t *testing.T) {
var gotQuery string
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"intake","title":"x","lengthInMinutes":15,"bookingFields":[]}]}`)
})
_ = srv
if _, err := c.GetEventType(context.Background(), "alice", "intake"); err != nil {
t.Fatalf("GetEventType: %v", err)
}
if !strings.Contains(gotQuery, "eventSlug=intake") {
t.Errorf("outbound query missing eventSlug=intake (got: %q)", gotQuery)
}
if !strings.Contains(gotQuery, "username=alice") {
t.Errorf("outbound query missing username=alice (got: %q)", gotQuery)
}
}
// TestListEventTypes_SendsUsernameQuery verifies that ListEventTypes propagates
// the username argument to Cal.com as a query param. Without this, the editor
// dropdown would silently fetch the API-key owner's own event types instead of
// the user the admin selected.
func TestListEventTypes_SendsUsernameQuery(t *testing.T) {
var gotQuery string
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = io.WriteString(w, `{"status":"success","data":[]}`)
})
_ = srv
if _, err := c.ListEventTypes(context.Background(), "alice"); err != nil {
t.Fatalf("ListEventTypes: %v", err)
}
if !strings.Contains(gotQuery, "username=alice") {
t.Errorf("outbound query missing username=alice (got: %q)", gotQuery)
}
}
// TestGetMe_URLAndHeaders verifies the request shape sent by GetMe:
// path /me, no query string, and the cal-api-version header for the /me endpoint.
func TestGetMe_URLAndHeaders(t *testing.T) {
var gotPath string
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
})
_ = srv
_, err := c.GetMe(context.Background())
if err != nil {
t.Fatalf("GetMe returned error: %v", err)
}
if gotPath != "/me" {
t.Errorf("path: got %q, want /me", gotPath)
}
if got := gotHeaders.Get("cal-api-version"); got != versionMe {
t.Errorf("cal-api-version: got %q, want %q", got, versionMe)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
}
// TestGetMe_ParsesResponse confirms the {status, data:{username, email}} envelope
// is decoded into MeResponse correctly.
func TestGetMe_ParsesResponse(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
})
_ = srv
me, err := c.GetMe(context.Background())
if err != nil {
t.Fatalf("GetMe: %v", err)
}
if me.Username != "alice" {
t.Errorf("Username: got %q, want alice", me.Username)
}
if me.Email != "alice@example.com" {
t.Errorf("Email: got %q, want alice@example.com", me.Email)
}
}
// TestGetMe_BubblesUpstreamError ensures a non-2xx Cal.com response surfaces
// as an error from GetMe rather than a zero-value MeResponse.
func TestGetMe_BubblesUpstreamError(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"message":"invalid api key"}`)
})
_ = srv
_, err := c.GetMe(context.Background())
if err == nil {
t.Fatal("expected error on 401, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error should mention 401, got: %v", err)
}
}

257
countries.go Normal file
View File

@ -0,0 +1,257 @@
package main
// Country is one ITU dialing entry for the booking phone country selector.
type Country struct {
Name string
Code string // ISO 3166-1 alpha-2
Dial string // E.164 calling code, e.g. "+61"
}
// countries is the full country list (sorted by name) rendered in the phone
// country combobox, and the source of truth for country lookups (countryByCode,
// countryLabel). Generated from the public CountryCodes dataset
// (gist anubhavshrimal/75f6183458db8c453306f93521e93d37).
var countries = []Country{
{Name: "Afghanistan", Code: "AF", Dial: "+93"},
{Name: "Aland Islands", Code: "AX", Dial: "+358"},
{Name: "Albania", Code: "AL", Dial: "+355"},
{Name: "Algeria", Code: "DZ", Dial: "+213"},
{Name: "AmericanSamoa", Code: "AS", Dial: "+1684"},
{Name: "Andorra", Code: "AD", Dial: "+376"},
{Name: "Angola", Code: "AO", Dial: "+244"},
{Name: "Anguilla", Code: "AI", Dial: "+1264"},
{Name: "Antarctica", Code: "AQ", Dial: "+672"},
{Name: "Antigua and Barbuda", Code: "AG", Dial: "+1268"},
{Name: "Argentina", Code: "AR", Dial: "+54"},
{Name: "Armenia", Code: "AM", Dial: "+374"},
{Name: "Aruba", Code: "AW", Dial: "+297"},
{Name: "Australia", Code: "AU", Dial: "+61"},
{Name: "Austria", Code: "AT", Dial: "+43"},
{Name: "Azerbaijan", Code: "AZ", Dial: "+994"},
{Name: "Bahamas", Code: "BS", Dial: "+1242"},
{Name: "Bahrain", Code: "BH", Dial: "+973"},
{Name: "Bangladesh", Code: "BD", Dial: "+880"},
{Name: "Barbados", Code: "BB", Dial: "+1246"},
{Name: "Belarus", Code: "BY", Dial: "+375"},
{Name: "Belgium", Code: "BE", Dial: "+32"},
{Name: "Belize", Code: "BZ", Dial: "+501"},
{Name: "Benin", Code: "BJ", Dial: "+229"},
{Name: "Bermuda", Code: "BM", Dial: "+1441"},
{Name: "Bhutan", Code: "BT", Dial: "+975"},
{Name: "Bolivia, Plurinational State of", Code: "BO", Dial: "+591"},
{Name: "Bosnia and Herzegovina", Code: "BA", Dial: "+387"},
{Name: "Botswana", Code: "BW", Dial: "+267"},
{Name: "Brazil", Code: "BR", Dial: "+55"},
{Name: "British Indian Ocean Territory", Code: "IO", Dial: "+246"},
{Name: "Brunei Darussalam", Code: "BN", Dial: "+673"},
{Name: "Bulgaria", Code: "BG", Dial: "+359"},
{Name: "Burkina Faso", Code: "BF", Dial: "+226"},
{Name: "Burundi", Code: "BI", Dial: "+257"},
{Name: "Cambodia", Code: "KH", Dial: "+855"},
{Name: "Cameroon", Code: "CM", Dial: "+237"},
{Name: "Canada", Code: "CA", Dial: "+1"},
{Name: "Cape Verde", Code: "CV", Dial: "+238"},
{Name: "Cayman Islands", Code: "KY", Dial: "+345"},
{Name: "Central African Republic", Code: "CF", Dial: "+236"},
{Name: "Chad", Code: "TD", Dial: "+235"},
{Name: "Chile", Code: "CL", Dial: "+56"},
{Name: "China", Code: "CN", Dial: "+86"},
{Name: "Christmas Island", Code: "CX", Dial: "+61"},
{Name: "Cocos (Keeling) Islands", Code: "CC", Dial: "+61"},
{Name: "Colombia", Code: "CO", Dial: "+57"},
{Name: "Comoros", Code: "KM", Dial: "+269"},
{Name: "Congo", Code: "CG", Dial: "+242"},
{Name: "Congo, The Democratic Republic of the Congo", Code: "CD", Dial: "+243"},
{Name: "Cook Islands", Code: "CK", Dial: "+682"},
{Name: "Costa Rica", Code: "CR", Dial: "+506"},
{Name: "Cote d'Ivoire", Code: "CI", Dial: "+225"},
{Name: "Croatia", Code: "HR", Dial: "+385"},
{Name: "Cuba", Code: "CU", Dial: "+53"},
{Name: "Cyprus", Code: "CY", Dial: "+357"},
{Name: "Czech Republic", Code: "CZ", Dial: "+420"},
{Name: "Denmark", Code: "DK", Dial: "+45"},
{Name: "Djibouti", Code: "DJ", Dial: "+253"},
{Name: "Dominica", Code: "DM", Dial: "+1767"},
{Name: "Dominican Republic", Code: "DO", Dial: "+1849"},
{Name: "Ecuador", Code: "EC", Dial: "+593"},
{Name: "Egypt", Code: "EG", Dial: "+20"},
{Name: "El Salvador", Code: "SV", Dial: "+503"},
{Name: "Equatorial Guinea", Code: "GQ", Dial: "+240"},
{Name: "Eritrea", Code: "ER", Dial: "+291"},
{Name: "Estonia", Code: "EE", Dial: "+372"},
{Name: "Ethiopia", Code: "ET", Dial: "+251"},
{Name: "Falkland Islands (Malvinas)", Code: "FK", Dial: "+500"},
{Name: "Faroe Islands", Code: "FO", Dial: "+298"},
{Name: "Fiji", Code: "FJ", Dial: "+679"},
{Name: "Finland", Code: "FI", Dial: "+358"},
{Name: "France", Code: "FR", Dial: "+33"},
{Name: "French Guiana", Code: "GF", Dial: "+594"},
{Name: "French Polynesia", Code: "PF", Dial: "+689"},
{Name: "Gabon", Code: "GA", Dial: "+241"},
{Name: "Gambia", Code: "GM", Dial: "+220"},
{Name: "Georgia", Code: "GE", Dial: "+995"},
{Name: "Germany", Code: "DE", Dial: "+49"},
{Name: "Ghana", Code: "GH", Dial: "+233"},
{Name: "Gibraltar", Code: "GI", Dial: "+350"},
{Name: "Greece", Code: "GR", Dial: "+30"},
{Name: "Greenland", Code: "GL", Dial: "+299"},
{Name: "Grenada", Code: "GD", Dial: "+1473"},
{Name: "Guadeloupe", Code: "GP", Dial: "+590"},
{Name: "Guam", Code: "GU", Dial: "+1671"},
{Name: "Guatemala", Code: "GT", Dial: "+502"},
{Name: "Guernsey", Code: "GG", Dial: "+44"},
{Name: "Guinea", Code: "GN", Dial: "+224"},
{Name: "Guinea-Bissau", Code: "GW", Dial: "+245"},
{Name: "Guyana", Code: "GY", Dial: "+595"},
{Name: "Haiti", Code: "HT", Dial: "+509"},
{Name: "Holy See (Vatican City State)", Code: "VA", Dial: "+379"},
{Name: "Honduras", Code: "HN", Dial: "+504"},
{Name: "Hong Kong", Code: "HK", Dial: "+852"},
{Name: "Hungary", Code: "HU", Dial: "+36"},
{Name: "Iceland", Code: "IS", Dial: "+354"},
{Name: "India", Code: "IN", Dial: "+91"},
{Name: "Indonesia", Code: "ID", Dial: "+62"},
{Name: "Iran, Islamic Republic of Persian Gulf", Code: "IR", Dial: "+98"},
{Name: "Iraq", Code: "IQ", Dial: "+964"},
{Name: "Ireland", Code: "IE", Dial: "+353"},
{Name: "Isle of Man", Code: "IM", Dial: "+44"},
{Name: "Israel", Code: "IL", Dial: "+972"},
{Name: "Italy", Code: "IT", Dial: "+39"},
{Name: "Jamaica", Code: "JM", Dial: "+1876"},
{Name: "Japan", Code: "JP", Dial: "+81"},
{Name: "Jersey", Code: "JE", Dial: "+44"},
{Name: "Jordan", Code: "JO", Dial: "+962"},
{Name: "Kazakhstan", Code: "KZ", Dial: "+77"},
{Name: "Kenya", Code: "KE", Dial: "+254"},
{Name: "Kiribati", Code: "KI", Dial: "+686"},
{Name: "Korea, Democratic People's Republic of Korea", Code: "KP", Dial: "+850"},
{Name: "Korea, Republic of South Korea", Code: "KR", Dial: "+82"},
{Name: "Kuwait", Code: "KW", Dial: "+965"},
{Name: "Kyrgyzstan", Code: "KG", Dial: "+996"},
{Name: "Laos", Code: "LA", Dial: "+856"},
{Name: "Latvia", Code: "LV", Dial: "+371"},
{Name: "Lebanon", Code: "LB", Dial: "+961"},
{Name: "Lesotho", Code: "LS", Dial: "+266"},
{Name: "Liberia", Code: "LR", Dial: "+231"},
{Name: "Libyan Arab Jamahiriya", Code: "LY", Dial: "+218"},
{Name: "Liechtenstein", Code: "LI", Dial: "+423"},
{Name: "Lithuania", Code: "LT", Dial: "+370"},
{Name: "Luxembourg", Code: "LU", Dial: "+352"},
{Name: "Macao", Code: "MO", Dial: "+853"},
{Name: "Macedonia", Code: "MK", Dial: "+389"},
{Name: "Madagascar", Code: "MG", Dial: "+261"},
{Name: "Malawi", Code: "MW", Dial: "+265"},
{Name: "Malaysia", Code: "MY", Dial: "+60"},
{Name: "Maldives", Code: "MV", Dial: "+960"},
{Name: "Mali", Code: "ML", Dial: "+223"},
{Name: "Malta", Code: "MT", Dial: "+356"},
{Name: "Marshall Islands", Code: "MH", Dial: "+692"},
{Name: "Martinique", Code: "MQ", Dial: "+596"},
{Name: "Mauritania", Code: "MR", Dial: "+222"},
{Name: "Mauritius", Code: "MU", Dial: "+230"},
{Name: "Mayotte", Code: "YT", Dial: "+262"},
{Name: "Mexico", Code: "MX", Dial: "+52"},
{Name: "Micronesia, Federated States of Micronesia", Code: "FM", Dial: "+691"},
{Name: "Moldova", Code: "MD", Dial: "+373"},
{Name: "Monaco", Code: "MC", Dial: "+377"},
{Name: "Mongolia", Code: "MN", Dial: "+976"},
{Name: "Montenegro", Code: "ME", Dial: "+382"},
{Name: "Montserrat", Code: "MS", Dial: "+1664"},
{Name: "Morocco", Code: "MA", Dial: "+212"},
{Name: "Mozambique", Code: "MZ", Dial: "+258"},
{Name: "Myanmar", Code: "MM", Dial: "+95"},
{Name: "Namibia", Code: "NA", Dial: "+264"},
{Name: "Nauru", Code: "NR", Dial: "+674"},
{Name: "Nepal", Code: "NP", Dial: "+977"},
{Name: "Netherlands", Code: "NL", Dial: "+31"},
{Name: "Netherlands Antilles", Code: "AN", Dial: "+599"},
{Name: "New Caledonia", Code: "NC", Dial: "+687"},
{Name: "New Zealand", Code: "NZ", Dial: "+64"},
{Name: "Nicaragua", Code: "NI", Dial: "+505"},
{Name: "Niger", Code: "NE", Dial: "+227"},
{Name: "Nigeria", Code: "NG", Dial: "+234"},
{Name: "Niue", Code: "NU", Dial: "+683"},
{Name: "Norfolk Island", Code: "NF", Dial: "+672"},
{Name: "Northern Mariana Islands", Code: "MP", Dial: "+1670"},
{Name: "Norway", Code: "NO", Dial: "+47"},
{Name: "Oman", Code: "OM", Dial: "+968"},
{Name: "Pakistan", Code: "PK", Dial: "+92"},
{Name: "Palau", Code: "PW", Dial: "+680"},
{Name: "Palestinian Territory, Occupied", Code: "PS", Dial: "+970"},
{Name: "Panama", Code: "PA", Dial: "+507"},
{Name: "Papua New Guinea", Code: "PG", Dial: "+675"},
{Name: "Paraguay", Code: "PY", Dial: "+595"},
{Name: "Peru", Code: "PE", Dial: "+51"},
{Name: "Philippines", Code: "PH", Dial: "+63"},
{Name: "Pitcairn", Code: "PN", Dial: "+872"},
{Name: "Poland", Code: "PL", Dial: "+48"},
{Name: "Portugal", Code: "PT", Dial: "+351"},
{Name: "Puerto Rico", Code: "PR", Dial: "+1939"},
{Name: "Qatar", Code: "QA", Dial: "+974"},
{Name: "Reunion", Code: "RE", Dial: "+262"},
{Name: "Romania", Code: "RO", Dial: "+40"},
{Name: "Russia", Code: "RU", Dial: "+7"},
{Name: "Rwanda", Code: "RW", Dial: "+250"},
{Name: "Saint Barthelemy", Code: "BL", Dial: "+590"},
{Name: "Saint Helena, Ascension and Tristan Da Cunha", Code: "SH", Dial: "+290"},
{Name: "Saint Kitts and Nevis", Code: "KN", Dial: "+1869"},
{Name: "Saint Lucia", Code: "LC", Dial: "+1758"},
{Name: "Saint Martin", Code: "MF", Dial: "+590"},
{Name: "Saint Pierre and Miquelon", Code: "PM", Dial: "+508"},
{Name: "Saint Vincent and the Grenadines", Code: "VC", Dial: "+1784"},
{Name: "Samoa", Code: "WS", Dial: "+685"},
{Name: "San Marino", Code: "SM", Dial: "+378"},
{Name: "Sao Tome and Principe", Code: "ST", Dial: "+239"},
{Name: "Saudi Arabia", Code: "SA", Dial: "+966"},
{Name: "Senegal", Code: "SN", Dial: "+221"},
{Name: "Serbia", Code: "RS", Dial: "+381"},
{Name: "Seychelles", Code: "SC", Dial: "+248"},
{Name: "Sierra Leone", Code: "SL", Dial: "+232"},
{Name: "Singapore", Code: "SG", Dial: "+65"},
{Name: "Slovakia", Code: "SK", Dial: "+421"},
{Name: "Slovenia", Code: "SI", Dial: "+386"},
{Name: "Solomon Islands", Code: "SB", Dial: "+677"},
{Name: "Somalia", Code: "SO", Dial: "+252"},
{Name: "South Africa", Code: "ZA", Dial: "+27"},
{Name: "South Georgia and the South Sandwich Islands", Code: "GS", Dial: "+500"},
{Name: "South Sudan", Code: "SS", Dial: "+211"},
{Name: "Spain", Code: "ES", Dial: "+34"},
{Name: "Sri Lanka", Code: "LK", Dial: "+94"},
{Name: "Sudan", Code: "SD", Dial: "+249"},
{Name: "Suriname", Code: "SR", Dial: "+597"},
{Name: "Svalbard and Jan Mayen", Code: "SJ", Dial: "+47"},
{Name: "Swaziland", Code: "SZ", Dial: "+268"},
{Name: "Sweden", Code: "SE", Dial: "+46"},
{Name: "Switzerland", Code: "CH", Dial: "+41"},
{Name: "Syrian Arab Republic", Code: "SY", Dial: "+963"},
{Name: "Taiwan", Code: "TW", Dial: "+886"},
{Name: "Tajikistan", Code: "TJ", Dial: "+992"},
{Name: "Tanzania, United Republic of Tanzania", Code: "TZ", Dial: "+255"},
{Name: "Thailand", Code: "TH", Dial: "+66"},
{Name: "Timor-Leste", Code: "TL", Dial: "+670"},
{Name: "Togo", Code: "TG", Dial: "+228"},
{Name: "Tokelau", Code: "TK", Dial: "+690"},
{Name: "Tonga", Code: "TO", Dial: "+676"},
{Name: "Trinidad and Tobago", Code: "TT", Dial: "+1868"},
{Name: "Tunisia", Code: "TN", Dial: "+216"},
{Name: "Turkey", Code: "TR", Dial: "+90"},
{Name: "Turkmenistan", Code: "TM", Dial: "+993"},
{Name: "Turks and Caicos Islands", Code: "TC", Dial: "+1649"},
{Name: "Tuvalu", Code: "TV", Dial: "+688"},
{Name: "Uganda", Code: "UG", Dial: "+256"},
{Name: "Ukraine", Code: "UA", Dial: "+380"},
{Name: "United Arab Emirates", Code: "AE", Dial: "+971"},
{Name: "United Kingdom", Code: "GB", Dial: "+44"},
{Name: "United States", Code: "US", Dial: "+1"},
{Name: "Uruguay", Code: "UY", Dial: "+598"},
{Name: "Uzbekistan", Code: "UZ", Dial: "+998"},
{Name: "Vanuatu", Code: "VU", Dial: "+678"},
{Name: "Venezuela, Bolivarian Republic of Venezuela", Code: "VE", Dial: "+58"},
{Name: "Vietnam", Code: "VN", Dial: "+84"},
{Name: "Virgin Islands, British", Code: "VG", Dial: "+1284"},
{Name: "Virgin Islands, U.S.", Code: "VI", Dial: "+1340"},
{Name: "Wallis and Futuna", Code: "WF", Dial: "+681"},
{Name: "Yemen", Code: "YE", Dial: "+967"},
{Name: "Zambia", Code: "ZM", Dial: "+260"},
{Name: "Zimbabwe", Code: "ZW", Dial: "+263"},
}

208
dategrid_alignment_test.go Normal file
View File

@ -0,0 +1,208 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// These tests pin the calendar-grid layout contract, written against the
// second bidbuddy.com.au report (2026-06-10, 20:59): the grid renders fixed
// SunSat column headers but poured consecutive day buttons in from column 1
// with no offset, so June 10 (a Wednesday) sat under "Sun" and tapping the
// cell under "Tue" (showing 12) selected Friday, June 12. The first date must
// be preceded by int(weekday) spacer cells so every button lands under its
// real weekday header. "Today" (window start + IsPast/IsToday flags) must be
// resolved in the visitor/site timezone, not the server clock's.
// divergentZone returns an IANA zone whose current calendar date differs from
// the server-local date right now. Pacific/Kiritimati (UTC+14) and
// Pacific/Pago_Pago (UTC-11) are 25 hours apart, so at any instant at least
// one of them is on a different date than the server.
func divergentZone(t *testing.T) (*time.Location, string) {
t.Helper()
serverToday := time.Now().Format("2006-01-02")
for _, name := range []string{"Pacific/Kiritimati", "Pacific/Pago_Pago"} {
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%s): %v", name, err)
}
if time.Now().In(loc).Format("2006-01-02") != serverToday {
return loc, name
}
}
t.Fatal("no zone diverges from server-local date — zones are 25h apart, impossible")
return nil, ""
}
// firstDateParam extracts the YYYY-MM-DD value of the first date= query param
// in rendered HTML — i.e. the first day button of the grid.
func firstDateParam(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, "date=")
if i < 0 || i+15 > len(html) {
t.Fatalf("no date= param found in rendered HTML")
}
return html[i+5 : i+15]
}
func TestLeadingBlanks(t *testing.T) {
cell := func(date string) DateCell { return DateCell{Date: date} }
cases := []struct {
name string
dates []DateCell
want int
}{
{"wednesday start needs 3 pads", []DateCell{cell("2026-06-10")}, 3},
{"sunday start needs none", []DateCell{cell("2026-06-14")}, 0},
{"saturday start needs 6", []DateCell{cell("2026-06-13")}, 6},
{"empty grid", nil, 0},
{"unparseable date", []DateCell{cell("garbage")}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := leadingBlanks(tc.dates); got != tc.want {
t.Errorf("leadingBlanks(%v) = %d, want %d", tc.dates, got, tc.want)
}
})
}
}
// TestHandleGetDateGrid_AlignsFirstDateToWeekdayColumn drives /date-grid with
// a future Wednesday weekStart and asserts exactly 3 spacer cells render
// before the first day button, so the 7-column grid lines dates up with the
// SunSat headers.
func TestHandleGetDateGrid_AlignsFirstDateToWeekdayColumn(t *testing.T) {
router := newTestRouter(t)
// Next Wednesday at least a week out, so it is never clamped to today.
start := time.Now().AddDate(0, 0, 7)
for start.Weekday() != time.Wednesday {
start = start.AddDate(0, 0, 1)
}
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-align&username=alice&eventType=30min&weeks=2&weekStart="+start.Format("2006-01-02"), nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if got := strings.Count(body, "calcom-date-pad"); got != 3 {
t.Errorf("Wednesday window: want 3 leading pad cells, got %d", got)
}
padIdx := strings.Index(body, "calcom-date-pad")
btnIdx := strings.Index(body, "date=")
if padIdx < 0 || btnIdx < 0 || padIdx > btnIdx {
t.Errorf("pad cells must precede the first day button (padIdx=%d, btnIdx=%d)", padIdx, btnIdx)
}
if !strings.Contains(body, `calcom-date-pad" aria-hidden="true"`) {
t.Errorf("pad cells must be aria-hidden")
}
}
// TestHandleGetDateGrid_SundayStartHasNoPads is the zero case: a window
// starting on a Sunday begins flush in column 1.
func TestHandleGetDateGrid_SundayStartHasNoPads(t *testing.T) {
router := newTestRouter(t)
start := time.Now().AddDate(0, 0, 7)
for start.Weekday() != time.Sunday {
start = start.AddDate(0, 0, 1)
}
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-sun&username=alice&eventType=30min&weeks=2&weekStart="+start.Format("2006-01-02"), nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := strings.Count(rec.Body.String(), "calcom-date-pad"); got != 0 {
t.Errorf("Sunday window: want 0 pad cells, got %d", got)
}
}
// TestHandleGetDateGrid_TodayResolvedInEventTimezone pins the clamp rule to
// the MEETING's calendar, not the server's: a past weekStart must clamp to
// today in the event's schedule timezone. Pre-fix the handler clamped to the
// server-local date, so a UTC container made a Perth schedule's grid start on
// yesterday until 08:00 AWST.
func TestHandleGetDateGrid_TodayResolvedInEventTimezone(t *testing.T) {
loc, name := divergentZone(t)
fakeCalcom(t, name, nil)
h, _ := newKeyedHandler(t)
wantToday := time.Now().In(loc).Format("2006-01-02")
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-tz&username=alice&eventType=30min&weeks=2&weekStart=2020-01-01", nil)
rec := httptest.NewRecorder()
h.HandleGetDateGrid(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if got := firstDateParam(t, rec.Body.String()); got != wantToday {
t.Errorf("clamped window must start at today in %s (%s), got %s", name, wantToday, got)
}
}
// TestCalcomBookingFunc_InitialGridUsesSiteTimezone covers the initial
// server-side block render with a cold event-timezone cache: the window must
// start at today in the SITE timezone (Site Settings → Localization),
// resolved via the settings manager stashed at router-mount time — not at
// the server clock's date. Page render never calls Cal.com.
func TestCalcomBookingFunc_InitialGridUsesSiteTimezone(t *testing.T) {
s, q := newTestSettings()
loc, name := divergentZone(t)
seedSiteTimezone(q, name)
renderSettings = s
resetEventTZCache()
t.Cleanup(func() { renderSettings = nil })
html := CalcomBookingFunc(context.Background(), map[string]any{
"_block_id": "b-init",
"username": "alice",
"eventTypeSlug": "30min",
})
wantToday := time.Now().In(loc).Format("2006-01-02")
if got := firstDateParam(t, html); got != wantToday {
t.Errorf("initial grid must start at today in site tz %s (%s), got %s", name, wantToday, got)
}
wantPads := int(time.Now().In(loc).Weekday())
if got := strings.Count(html, "calcom-date-pad"); got != wantPads {
t.Errorf("initial grid: want %d pad cells for %s, got %d", wantPads, wantToday, got)
}
}
// TestCalcomBookingFunc_InitialGridPrefersCachedEventTimezone asserts the
// initial render uses the meeting timezone over the site setting once the
// cache is warm (any visitor's first HTMX interaction warms it).
func TestCalcomBookingFunc_InitialGridPrefersCachedEventTimezone(t *testing.T) {
s, q := newTestSettings()
loc, name := divergentZone(t)
seedSiteTimezone(q, "UTC") // site setting present but unset-equivalent
storeEventTZ("alice", "30min", name)
renderSettings = s
t.Cleanup(func() {
renderSettings = nil
resetEventTZCache()
})
html := CalcomBookingFunc(context.Background(), map[string]any{
"_block_id": "b-init2",
"username": "alice",
"eventTypeSlug": "30min",
})
wantToday := time.Now().In(loc).Format("2006-01-02")
if got := firstDateParam(t, html); got != wantToday {
t.Errorf("initial grid must start at today in cached event tz %s (%s), got %s", name, wantToday, got)
}
if !strings.Contains(html, name) {
t.Errorf("ShowTimezone label should carry the event tz %s", name)
}
}

25
errors.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"fmt"
"log"
"net/http"
)
// writeInternalServerError logs err and returns a generic 500 to the client.
// It never exposes err.Error() to the HTTP response.
func writeInternalServerError(w http.ResponseWriter, err error, context string) {
writeServerError(w, err, "Internal Server Error", context)
}
// writeServerError logs err and returns publicMessage with HTTP 500 to the client.
// It never exposes err.Error() to the HTTP response.
func writeServerError(w http.ResponseWriter, err error, publicMessage string, context string) {
if err != nil && context != "" {
err = fmt.Errorf("%s: %w", context, err)
}
if err != nil {
log.Printf("server error: %v", err)
}
http.Error(w, publicMessage, http.StatusInternalServerError)
}

541
field_routing_test.go Normal file
View File

@ -0,0 +1,541 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Cal.com field-taxonomy regressions
//
// Cal.com event types return a mix of SYSTEM booking fields (name, email,
// attendeePhoneNumber, location, notes, guests, title, rescheduleReason) and
// CUSTOM fields. Each system field has a fixed slug + type + `editable` value,
// and reschedule-only fields carry views:[{id:"reschedule"}] (see Cal.com
// getBookingFields.ts). The widget must:
// - NOT render reschedule-only fields on a NEW booking (rescheduleReason)
// - route each field to its correct slot in POST /v2/bookings:
// phone-typed -> attendee.phoneNumber (E.164-normalized)
// guests -> top-level guests[] array
// custom -> bookingFieldsResponses (custom-only per the v2 schema)
// - never emit empty-string responses (Cal.com rejects empty/mistyped values)
// These tests pin that contract.
// ---------------------------------------------------------------------------
// TestHandleGetForm_DropsRescheduleOnlyFields asserts the booking form does not
// render fields whose Cal.com `views` restricts them to the reschedule flow
// (rescheduleReason). Genuine custom fields (birthday) must still render.
func TestHandleGetForm_DropsRescheduleOnlyFields(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","editable":"system","required":true},
{"slug":"email","type":"email","editable":"system-but-optional","required":true},
{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false,"views":[{"id":"reschedule","label":"Reschedule"}]},
{"slug":"birthday","type":"text","label":"Birthday","editable":"user","required":false}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=UTC", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// rescheduleReason is reschedule-only — must NOT appear on a new booking.
if strings.Contains(body, `name="rescheduleReason"`) {
t.Errorf("reschedule-only field rescheduleReason rendered on a new booking form; body: %q", body)
}
if strings.Contains(body, "Reason for reschedule") {
t.Errorf("reschedule-only label 'Reason for reschedule' rendered on a new booking form; body: %q", body)
}
// Genuine custom field must still render.
if !strings.Contains(body, `name="birthday"`) {
t.Errorf("expected custom field name=\"birthday\" in form, got: %q", body)
}
}
// TestHandleGetForm_RendersPhoneCountryCombobox asserts a Cal.com phone field
// renders the searchable country combobox with the detected country (AU, from
// the Australia/Perth timezone) pre-selected, alongside the tel number input.
func TestHandleGetForm_RendersPhoneCountryCombobox(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","required":true},
{"slug":"attendeePhoneNumber","type":"phone","required":true}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=Australia/Perth&locale=en-AU", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"data-cc-combobox",
`name="phoneCountry"`,
`value="AU"`, // detected default pre-selected in the hidden input
"🇦🇺 Australia (+61)", // default search label / AU option
`data-code="US"`, // other countries present in the list
`type="tel"`, // the national-number input
`name="attendeePhoneNumber"`,
} {
if !strings.Contains(body, want) {
t.Errorf("phone combobox missing %q in rendered form", want)
}
}
}
// TestHandleGetForm_DropsSystemFieldsBySlug asserts system fields with no valid
// new-booking home — location (radioInput) and rescheduleReason even WITHOUT a
// views flag — are not rendered (their submitted values would be discarded).
// Genuine custom fields still render.
func TestHandleGetForm_DropsSystemFieldsBySlug(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","required":true},
{"slug":"location","type":"radioInput","editable":"system","required":false},
{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false},
{"slug":"birthday","type":"text","label":"Birthday","editable":"user","required":false}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=UTC", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, `name="location"`) {
t.Errorf("location field must not render (its value is dropped on submit); body: %q", body)
}
if strings.Contains(body, `name="rescheduleReason"`) {
t.Errorf("rescheduleReason must not render even without a views flag; body: %q", body)
}
if !strings.Contains(body, `name="birthday"`) {
t.Errorf("expected custom field name=\"birthday\" to still render")
}
}
// TestHandleCreateBooking_EmptySystemFieldsNotSent asserts the real-world
// failure case: the visitor fills name/email but leaves the optional system
// fields (location, notes, guests, rescheduleReason, title) empty. None of
// those empties may reach Cal.com — Cal.com rejects empty/mistyped system
// responses with a 400, which is the production booking failure.
func TestHandleCreateBooking_EmptySystemFieldsNotSent(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"timezone": {"America/Toronto"},
"attendeePhoneNumber": {""},
"location": {""},
"notes": {""},
"guests": {""},
"rescheduleReason": {""},
"title": {""},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.body == nil {
t.Fatalf("Cal.com received no parseable body")
}
// bookingFieldsResponses must be omitted entirely when there is nothing
// custom and non-empty to send.
if resps, present := cap.body["bookingFieldsResponses"]; present {
m, _ := resps.(map[string]any)
if len(m) != 0 {
t.Errorf("bookingFieldsResponses must be empty/omitted when all optional fields are blank, got: %v", resps)
}
// No empty-string values may ever be sent.
for k, v := range m {
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
t.Errorf("bookingFieldsResponses[%q] is an empty string — Cal.com rejects empty responses", k)
}
}
// System/reschedule keys must never appear as custom responses.
for _, banned := range []string{"location", "notes", "guests", "rescheduleReason", "title", "attendeePhoneNumber"} {
if _, bad := m[banned]; bad {
t.Errorf("bookingFieldsResponses must not contain system key %q", banned)
}
}
}
// guests is a top-level field — empty guests must be omitted, not sent as "".
if g, present := cap.body["guests"]; present {
t.Errorf("empty guests must be omitted from the top-level payload, got: %v", g)
}
}
// TestHandleCreateBooking_GuestsRoutedToTopLevelArray asserts that the `guests`
// system field (Cal.com type multiemail) is split and sent as the top-level
// `guests` array of emails — NOT as a comma-joined string inside
// bookingFieldsResponses.
func TestHandleCreateBooking_GuestsRoutedToTopLevelArray(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"guests": {"a@x.com, b@y.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
guests, ok := cap.body["guests"].([]any)
if !ok {
t.Fatalf("guests must be a top-level JSON array, got %T: %v", cap.body["guests"], cap.body["guests"])
}
if len(guests) != 2 || guests[0] != "a@x.com" || guests[1] != "b@y.com" {
t.Errorf("guests array wrong: %v", guests)
}
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["guests"]; bad {
t.Errorf("guests must not appear in bookingFieldsResponses")
}
}
}
// TestHandleCreateBooking_PhoneSlugRoutedToAttendee asserts that a phone value
// submitted under Cal.com's system slug `attendeePhoneNumber` (not the literal
// "phone") lands in attendee.phoneNumber, E.164-normalized (separators
// stripped) — not in bookingFieldsResponses.
func TestHandleCreateBooking_PhoneSlugRoutedToAttendee(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"attendeePhoneNumber": {"+61 432 777 227"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, ok := cap.body["attendee"].(map[string]any)
if !ok {
t.Fatalf("attendee missing/wrong type: %v", cap.body)
}
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (separators stripped)", att["phoneNumber"])
}
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["attendeePhoneNumber"]; bad {
t.Errorf("attendeePhoneNumber must route to attendee.phoneNumber, not bookingFieldsResponses")
}
}
}
// TestNormalizePhone_AppliesDialCode pins the E.164 normalization: Cal.com
// rejects national-format numbers (`{attendeePhoneNumber}invalid_number`), so a
// configured default dial code converts trunk-0 / bare-national numbers to
// international form. Numbers already in +/00 international form are left intact.
func TestNormalizePhone(t *testing.T) {
// libphonenumber applies each country's trunk rules. The IN/IT/RU cases failed
// under the old hand-rolled heuristic — the reviewer's bug class.
cases := []struct{ raw, region, want string }{
{"0432777227", "AU", "+61432777227"}, // AU mobile, trunk-0 dropped
{"0412 345 678", "AU", "+61412345678"}, // separators + trunk-0
{"07911123456", "GB", "+447911123456"}, // UK mobile, trunk-0 dropped
{"+44 7911 123456", "", "+447911123456"}, // already international, region ignored
{"9876543210", "IN", "+919876543210"}, // India mobile
{"0236618300", "IT", "+390236618300"}, // Italy landline KEEPS the leading 0
{"89161234567", "RU", "+79161234567"}, // Russia trunk prefix is 8
{"", "AU", ""}, // empty stays empty
{"NOPE", "AU", "NOPE"}, // unparseable -> best-effort fallback
{"555-1212", "", "5551212"}, // no region, not international -> fallback strip
}
for _, c := range cases {
if got := normalizePhone(c.raw, c.region); got != c.want {
t.Errorf("normalizePhone(%q, %q) = %q, want %q", c.raw, c.region, got, c.want)
}
}
}
// TestRegionFromLocale pins extraction of the region subtag from navigator.language.
func TestRegionFromLocale(t *testing.T) {
cases := map[string]string{
"en-AU": "AU",
"en_AU": "AU",
"AU": "AU",
"fr-FR": "FR",
"zh-Hans-CN": "CN",
"de-DE-1996": "DE",
"en": "", // no region subtag
"": "",
}
for in, want := range cases {
if got := regionFromLocale(in); got != want {
t.Errorf("regionFromLocale(%q) = %q, want %q", in, got, want)
}
}
}
// TestResolveRegion pins the country pre-selected in the phone combobox:
// timezone region first (strongest locator), then browser locale region.
func TestResolveRegion(t *testing.T) {
cases := []struct{ locale, tz, want string }{
{"en-AU", "", "AU"},
{"en-US", "Australia/Sydney", "AU"}, // timezone beats mismatched locale
{"fr-FR", "", "FR"},
{"", "Pacific/Auckland", "NZ"},
{"en", "", ""}, // no region
{"", "UTC", ""}, // unmapped tz, no locale
{"", "", ""},
}
for _, c := range cases {
if got := resolveRegion(c.locale, c.tz); got != c.want {
t.Errorf("resolveRegion(%q, %q) = %q, want %q", c.locale, c.tz, got, c.want)
}
}
}
// TestCountryFlag verifies ISO code → regional-indicator emoji.
func TestCountryFlag(t *testing.T) {
if got := countryFlag("AU"); got != "🇦🇺" {
t.Errorf("countryFlag(AU) = %q, want 🇦🇺", got)
}
// Guard rejects malformed input (wrong length / non-letters), not merely
// unknown-but-well-formed codes.
if got := countryFlag("X"); got != "" {
t.Errorf("countryFlag(too short) = %q, want empty", got)
}
if got := countryFlag("1A"); got != "" {
t.Errorf("countryFlag(non-letters) = %q, want empty", got)
}
if got := countryLabel("AU"); got != "🇦🇺 Australia (+61)" {
t.Errorf("countryLabel(AU) = %q, want '🇦🇺 Australia (+61)'", got)
}
}
// TestHandleCreateBooking_PhoneCountrySelectionWins asserts the visitor's
// explicit country-combobox choice drives the E.164 conversion and overrides
// browser detection.
func TestHandleCreateBooking_PhoneCountrySelectionWins(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"bidbuddy"},
"eventType": {"30min"},
"start": {"2026-06-01T09:30:00Z"},
"name": {"Alex"},
"email": {"alex@example.com"},
"attendeePhoneNumber": {"0432777227"},
"phoneCountry": {"AU"}, // explicit selection
"locale": {"en-US"}, // mismatched locale that must NOT win
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, _ := cap.body["attendee"].(map[string]any)
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (explicit AU selection)", att["phoneNumber"])
}
// phoneCountry is a control field — must not leak into responses.
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["phoneCountry"]; bad {
t.Errorf("phoneCountry must not appear in bookingFieldsResponses")
}
}
}
// TestHandleCreateBooking_InfersDialCodeFromLocale is the zero-config regression:
// the bidbuddy visitor submits the AU national number 0432777227 with NO admin
// dial code configured; the browser locale (en-AU) must drive the E.164
// conversion so Cal.com accepts attendee.phoneNumber.
func TestHandleCreateBooking_InfersDialCodeFromLocale(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
// Deliberately NO SetDefaultDialCode — detection must come from the locale.
form := url.Values{
"blockId": {"b1"},
"username": {"bidbuddy"},
"eventType": {"30min"},
"start": {"2026-06-01T09:30:00Z"},
"name": {"Alex Dunmow"},
"email": {"alex@example.com"},
"attendeePhoneNumber": {"0432777227"},
"locale": {"en-AU"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, ok := cap.body["attendee"].(map[string]any)
if !ok {
t.Fatalf("attendee missing/wrong type: %v", cap.body)
}
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (E.164 inferred from en-AU locale)", att["phoneNumber"])
}
// locale is a control field — it must never leak into bookingFieldsResponses.
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["locale"]; bad {
t.Errorf("locale must not appear in bookingFieldsResponses")
}
}
}
// TestStatusErrorWithBody_ParsesNestedCalcomError guards the diagnostic gap:
// Cal.com v2 returns errors as a NESTED object {"error":{"code","message"}},
// not a flat {"message"}. statusErrorWithBody must surface that message in the
// (server-side) error so booking failures are diagnosable instead of a bare
// "status 400".
func TestStatusErrorWithBody_ParsesNestedCalcomError(t *testing.T) {
body := []byte(`{"status":"error","timestamp":"2026-05-30T00:00:00Z","path":"/v2/bookings","error":{"code":"BAD_REQUEST","message":"Invalid responses - guests: Expected array, received string"}}`)
err := statusErrorWithBody("booking", http.StatusBadRequest, body)
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, "400") {
t.Errorf("error must include the status code, got: %q", msg)
}
if !strings.Contains(msg, "guests: Expected array") {
t.Errorf("error must surface Cal.com's nested error.message, got: %q", msg)
}
}

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module git.dev.alexdunmow.com/block/calcomblock
go 1.26.4
require (
git.dev.alexdunmow.com/block/core v0.18.2
github.com/a-h/templ v0.3.1020
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/nyaruka/phonenumbers v1.8.0
)
require (
connectrpc.com/connect v1.20.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

46
go.sum Normal file
View File

@ -0,0 +1,46 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU=
git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/nyaruka/phonenumbers v1.8.0 h1:TrXNJmbwcAHajzDqin3mLWw57vqLUA6ZjVdeNds0heQ=
github.com/nyaruka/phonenumbers v1.8.0/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1150
handler.go Normal file

File diff suppressed because it is too large Load Diff

340
handler_captcha_test.go Normal file
View File

@ -0,0 +1,340 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/google/uuid"
"git.dev.alexdunmow.com/block/core/captcha"
)
// fakeBlockResolver is an in-memory blockContentResolver so the booking handler
// can resolve the server-authoritative "does this username+eventType require a
// captcha?" answer without a live database. It mirrors the DB query
// (CalcomBookingRequiresCaptcha), which returns true iff a published
// calcom:booking block for that username+eventTypeSlug has captchaEnabled=true.
// The key is username+eventTypeSlug — deliberately NOT the blockId — so tests
// prove the requirement no longer depends on the client-supplied id.
type fakeBlockResolver struct {
// required maps "username\x00eventTypeSlug" → whether a published block
// requires captcha for that pair.
required map[string]bool
// err, when set, is returned to exercise the resolver-error path.
err error
}
func (f fakeBlockResolver) CalcomBookingRequiresCaptcha(_ context.Context, username, eventTypeSlug string) (bool, error) {
if f.err != nil {
return false, f.err
}
return f.required[username+"\x00"+eventTypeSlug], nil
}
// newCalcomCaptchaHandler builds a CalcomHandler whose published calcom:booking
// block for username "alice" / eventType "30min" carries the given
// captchaEnabled flag, plus a persisted API key so the booking flow reaches the
// Cal.com call. The requirement is keyed on username+eventType, NOT a blockId —
// so the booking form may post any blockId (or none) without affecting it.
func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler {
t.Helper()
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
h.blockConfig = fakeBlockResolver{required: map[string]bool{
"alice\x0030min": captchaEnabled,
}}
return h
}
// newCalcomTestCaptchaServer builds a captcha.Server with a low PoW difficulty
// so the challenge solves quickly in tests.
func newCalcomTestCaptchaServer() *captcha.Server {
return captcha.New([]byte("calcom-captcha-test-secret"), captcha.WithChallenge(4, 8, 2))
}
// captchaBookingForm builds a booking POST body that passes every cheap
// validation (name+email present, valid email) so the only thing standing
// between the request and the Cal.com call is the captcha check. blockID is
// posted only as a render target (as a real widget would); when empty it is
// omitted entirely, proving the captcha requirement does not depend on it.
func captchaBookingForm(blockID string) url.Values {
v := url.Values{
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
if blockID != "" {
v.Set("blockId", blockID)
}
return v
}
func postCaptchaBooking(h *CalcomHandler, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", "198.51.100.7") // TEST-NET-2, isolated bucket
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
return rec
}
// fakeCalcomServer stands in for Cal.com and counts booking creations, so tests
// can assert whether a submission got past the captcha gate to actually book.
func fakeCalcomServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var bookings atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/bookings") {
bookings.Add(1)
}
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u"}}`)
}))
t.Cleanup(srv.Close)
return srv, &bookings
}
func TestCalcomBooking_CaptchaEnabled_RejectsWithoutToken(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h := newCalcomCaptchaHandler(t, true)
SetCaptchaServer(newCalcomTestCaptchaServer())
t.Cleanup(func() { SetCaptchaServer(nil) })
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token
if got := bookings.Load(); got != 0 {
t.Fatalf("Cal.com booking created %d times; want 0 (missing captcha token must be rejected)", got)
}
if !strings.Contains(rec.Body.String(), "aptcha") {
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
}
}
func TestCalcomBooking_CaptchaEnabled_RejectsInvalidToken(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h := newCalcomCaptchaHandler(t, true)
SetCaptchaServer(newCalcomTestCaptchaServer())
t.Cleanup(func() { SetCaptchaServer(nil) })
form := captchaBookingForm(uuid.NewString())
form.Set("cap-token", "not-a-real-token")
rec := postCaptchaBooking(h, form)
if got := bookings.Load(); got != 0 {
t.Fatalf("Cal.com booking created %d times; want 0 (invalid token rejected)", got)
}
if !strings.Contains(rec.Body.String(), "aptcha") {
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
}
}
func TestCalcomBooking_CaptchaEnabled_ProceedsWithValidToken(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
srv := newCalcomTestCaptchaServer()
h := newCalcomCaptchaHandler(t, true)
SetCaptchaServer(srv)
t.Cleanup(func() { SetCaptchaServer(nil) })
token := mintCalcomCaptchaToken(t, srv)
form := captchaBookingForm(uuid.NewString())
form.Set("cap-token", token)
rec := postCaptchaBooking(h, form)
if got := bookings.Load(); got != 1 {
t.Fatalf("Cal.com booking created %d times; want 1 (valid token must pass captcha)", got)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
}
// The captcha token must never be forwarded to Cal.com as a booking field.
if strings.Contains(rec.Body.String(), "cap-token") {
t.Fatalf("cap-token leaked into rendered response: %s", rec.Body.String())
}
}
func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h := newCalcomCaptchaHandler(t, false)
// A non-nil server would still reject if enforcement wrongly kicked in; nil
// proves the disabled path never touches the verifier.
SetCaptchaServer(nil)
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token
if got := bookings.Load(); got != 1 {
t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "aptcha") {
t.Fatalf("captcha error fragment rendered when captcha disabled: %s", rec.Body.String())
}
}
func TestCalcomBooking_CaptchaEnabled_FailsClosedWhenServerNil(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h := newCalcomCaptchaHandler(t, true)
// nil captcha server: the instance secret failed at startup. An enabled
// block must reject rather than allow the booking through unverified.
SetCaptchaServer(nil)
form := captchaBookingForm(uuid.NewString())
form.Set("cap-token", "anything")
rec := postCaptchaBooking(h, form)
if got := bookings.Load(); got != 0 {
t.Fatalf("Cal.com booking created %d times; want 0 (fail closed on nil server)", got)
}
if !strings.Contains(rec.Body.String(), "aptcha") {
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
}
}
// TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement is the
// regression guard for the security finding (commit 37e88d3b9): the captcha
// requirement derives from published content (username+eventType), NEVER the
// client-supplied blockId. A bot that omits blockId OR forges a random one must
// still be rejected when a published calcom:booking block for that
// username+eventType has captchaEnabled=true. Before the fix, omitting blockId
// caused blockCaptchaEnabled to fail open and book with no captcha.
func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T) {
for _, tc := range []struct{ name, blockID string }{
{"blockId omitted", ""},
{"blockId forged (random, unresolvable)", uuid.NewString()},
} {
t.Run(tc.name, func(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h := newCalcomCaptchaHandler(t, true) // published block requires captcha
SetCaptchaServer(newCalcomTestCaptchaServer())
t.Cleanup(func() { SetCaptchaServer(nil) })
// No valid cap-token; the requirement must not depend on blockId.
rec := postCaptchaBooking(h, captchaBookingForm(tc.blockID))
if got := bookings.Load(); got != 0 {
t.Fatalf("Cal.com booking created %d times; want 0 (captcha requirement must not depend on blockId)", got)
}
if !strings.Contains(rec.Body.String(), "aptcha") {
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
}
})
}
}
// TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen documents the
// deliberate choice for the requirement lookup: a resolver (DB) error is logged
// and treated as "not required", so a transient DB blip cannot block every
// booking site-wide. The honeypot + per-IP rate limit remain the baseline
// protection. (This is distinct from the nil-captcha-server path, which fails
// CLOSED when a block IS known to require captcha.)
func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
fake, bookings := fakeCalcomServer(t)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
h.blockConfig = fakeBlockResolver{err: errors.New("db unavailable")}
SetCaptchaServer(newCalcomTestCaptchaServer())
t.Cleanup(func() { SetCaptchaServer(nil) })
rec := postCaptchaBooking(h, captchaBookingForm("")) // no cap-token
if got := bookings.Load(); got != 1 {
t.Fatalf("Cal.com booking created %d times; want 1 (resolver error → captcha not enforced; honeypot + rate limit still apply)", got)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// captcha PoW helpers — local copies of the (unexported) core/captcha PRNG so
// the test can solve a challenge and mint a real verification token. Mirrors
// the helpers in internal/handlers/captcha_challenge_test.go.
// ---------------------------------------------------------------------------
func mintCalcomCaptchaToken(t *testing.T, srv *captcha.Server) string {
t.Helper()
ch, err := srv.CreateChallenge()
if err != nil {
t.Fatalf("CreateChallenge: %v", err)
}
intSols := solveCalcomChallenge(ch.Token, ch.Challenge.C, ch.Challenge.S, ch.Challenge.D)
sols := make([]string, len(intSols))
for i, n := range intSols {
sols[i] = strconv.Itoa(n)
}
rd := srv.Redeem(ch.Token, sols)
if !rd.Success || rd.Token == "" {
t.Fatalf("Redeem failed: %+v", rd)
}
return rd.Token
}
func solveCalcomChallenge(token string, c, s, d int) []int {
sols := make([]int, c)
for i := 1; i <= c; i++ {
salt := calcomPRNG(token+strconv.Itoa(i), s)
target := calcomPRNG(token+strconv.Itoa(i)+"d", d)
for n := 0; ; n++ {
sum := sha256.Sum256([]byte(salt + strconv.Itoa(n)))
h := hex.EncodeToString(sum[:])
if len(h) >= len(target) && h[:len(target)] == target {
sols[i-1] = n
break
}
}
}
return sols
}
func calcomPRNG(seed string, length int) string {
const mask = 0xFFFFFFFF
state := calcomFNV1a(seed)
var b strings.Builder
for b.Len() < length {
state ^= (state << 13) & mask
state ^= state >> 17
state ^= (state << 5) & mask
state &= mask
fmt.Fprintf(&b, "%08x", state)
}
return b.String()[:length]
}
func calcomFNV1a(s string) uint32 {
var h uint32 = 2166136261
for _, ch := range s {
h ^= uint32(ch)
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
}
return h
}

1335
handler_integration_test.go Normal file

File diff suppressed because it is too large Load Diff

929
handler_test.go Normal file
View File

@ -0,0 +1,929 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
"github.com/jackc/pgx/v5/pgconn"
)
// fakeQuerier is an in-memory implementation of helpers.PluginSettingsQuerier
// used by tests so SettingsManager can persist and read settings without a DB.
type fakeQuerier struct {
mu sync.Mutex
data map[string][]byte
}
func newFakeQuerier() *fakeQuerier {
return &fakeQuerier{data: map[string][]byte{}}
}
func (f *fakeQuerier) GetSetting(_ context.Context, key string) (db.Setting, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.data[key]
if !ok {
return db.Setting{}, &pgconn.PgError{Code: "P0002", Message: "no rows"}
}
return db.Setting{Key: key, Value: v}, nil
}
func (f *fakeQuerier) UpsertSetting(_ context.Context, arg db.UpsertSettingParams) (db.Setting, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.data[arg.Key] = arg.Value
return db.Setting(arg), nil
}
// newTestSettings builds a SettingsManager backed by an in-memory querier and
// a real crypto with a deterministic key — suitable for handler tests.
func newTestSettings() (*SettingsManager, *fakeQuerier) {
q := newFakeQuerier()
c := helpers.NewPluginCrypto("test-secret-key-for-unit-test!!")
return NewSettingsManager(c, q), q
}
// newTestHandler builds a CalcomHandler wired with an isolated rate limiter
// — handy for tests that need to drive HandleCreateBooking.
func newTestHandler() (*CalcomHandler, *fakeQuerier) {
s, q := newTestSettings()
return NewCalcomHandler(s, NewRateLimiter(bookingsPerHourPerIP), "https://test.localdev.blockninjacms.com"), q
}
// TestWriteServerError_DoesNotLeakInternalError verifies that writeServerError
// returns the public message to the HTTP client and does NOT expose the raw
// internal error string. This is the canonical behaviour required for the
// HandleSaveSettings error branch.
func TestWriteServerError_DoesNotLeakInternalError(t *testing.T) {
internalErrMsg := "secret connection string dsn=postgres://user:pass@db/internal"
internalErr := errors.New(internalErrMsg)
rec := httptest.NewRecorder()
writeServerError(rec, internalErr, "Failed to save settings", "calcomblock.HandleSaveSettings")
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, "Failed to save settings") {
t.Errorf("expected response to contain \"Failed to save settings\", got: %q", responseBody)
}
if strings.Contains(responseBody, internalErrMsg) {
t.Errorf("response leaks internal error detail: %q", responseBody)
}
for _, sensitiveFragment := range []string{"postgres://", "user:pass", "dsn=", "internal"} {
if strings.Contains(responseBody, sensitiveFragment) {
t.Errorf("response leaks sensitive fragment %q in body: %q", sensitiveFragment, responseBody)
}
}
}
// TestWriteInternalServerError_ReturnsGenericMessage verifies that
// writeInternalServerError returns "Internal Server Error" (not the raw err).
func TestWriteInternalServerError_ReturnsGenericMessage(t *testing.T) {
internalErr := errors.New("secret internal detail: file=/etc/secret key=abc123")
rec := httptest.NewRecorder()
writeInternalServerError(rec, internalErr, "calcomblock.SomeHandler")
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, "Internal Server Error") {
t.Errorf("expected \"Internal Server Error\" in body, got: %q", responseBody)
}
if strings.Contains(responseBody, "secret internal detail") {
t.Errorf("response leaks internal error: %q", responseBody)
}
}
// TestHandleSaveSettings_SuccessPath verifies the success branch works correctly.
func TestHandleSaveSettings_SuccessPath(t *testing.T) {
h, _ := newTestHandler()
body, _ := json.Marshal(map[string]string{"api_key": "cal_live_abc123"})
req := httptest.NewRequest(http.MethodPost, "/settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSaveSettings(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if success, _ := resp["success"].(bool); !success {
t.Errorf("expected success=true in response, got: %v", resp)
}
}
// TestHandleSaveSettings_InvalidBody verifies that a malformed request body
// returns 400 Bad Request.
func TestHandleSaveSettings_InvalidBody(t *testing.T) {
h, _ := newTestHandler()
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader("{bad json}"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleSaveSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rec.Code)
}
}
// TestSettingsManager_APIKeyPersistsAcrossInstances ensures a new SettingsManager
// backed by the same querier still sees a previously stored API key — verifying
// the rewrite genuinely persists rather than holding the value in memory.
func TestSettingsManager_APIKeyPersistsAcrossInstances(t *testing.T) {
q := newFakeQuerier()
c := helpers.NewPluginCrypto("test-secret-key-for-unit-test!!")
a := NewSettingsManager(c, q)
if err := a.SetAPIKey(context.Background(), "cal_live_persisted"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
// Build a fresh manager over the same querier — simulates server restart.
b := NewSettingsManager(c, q)
got, err := b.GetAPIKey(context.Background())
if err != nil {
t.Fatalf("GetAPIKey: %v", err)
}
if got != "cal_live_persisted" {
t.Errorf("expected persisted key 'cal_live_persisted', got %q", got)
}
}
// ---------------------------------------------------------------------------
// WO-043 — HandleGetSlots error paths (no Cal.com call needed for these paths)
// ---------------------------------------------------------------------------
// TestHandleGetSlots_MissingParams verifies that a GET request missing required
// params (username, eventType, date) causes HandleGetSlots to render an HTML
// error rather than attempt any Cal.com request. The apiKey must be set so the
// handler reaches the param-check logic.
func TestHandleGetSlots_MissingParams(t *testing.T) {
h, q := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
_ = q
// Missing username, eventType, and date.
req := httptest.NewRequest(http.MethodGet, "/slots?blockId=b1", nil)
rec := httptest.NewRecorder()
h.HandleGetSlots(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Missing required parameters") {
t.Errorf("expected 'Missing required parameters' in body, got: %q", body)
}
}
// TestHandleGetSlots_NoAPIKey verifies that when no API key is configured the
// handler renders a "not configured" error without making any Cal.com request.
func TestHandleGetSlots_NoAPIKey(t *testing.T) {
h, _ := newTestHandler()
// No apiKey stored — leave settings empty.
req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-05-27&blockId=b1", nil)
rec := httptest.NewRecorder()
h.HandleGetSlots(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "not configured") {
t.Errorf("expected 'not configured' in body, got: %q", body)
}
}
// TestHandleGetSlots_InvalidDate verifies that a date param that doesn't match
// YYYY-MM-DD produces a "Invalid date format" error rendered in HTML.
func TestHandleGetSlots_InvalidDate(t *testing.T) {
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=not-a-date&blockId=b1", nil)
rec := httptest.NewRecorder()
h.HandleGetSlots(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Invalid date format") {
t.Errorf("expected 'Invalid date format' in body, got: %q", body)
}
}
// TestHandleGetSlots_HappyPath — TODO: requires a live-or-mocked Cal.com
// endpoint because the handler constructs its own client internally via
// NewCalcomClient(apiKey). Injecting a fake transport at handler level is out
// of scope for WO-043. The client-level happy-path coverage exists in
// client_test.go (TestGetSlots_URLAndHeaders, TestGetSlots_ParsesResponse).
// TestHandleGetSlots_TimezonePassthrough — TODO: same constraint as HappyPath.
// Timezone propagation is covered at the client layer in
// TestGetSlots_URLAndHeaders which verifies the timeZone query param.
// ---------------------------------------------------------------------------
// WO-044 — Anti-abuse: honeypot, invalid email, rate limit
// ---------------------------------------------------------------------------
// TestHandleCreateBooking_HoneypotShortCircuits verifies that a POST with the
// honeypot field ("bn_message_extra") populated never reaches Cal.com (hitCount stays 0)
// and instead returns a fake 200 confirmation to fool the bot.
func TestHandleCreateBooking_HoneypotShortCircuits(t *testing.T) {
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"bn_message_extra": {"spam"}, // honeypot tripped
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (bot thinks it succeeded), got %d", rec.Code)
}
// The honeypot path renders CalcomConfirmation — it contains "Booking Confirmed!"
body := rec.Body.String()
if !strings.Contains(body, "Booking Confirmed") {
t.Errorf("expected honeypot response to contain 'Booking Confirmed', got: %q", body)
}
}
// TestHandleCreateBooking_InvalidEmail verifies that an email address failing
// the regex check returns a rendered HTML error with "Invalid email" text.
// The email check happens BEFORE rate-limit consumption (spec § 3.8), so a
// flood of bad-email requests cannot drain a victim IP's quota.
func TestHandleCreateBooking_InvalidEmail(t *testing.T) {
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
for _, badEmail := range []string{"", "not-an-email", "@example.com", "x@"} {
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"name": {"Bob"},
"email": {badEmail},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("email=%q: expected 200 with error body, got %d", badEmail, rec.Code)
}
body := rec.Body.String()
// Empty email triggers "Name and email are required"; others trigger "Invalid email address"
if !strings.Contains(body, "email") && !strings.Contains(body, "Email") {
t.Errorf("email=%q: expected email-related error in body, got: %q", badEmail, body)
}
}
}
// validBookingFormRequest builds a POST request whose form passes every cheap
// validation (name+email present, email regex). The Cal.com call therefore
// fires, so callers must point calcomBaseURL at a fake server via
// withCalcomBaseURL.
func validBookingFormRequest(t *testing.T, ip string) *http.Request {
t.Helper()
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if ip != "" {
req.Header.Set("X-Forwarded-For", ip)
}
return req
}
// TestHandleCreateBooking_BadEmailDoesNotConsumeRateLimit guards the spec § 3.8
// requirement that the email regex runs BEFORE rate-limit consumption. Without
// this ordering, a single malicious actor could drain an honest user's IP
// quota by spraying invalid-email requests.
func TestHandleCreateBooking_BadEmailDoesNotConsumeRateLimit(t *testing.T) {
// Fake Cal.com — counts booking creations. Garbage-email requests must
// never reach this server; only the final honest request may. (The honest
// request also triggers the event-timezone lookup — /schedules — which is
// not a booking and not counted.)
var calcomHits atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/bookings") {
calcomHits.Add(1)
}
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
const ip = "203.0.113.99"
// bookingsPerHourPerIP+1 garbage-email requests — each rejected at the
// email-regex check, no rate-limit slots consumed, no Cal.com calls.
for i := range bookingsPerHourPerIP + 1 {
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"name": {"Bot"},
"email": {"not-an-email"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", ip)
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("garbage-email attempt %d was rate-limited — email regex must run before rate-limit per spec § 3.8", i+1)
}
}
if hits := calcomHits.Load(); hits != 0 {
t.Fatalf("garbage-email phase hit Cal.com %d times — should be 0", hits)
}
// Final honest request — passes regex, consumes first rate-limit slot,
// reaches Cal.com. Asserts the bucket was untouched by the garbage flood.
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, validBookingFormRequest(t, ip))
if rec.Code == http.StatusTooManyRequests {
t.Errorf("honest request was rate-limited because garbage-email requests burned the bucket (spec § 3.8 violation)")
}
if hits := calcomHits.Load(); hits != 1 {
t.Errorf("expected exactly 1 Cal.com hit (the honest request), got %d", hits)
}
}
// TestHandleCreateBooking_RateLimitAfter10 confirms the per-IP cap: 10 valid
// requests from the same IP pass, the 11th gets 429. The fake Cal.com is
// configured to return success so every legitimate request consumes one slot.
func TestHandleCreateBooking_RateLimitAfter10(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u","rescheduleUrl":"r","cancelUrl":"c"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
const testIP = "203.0.113.42" // TEST-NET-3, safe for tests
for i := range bookingsPerHourPerIP {
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, validBookingFormRequest(t, testIP))
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("attempt %d was rate-limited too early (expected first %d to be allowed)", i+1, bookingsPerHourPerIP)
}
}
// 11th request — must be rate-limited.
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, validBookingFormRequest(t, testIP))
if rec.Code != http.StatusTooManyRequests {
t.Errorf("11th attempt: expected 429, got %d (body: %q)", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// WO-045 — routeBookingForm unit tests (field-routing correctness)
// ---------------------------------------------------------------------------
// TestRouteBookingForm_BuiltinExcluded verifies control fields and the attendee
// phone are kept out of bookingFieldsResponses, the phone routes to
// attendee.phoneNumber (E.164-normalized), and only genuinely custom keys
// survive as responses.
func TestRouteBookingForm_BuiltinExcluded(t *testing.T) {
form := map[string][]string{
// Control/built-ins that must be excluded from responses:
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T10:00:00Z"},
"timezone": {"America/Toronto"},
"bn_message_extra": {""}, // honeypot (empty)
"name": {"Bob"},
"email": {"bob@example.com"},
"phone": {"+1-555-1234"},
// Custom fields that MUST survive:
"q1": {"yes"},
"custom_field": {"custom_value"},
}
got := routeBookingForm(form, "")
if got.Responses == nil {
t.Fatal("expected non-nil responses when custom fields are present")
}
for _, builtin := range []string{"blockId", "username", "eventType", "start", "timezone", "bn_message_extra", "name", "email", "phone"} {
if _, present := got.Responses[builtin]; present {
t.Errorf("built-in field %q must not appear in bookingFieldsResponses", builtin)
}
}
// Phone routes to the attendee, separators stripped.
if got.Phone != "+15551234" {
t.Errorf("expected attendee phone '+15551234', got: %q", got.Phone)
}
if got.Responses["q1"] != "yes" {
t.Errorf("expected q1='yes', got: %v", got.Responses["q1"])
}
if got.Responses["custom_field"] != "custom_value" {
t.Errorf("expected custom_field='custom_value', got: %v", got.Responses["custom_field"])
}
}
// TestRouteBookingForm_MultiValue verifies that form values with multiple
// entries (multi-select checkboxes) are preserved as a JSON array rather than
// collapsed to a single string.
func TestRouteBookingForm_MultiValue(t *testing.T) {
form := map[string][]string{
"topics": {"go", "rust", "zig"}, // multiple selections
}
got := routeBookingForm(form, "")
if got.Responses == nil {
t.Fatal("expected non-nil responses")
}
vs, ok := got.Responses["topics"].([]any)
if !ok {
t.Fatalf("expected topics to be []any, got %T: %v", got.Responses["topics"], got.Responses["topics"])
}
if len(vs) != 3 || vs[0] != "go" || vs[1] != "rust" || vs[2] != "zig" {
t.Errorf("multi-value slice wrong: %v", vs)
}
}
// TestRouteBookingForm_EmptyReturnsNil verifies that a form containing only
// control fields yields nil responses so the omitempty JSON tag drops the field.
func TestRouteBookingForm_EmptyReturnsNil(t *testing.T) {
form := map[string][]string{
"blockId": {"b1"},
"username": {"alice"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
got := routeBookingForm(form, "")
if got.Responses != nil {
t.Errorf("expected nil responses when only control fields present, got: %v", got.Responses)
}
}
// ---------------------------------------------------------------------------
// WO-046 — HandleGetForm default rendering + admin endpoint shapes
// ---------------------------------------------------------------------------
// TestHandleGetForm_NoBookingFieldsRendersDefaults verifies that when no API
// key is configured (so the Cal.com event-type fetch is skipped), the form
// falls back to rendering the default name + email fields.
func TestHandleGetForm_NoBookingFieldsRendersDefaults(t *testing.T) {
h, _ := newTestHandler()
// No apiKey set — handler skips GetEventType and uses default fields.
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-05-27T10:00:00Z&date=2026-05-27&username=alice&eventType=30min&timezone=UTC",
nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `name="name"`) {
t.Errorf("expected name input in default form, body: %q", body)
}
if !strings.Contains(body, `name="email"`) {
t.Errorf("expected email input in default form, body: %q", body)
}
}
// TestHandleListEventTypes_NotConfigured verifies the JSON error shape when no
// API key has been stored. No outbound request should be made.
func TestHandleListEventTypes_NotConfigured(t *testing.T) {
h, _ := newTestHandler()
// No apiKey — handler returns the "not configured" JSON response.
req := httptest.NewRequest(http.MethodGet, "/event-types?username=alice", nil)
rec := httptest.NewRecorder()
h.HandleListEventTypes(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
var got map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if success, _ := got["success"].(bool); success {
t.Errorf("expected success=false, got: %v", got)
}
if errMsg, _ := got["error"].(string); errMsg != "API key not configured" {
t.Errorf("expected error='API key not configured', got: %q", errMsg)
}
}
// TestHandleGetSettings_ShapeWhenEmpty verifies the JSON shape when no Cal.com
// settings have been configured: api_key_configured=false, webhook_url present,
// webhook_secret_configured=false.
func TestHandleGetSettings_ShapeWhenEmpty(t *testing.T) {
h, _ := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if v, _ := got["api_key_configured"].(bool); v {
t.Errorf("expected api_key_configured=false, got true")
}
if _, present := got["webhook_url"]; !present {
t.Errorf("expected webhook_url field in response, got: %v", got)
}
if v, _ := got["webhook_secret_configured"].(bool); v {
t.Errorf("expected webhook_secret_configured=false, got true")
}
}
// TestHandleGetSettings_ShapeWhenConfigured verifies that after storing an API
// key and a webhook secret the settings response exposes masked values and sets
// the boolean flags to true.
func TestHandleGetSettings_ShapeWhenConfigured(t *testing.T) {
h, _ := newTestHandler()
ctx := context.Background()
if err := h.settings.SetAPIKey(ctx, "cal_live_abc123456789"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
if err := h.settings.SetWebhookSecret(ctx, "deadbeefdeadbeefdeadbeefdeadbeef00000000000000000000000000000000"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if v, _ := got["api_key_configured"].(bool); !v {
t.Errorf("expected api_key_configured=true, got false; resp=%v", got)
}
if _, present := got["api_key_masked"]; !present {
t.Errorf("expected api_key_masked field in response")
}
if v, _ := got["webhook_secret_configured"].(bool); !v {
t.Errorf("expected webhook_secret_configured=true, got false; resp=%v", got)
}
if _, present := got["webhook_secret_masked"]; !present {
t.Errorf("expected webhook_secret_masked field in response")
}
}
// TestHandleSaveSettings_PersistsUsernameFromMe asserts that a successful API
// key save fires Cal.com /v2/me and persists the resolved username so the
// block editor doesn't have to ask the admin to type it.
func TestHandleSaveSettings_PersistsUsernameFromMe(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/me" {
t.Errorf("unexpected upstream path %q (want /me)", r.URL.Path)
}
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
body := bytes.NewBufferString(`{"api_key":"cal_live_test"}`)
req := httptest.NewRequest(http.MethodPost, "/settings", body)
rec := httptest.NewRecorder()
h.HandleSaveSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
got, err := h.settings.GetUsername(context.Background())
if err != nil {
t.Fatalf("GetUsername: %v", err)
}
if got != "alice" {
t.Errorf("expected username alice, got %q", got)
}
}
// TestHandleSaveSettings_MeFailureDoesNotBlockSave guarantees that a transient
// Cal.com /me failure does NOT cause the API key save to fail. The key is
// still persisted; username is left empty for the admin to refresh later.
func TestHandleSaveSettings_MeFailureDoesNotBlockSave(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
body := bytes.NewBufferString(`{"api_key":"cal_live_test"}`)
req := httptest.NewRequest(http.MethodPost, "/settings", body)
rec := httptest.NewRecorder()
h.HandleSaveSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 even when /me is down, got %d: %s", rec.Code, rec.Body.String())
}
gotKey, err := h.settings.GetAPIKey(context.Background())
if err != nil {
t.Fatalf("GetAPIKey: %v", err)
}
if gotKey != "cal_live_test" {
t.Errorf("API key should still be saved, got %q", gotKey)
}
gotUser, _ := h.settings.GetUsername(context.Background())
if gotUser != "" {
t.Errorf("username should be empty when /me failed, got %q", gotUser)
}
}
// TestHandleSaveSettings_ClearingKeyClearsUsername confirms that posting an
// empty api_key both clears the key and the cached username so the next save
// re-derives identity from the fresh key.
func TestHandleSaveSettings_ClearingKeyClearsUsername(t *testing.T) {
h, _ := newTestHandler()
ctx := context.Background()
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
if err := h.settings.SetUsername(ctx, "alice"); err != nil {
t.Fatalf("SetUsername: %v", err)
}
body := bytes.NewBufferString(`{"api_key":""}`)
req := httptest.NewRequest(http.MethodPost, "/settings", body)
rec := httptest.NewRecorder()
h.HandleSaveSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
got, _ := h.settings.GetUsername(ctx)
if got != "" {
t.Errorf("username should be cleared on key removal, got %q", got)
}
}
// TestHandleGetSettings_IncludesUsername confirms the JSON response surfaces
// the cached username so the block editor can stamp it into content without
// asking the admin to type it.
func TestHandleGetSettings_IncludesUsername(t *testing.T) {
h, _ := newTestHandler()
ctx := context.Background()
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
if err := h.settings.SetUsername(ctx, "alice"); err != nil {
t.Fatalf("SetUsername: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got["username"] != "alice" {
t.Errorf("expected username=alice in response, got %v", got["username"])
}
}
// TestHandleGetSettings_BackfillsUsernameWhenMissing covers the upgrade path for
// tenants whose API key was saved BEFORE the auto-username feature shipped: the
// key is present, username is empty, so HandleGetSettings lazily fires /v2/me
// and persists the result. Without this self-heal step those tenants would have
// to manually clear and re-save the key.
func TestHandleGetSettings_BackfillsUsernameWhenMissing(t *testing.T) {
var meHits atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/me" {
t.Errorf("unexpected upstream path %q (want /me)", r.URL.Path)
}
meHits.Add(1)
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
ctx := context.Background()
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
// Deliberately leave username unset to simulate a pre-feature save.
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got["username"] != "alice" {
t.Errorf("expected username=alice in response, got %v", got["username"])
}
persisted, _ := h.settings.GetUsername(ctx)
if persisted != "alice" {
t.Errorf("expected backfill to persist username, got %q", persisted)
}
// A second GET must NOT re-fire /me — the cache is now warm.
rec2 := httptest.NewRecorder()
h.HandleGetSettings(rec2, req)
if hits := meHits.Load(); hits != 1 {
t.Errorf("expected exactly 1 /me hit after both GETs, got %d", hits)
}
}
// TestHandleGetSettings_NoBackfillWhenNoAPIKey guards against the backfill
// firing /me with an empty key — that would 401 every GET on an unconfigured
// plugin.
func TestHandleGetSettings_NoBackfillWhenNoAPIKey(t *testing.T) {
var meHits atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
meHits.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if hits := meHits.Load(); hits != 0 {
t.Errorf("expected zero /me hits without an API key, got %d", hits)
}
}
// TestHandleGetSettings_BackfillFailureIsNonFatal asserts that a /me error
// during backfill never prevents settings from rendering — the response is
// still 200 with username left empty.
func TestHandleGetSettings_BackfillFailureIsNonFatal(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
ctx := context.Background()
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got["username"] != "" {
t.Errorf("expected empty username on /me failure, got %v", got["username"])
}
}
// TestSettingsManager_UsernamePersists round-trips username through the in-memory
// settings store: empty by default, persisted via SetUsername, read back via
// GetUsername. Username is NOT a secret so it is stored as plain text.
func TestSettingsManager_UsernamePersists(t *testing.T) {
s, _ := newTestSettings()
ctx := context.Background()
got, err := s.GetUsername(ctx)
if err != nil {
t.Fatalf("GetUsername on empty store: %v", err)
}
if got != "" {
t.Errorf("expected empty username, got %q", got)
}
if err := s.SetUsername(ctx, "alice"); err != nil {
t.Fatalf("SetUsername: %v", err)
}
got, err = s.GetUsername(ctx)
if err != nil {
t.Fatalf("GetUsername after set: %v", err)
}
if got != "alice" {
t.Errorf("Username round-trip: got %q, want alice", got)
}
}
// TestSettingsManager_SetUsernameEmptyClears confirms that passing "" deletes
// the persisted username so the admin can effectively "forget" the cached
// owner on demand (e.g. after rotating the API key).
func TestSettingsManager_SetUsernameEmptyClears(t *testing.T) {
s, _ := newTestSettings()
ctx := context.Background()
if err := s.SetUsername(ctx, "alice"); err != nil {
t.Fatalf("SetUsername: %v", err)
}
if err := s.SetUsername(ctx, ""); err != nil {
t.Fatalf("SetUsername empty: %v", err)
}
got, err := s.GetUsername(ctx)
if err != nil {
t.Fatalf("GetUsername: %v", err)
}
if got != "" {
t.Errorf("expected cleared username, got %q", got)
}
}

24
internal/db/db.go Normal file
View File

@ -0,0 +1,24 @@
// Package db vendors the minimal subset of the CMS-internal sqlc types the
// calcomblock plugin references for its settings storage. A standalone wasm
// plugin may not import git.dev.alexdunmow.com/block/cms/internal/db, so the
// two structs the plugin actually touches (only the Key + Value fields) are
// reproduced here.
//
// Provenance: cms/backend/internal/db/{models.go (Setting),
// settings.sql.go (UpsertSettingParams)}. The upstream structs also carry
// UpdatedAt/UpdatedBy columns; the plugin never reads them, so they are
// intentionally omitted from this vendored subset.
package db
// Setting mirrors the CMS `settings` row as far as the plugin consumes it.
type Setting struct {
Key string `json:"key"`
Value []byte `json:"value"`
}
// UpsertSettingParams mirrors the upstream sqlc UpsertSetting params the plugin
// constructs.
type UpsertSettingParams struct {
Key string `json:"key"`
Value []byte `json:"value"`
}

197
internal/helpers/helpers.go Normal file
View File

@ -0,0 +1,197 @@
// Package helpers vendors the small set of utility functions the calcomblock
// plugin previously imported from the CMS-internal package
// git.dev.alexdunmow.com/block/cms/internal/helpers. A standalone wasm plugin
// may only import git.dev.alexdunmow.com/block/core/... — never the CMS
// internals — so these pure, self-contained helpers are copied here verbatim
// (provenance: cms/backend/internal/helpers/{http,cleanup,maps,plugin_settings}.go).
//
// Re-copy on upstream changes; nothing here touches the CMS DB or capabilities.
package helpers
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
)
// GetRealIP extracts the client IP from proxy headers, trusting the rightmost
// X-Forwarded-For entry (added by our trusted reverse proxy), then X-Real-IP,
// then CF-Connecting-IP, then RemoteAddr.
func GetRealIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" {
return ip
}
}
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
if cfip := r.Header.Get("CF-Connecting-IP"); cfip != "" {
return cfip
}
ip := r.RemoteAddr
if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 {
if !strings.Contains(ip, "[") || strings.Contains(ip[colonIdx:], "]") {
ip = ip[:colonIdx]
}
}
ip = strings.TrimPrefix(ip, "[")
ip = strings.TrimSuffix(ip, "]")
return ip
}
// CleanupFunc is a function that performs cleanup work.
type CleanupFunc func()
// StartCleanupLoop starts a background goroutine that calls cleanupFn at the
// given interval until the context is cancelled.
func StartCleanupLoop(ctx context.Context, interval time.Duration, cleanupFn CleanupFunc) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleanupFn()
}
}
}()
}
// GetStringOr returns a string value from a map, or defaultVal if not
// found/wrong type.
func GetStringOr(m map[string]any, key, defaultVal string) string {
if v, ok := m[key].(string); ok {
return v
}
return defaultVal
}
// MaskSecret returns a masked version of a secret for display, showing the
// first 7 and last 4 characters. Example: "cal_live_1234567890abcdef" ->
// "cal_liv...cdef".
func MaskSecret(secret string) string {
if len(secret) <= 11 {
return "***"
}
return secret[:7] + "..." + secret[len(secret)-4:]
}
// PluginSettingsQuerier is the interface required for plugin settings storage.
// The production implementation is capability-backed (see settings_store.go);
// tests inject an in-memory fake.
type PluginSettingsQuerier interface {
GetSetting(ctx context.Context, key string) (db.Setting, error)
UpsertSetting(ctx context.Context, arg db.UpsertSettingParams) (db.Setting, error)
}
// GetPluginSettings retrieves settings for a plugin, stored under key
// "plugin:{pluginName}". Returns an empty map when none exist.
func GetPluginSettings(ctx context.Context, q PluginSettingsQuerier, pluginName string) (map[string]any, error) {
key := "plugin:" + pluginName
setting, err := q.GetSetting(ctx, key)
if err != nil {
return make(map[string]any), nil
}
var result map[string]any
if err := json.Unmarshal(setting.Value, &result); err != nil {
return nil, fmt.Errorf("failed to parse plugin settings: %w", err)
}
if result == nil {
result = make(map[string]any)
}
return result, nil
}
// SetPluginSettings persists settings for a plugin under key
// "plugin:{pluginName}".
func SetPluginSettings(ctx context.Context, q PluginSettingsQuerier, pluginName string, settings map[string]any) error {
key := "plugin:" + pluginName
value, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to serialize plugin settings: %w", err)
}
_, err = q.UpsertSetting(ctx, db.UpsertSettingParams{Key: key, Value: value})
return err
}
// PluginCrypto provides AES-256-GCM encryption/decryption for plugin secrets.
// The production path uses the SDK-provided crypto.Crypto; this is retained for
// the plugin's unit tests, which need a concrete implementation.
type PluginCrypto struct {
encryptionKey []byte
}
// NewPluginCrypto creates a PluginCrypto whose key is padded/truncated to 32
// bytes for AES-256.
func NewPluginCrypto(secretKey string) *PluginCrypto {
key := make([]byte, 32)
copy(key, []byte(secretKey))
return &PluginCrypto{encryptionKey: key}
}
// EncryptSecret encrypts a value with AES-256-GCM, returning hex.
func (c *PluginCrypto) EncryptSecret(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(ciphertext), nil
}
// DecryptSecret decrypts a hex AES-256-GCM ciphertext, falling back to the
// input verbatim for legacy unencrypted values.
func (c *PluginCrypto) DecryptSecret(cipherHex string) (string, error) {
if cipherHex == "" {
return "", nil
}
ciphertext, err := hex.DecodeString(cipherHex)
if err != nil {
return cipherHex, nil
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create gcm: %w", err)
}
if len(ciphertext) < gcm.NonceSize() {
return cipherHex, nil
}
nonce := ciphertext[:gcm.NonceSize()]
ciphertext = ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return cipherHex, nil
}
return string(plaintext), nil
}

13
main.go Normal file
View File

@ -0,0 +1,13 @@
//go:build wasip1
// Package main compiles the Cal.com Booking plugin to a wasip1 reactor-mode
// wasm guest. `ninja plugin build` produces the .bnp artifact the registry
// serves; the host runs `_initialize` once per pooled instance, invoking this
// init (hence wasmguest.Serve) before any ABI hook call. main is never called.
package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) }
func main() {} // never called — reactor mode

123
phone_region.go Normal file
View File

@ -0,0 +1,123 @@
package main
import "strings"
// regionFromLocale extracts the ISO 3166-1 alpha-2 region from a BCP-47 locale
// tag such as the value of navigator.language ("en-AU" -> "AU", "zh-Hans-CN" ->
// "CN"). The first subtag is the language unless it is itself an uppercase
// 2-letter region (a bare "AU"). Returns "" when no region subtag is present
// ("en" -> "").
func regionFromLocale(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
parts := strings.FieldsFunc(locale, func(r rune) bool { return r == '-' || r == '_' })
for i, part := range parts {
if len(part) != 2 || !isASCIILetters(part) {
continue // language (handled below), script ("Hans"), or variant
}
// Subtag 0 is the language unless it is already an uppercase region.
if i == 0 && part != strings.ToUpper(part) {
continue
}
return strings.ToUpper(part)
}
return ""
}
// isASCIILetters reports whether s is non-empty and all ASCII letters.
func isASCIILetters(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
return false
}
}
return true
}
// tzRegions maps IANA timezones to ISO 3166-1 alpha-2 regions for the common,
// unambiguous cases. The device timezone is a stronger locator than the UI
// locale (an en-US browser in Australia still reports Australia/*), so it is
// preferred. Ambiguous zones (most America/*) are intentionally omitted — those
// fall through to the locale. Australia/* is matched by prefix in
// regionFromTimezone.
var tzRegions = map[string]string{
"Pacific/Auckland": "NZ", "Pacific/Chatham": "NZ",
"Europe/London": "GB", "Europe/Dublin": "IE", "Europe/Paris": "FR",
"Europe/Berlin": "DE", "Europe/Madrid": "ES", "Europe/Rome": "IT",
"Europe/Amsterdam": "NL", "Europe/Brussels": "BE", "Europe/Zurich": "CH",
"Europe/Stockholm": "SE", "Europe/Oslo": "NO", "Europe/Copenhagen": "DK",
"Asia/Singapore": "SG", "Asia/Kolkata": "IN", "Asia/Calcutta": "IN",
"Asia/Tokyo": "JP", "Asia/Hong_Kong": "HK", "Asia/Dubai": "AE",
"Asia/Shanghai": "CN", "Asia/Seoul": "KR", "Africa/Johannesburg": "ZA",
}
// regionFromTimezone maps an IANA timezone (navigator-detected, e.g.
// "Australia/Perth") to a region for the common cases. Returns "" for
// unknown/ambiguous zones (including the UTC fallback some browsers report).
func regionFromTimezone(tz string) string {
tz = strings.TrimSpace(tz)
if tz == "" {
return ""
}
if strings.HasPrefix(tz, "Australia/") {
return "AU"
}
return tzRegions[tz]
}
// resolveRegion picks the ISO region used to pre-select the phone country
// combobox and to seed E.164 normalization when the visitor has not explicitly
// picked a country. It prefers the timezone region (strongest locator), then the
// browser locale region, validated against the known country list. Returns ""
// when neither is known — the combobox then has no default and normalizePhone
// only handles already-international numbers.
func resolveRegion(locale, timezone string) string {
for _, r := range []string{regionFromTimezone(timezone), regionFromLocale(locale)} {
if r != "" {
if _, ok := countryByCode(r); ok {
return r
}
}
}
return ""
}
// countryByCode returns the country with the given ISO alpha-2 code, or false.
func countryByCode(code string) (Country, bool) {
code = strings.ToUpper(strings.TrimSpace(code))
for _, c := range countries {
if c.Code == code {
return c, true
}
}
return Country{}, false
}
// countryFlag returns the emoji flag for a 2-letter ISO region code by mapping
// each letter to its Unicode regional-indicator symbol. "" for invalid codes.
func countryFlag(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if len(code) != 2 || !isASCIILetters(code) {
return ""
}
r := []rune(code)
return string(rune(0x1F1E6+(r[0]-'A'))) + string(rune(0x1F1E6+(r[1]-'A')))
}
// countryLabel renders the human label shown in the combobox and its pre-fill,
// e.g. "🇦🇺 Australia (+61)". Returns "" when the code is unknown.
func countryLabel(code string) string {
c, ok := countryByCode(code)
if !ok {
return ""
}
if flag := countryFlag(c.Code); flag != "" {
return flag + " " + c.Name + " (" + c.Dial + ")"
}
return c.Name + " (" + c.Dial + ")"
}

9
plugin.mod Normal file
View File

@ -0,0 +1,9 @@
[plugin]
name = "calcomblock"
display_name = "Cal.com Booking"
scope = "ninja"
version = "2.0.0"
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"
categories = ["forms"]
tags = ["calcom", "booking", "calendar", "scheduling", "appointments"]

61
ratelimit.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"sync"
"time"
)
// RateLimiter is a per-IP fixed-window counter used to throttle the public
// /book endpoint. It is intentionally simple — in-memory, single-process —
// matching the single-tenant deployment model. A multi-replica deployment
// would need a shared store (Redis or DB) before this limiter is meaningful.
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*rlBucket
perHour int
}
type rlBucket struct {
count int
resetsAt time.Time
}
// NewRateLimiter returns a limiter that allows perHour requests per IP in any
// rolling 1-hour window (resetting after the first request in a fresh window).
func NewRateLimiter(perHour int) *RateLimiter {
return &RateLimiter{
buckets: make(map[string]*rlBucket),
perHour: perHour,
}
}
// Allow consumes one allowance for ip; returns false when the window's quota
// is exhausted.
func (r *RateLimiter) Allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b := r.buckets[ip]
if b == nil || now.After(b.resetsAt) {
r.buckets[ip] = &rlBucket{count: 1, resetsAt: now.Add(time.Hour)}
return true
}
if b.count >= r.perHour {
return false
}
b.count++
return true
}
// SweepStale drops buckets whose window has elapsed. Driven by
// helpers.StartCleanupLoop on a periodic schedule.
func (r *RateLimiter) SweepStale() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
for ip, b := range r.buckets {
if now.After(b.resetsAt) {
delete(r.buckets, ip)
}
}
}

85
ratelimit_test.go Normal file
View File

@ -0,0 +1,85 @@
package main
import (
"testing"
"time"
)
// TestRateLimiter_AllowsUpToCapAndBlocksRest verifies the WO-018 contract:
// 10 calls from the same IP succeed; the 11th is denied.
func TestRateLimiter_AllowsUpToCapAndBlocksRest(t *testing.T) {
rl := NewRateLimiter(10)
for i := 1; i <= 10; i++ {
if !rl.Allow("203.0.113.1") {
t.Fatalf("call %d unexpectedly denied", i)
}
}
if rl.Allow("203.0.113.1") {
t.Fatalf("11th call must be denied")
}
}
// TestRateLimiter_IsolatesByIP confirms that buckets are keyed per-IP.
func TestRateLimiter_IsolatesByIP(t *testing.T) {
rl := NewRateLimiter(2)
for i := range 2 {
if !rl.Allow("a") {
t.Fatalf("call %d from 'a' should succeed", i+1)
}
}
if rl.Allow("a") {
t.Fatalf("third call from 'a' should be denied")
}
if !rl.Allow("b") {
t.Fatalf("first call from 'b' should succeed even after 'a' is full")
}
}
// TestRateLimiter_WindowResetsAfterHour verifies the fixed-window semantics:
// once a bucket's resetsAt is in the past, the next Allow() call starts a
// fresh window with count=1. This guards against a regression where the
// limiter forgets to clear/reset stale buckets and locks out legitimate
// traffic indefinitely.
//
// We exercise this by mutating the bucket's resetsAt directly to a time in
// the past — equivalent to fast-forwarding the clock without waiting an hour.
// The helper accesses the unexported buckets map; this is safe because
// _test.go files compile inside the same package.
func TestRateLimiter_WindowResetsAfterHour(t *testing.T) {
rl := NewRateLimiter(3)
const ip = "198.51.100.7"
// Fill the bucket.
for i := range 3 {
if !rl.Allow(ip) {
t.Fatalf("call %d should succeed", i+1)
}
}
if rl.Allow(ip) {
t.Fatalf("4th call should be denied (bucket full)")
}
// Fast-forward the clock by pushing resetsAt into the past.
rl.mu.Lock()
b, ok := rl.buckets[ip]
if !ok {
rl.mu.Unlock()
t.Fatalf("bucket missing for ip %s after filling", ip)
}
b.resetsAt = time.Now().Add(-time.Hour - time.Second)
rl.mu.Unlock()
// Next call must reset the window and succeed.
if !rl.Allow(ip) {
t.Fatalf("Allow() must reset the bucket after window expiry — got false")
}
// Bucket count must be 1 (fresh window), not 4.
rl.mu.Lock()
got := rl.buckets[ip].count
rl.mu.Unlock()
if got != 1 {
t.Errorf("bucket count after reset: got %d, want 1 (fresh window)", got)
}
}

64
register.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"embed"
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
)
//go:embed all:web/dist/assets
var distAssets embed.FS
//go:embed schemas
var schemasFS embed.FS
//go:embed plugin.mod
var pluginModBytes []byte
// Register is the plugin entry point.
// Returns an error if registration fails.
func Register(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
br.Register(CalcomBookingMeta, CalcomBookingFunc)
return nil
}
// CalcomBookingMeta defines the Cal.com booking block metadata
var CalcomBookingMeta = blocks.BlockMeta{
Key: "calcom:booking",
Title: "Cal.com Booking",
Description: "Embeddable booking calendar with custom styling powered by Cal.com",
Category: blocks.CategoryContent,
Source: "calcomblock",
EditorJS: "remoteEntry.js",
}
// Schemas returns the embedded schemas filesystem
func Schemas() fs.FS {
subFS, _ := fs.Sub(schemasFS, "schemas")
return subFS
}
// AssetsHandler serves static assets including the Module Federation remote entry
func AssetsHandler() http.Handler {
subFS, err := fs.Sub(distAssets, "web/dist/assets")
if err != nil {
return http.NotFoundHandler()
}
return http.FileServer(http.FS(subFS))
}
// HTTPHandler returns the plugin's HTTP handler for Cal.com API endpoints.
// Mounted at /api/plugins/calcomblock/. deps carries Crypto + Pool.
func HTTPHandler(deps plugin.CoreServices) http.Handler {
return NewCalcomRouter(deps)
}
// SettingsPanel returns the path to the Module Federation settings component.
// The settings.tsx component is exposed at ./settings in the remoteEntry.js
func SettingsPanel() string {
return "remoteEntry.js"
}

23
registration.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
)
// Registration is the compile-time plugin registration for the Cal.com Booking block.
var Registration = plugin.PluginRegistration{
Name: "calcomblock",
Version: plugin.ParseModVersion(pluginModBytes),
Register: func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
return Register(tr, br)
},
Assets: func() http.Handler { return AssetsHandler() },
Schemas: func() fs.FS { return Schemas() },
HTTPHandler: func(deps plugin.CoreServices) http.Handler { return NewCalcomRouter(deps) },
SettingsPanel: func() string { return SettingsPanel() },
}

View File

@ -0,0 +1,56 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Cal.com Booking",
"description": "Embeddable booking calendar powered by Cal.com",
"properties": {
"username": {
"type": "string",
"title": "Cal.com Username",
"description": "Your Cal.com username (e.g., 'johndoe')",
"x-editor": "text"
},
"eventTypeSlug": {
"type": "string",
"title": "Event Type",
"description": "The event type slug (e.g., '30min', 'consultation')",
"x-editor": "text"
},
"title": {
"type": "string",
"title": "Widget Title",
"description": "Title shown above the booking widget",
"x-editor": "text"
},
"description": {
"type": "string",
"title": "Description",
"description": "Optional description text",
"x-editor": "textarea"
},
"weeksToShow": {
"type": "integer",
"title": "Weeks to Display",
"description": "Number of weeks to show in the calendar",
"default": 2,
"minimum": 1,
"maximum": 8,
"x-editor": "number"
},
"showTimezone": {
"type": "boolean",
"title": "Show Timezone",
"description": "Allow visitors to select their timezone",
"default": true,
"x-editor": "checkbox"
},
"captchaEnabled": {
"type": "boolean",
"title": "Enable captcha",
"description": "Require visitors to solve a privacy-friendly proof-of-work captcha before booking (spam protection)",
"default": false,
"x-editor": "checkbox"
}
},
"required": ["eventTypeSlug"]
}

247
settings_store.go Normal file
View File

@ -0,0 +1,247 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"strings"
"time"
"git.dev.alexdunmow.com/block/core/crypto"
"git.dev.alexdunmow.com/block/core/settings"
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
)
// SettingsManager persists Cal.com plugin settings through the SDK settings
// capabilities (a capabilityQuerier satisfying helpers.PluginSettingsQuerier in
// production; an in-memory fake in tests). Secrets are encrypted using the
// SDK-provided crypto.Crypto interface.
type SettingsManager struct {
crypto crypto.Crypto
queries helpers.PluginSettingsQuerier
}
// NewSettingsManager constructs a SettingsManager backed by the given crypto
// and querier. The querier must satisfy helpers.PluginSettingsQuerier.
func NewSettingsManager(c crypto.Crypto, q helpers.PluginSettingsQuerier) *SettingsManager {
return &SettingsManager{crypto: c, queries: q}
}
// GetAPIKey returns the decrypted Cal.com API key, or empty string if not configured.
func (m *SettingsManager) GetAPIKey(ctx context.Context) (string, error) {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return "", err
}
enc := helpers.GetStringOr(s, "api_key_encrypted", "")
if enc == "" {
return "", nil
}
return m.crypto.DecryptSecret(enc)
}
// SetAPIKey encrypts and stores the Cal.com API key. Pass an empty string to clear.
func (m *SettingsManager) SetAPIKey(ctx context.Context, key string) error {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return err
}
if key == "" {
delete(s, "api_key_encrypted")
} else {
enc, err := m.crypto.EncryptSecret(key)
if err != nil {
return err
}
s["api_key_encrypted"] = enc
}
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
}
// GetSettings returns the full settings blob. Secrets are NOT decrypted here —
// the caller is responsible for not exposing `api_key_encrypted` directly.
func (m *SettingsManager) GetSettings(ctx context.Context) (map[string]any, error) {
return helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
}
// GetUsername returns the Cal.com account username associated with the
// configured API key. Stored as plain text (not a secret); populated by
// HandleSaveSettings via CalcomClient.GetMe. Empty string when the API key
// has never been saved or the /me lookup has not yet succeeded.
func (m *SettingsManager) GetUsername(ctx context.Context) (string, error) {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return "", err
}
return helpers.GetStringOr(s, "username", ""), nil
}
// SetUsername persists the Cal.com account username. Pass an empty string to
// clear (e.g. when the API key is removed).
func (m *SettingsManager) SetUsername(ctx context.Context, username string) error {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return err
}
if username == "" {
delete(s, "username")
} else {
s["username"] = username
}
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
}
// GetSiteTimezone returns the site-wide IANA timezone configured under Site
// Settings → Localization, or "" when unset. Used as the fallback display
// timezone for booking times when the visitor's request carries none.
//
// Reads the "site" settings key through the querier, which resolves it via the
// SDK settings capability (settings.Settings.GetSiteSettings) host-side.
func (m *SettingsManager) GetSiteTimezone(ctx context.Context) string {
setting, err := m.queries.GetSetting(ctx, "site")
if err != nil {
return ""
}
var site struct {
Localization struct {
Timezone string `json:"timezone"`
} `json:"localization"`
}
if err := json.Unmarshal(setting.Value, &site); err != nil {
return ""
}
return strings.TrimSpace(site.Localization.Timezone)
}
// resolveLocation resolves the IANA timezone used to window and display
// booking times. Resolution order: the visitor-supplied name (browser-detected,
// user-controlled input) when it parses, else the site-wide Localization
// timezone from site settings, else UTC. A literal "UTC" request is treated as
// unset: it is the widget's never-updated sentinel default (pages cached from
// before timezone detection, no-JS visitors, bots), so the site timezone is a
// strictly better guess for a human visitor.
//
// Returns the location plus its canonical name for forwarding to Cal.com.
// Lives on SettingsManager (not the handler) so the initial block render —
// which has no *http.Request — can resolve via the same ladder.
func (m *SettingsManager) resolveLocation(ctx context.Context, requested string) (*time.Location, string) {
for _, name := range []string{strings.TrimSpace(requested), m.GetSiteTimezone(ctx)} {
if name == "" || strings.EqualFold(name, "UTC") {
continue
}
if loc, err := time.LoadLocation(name); err == nil {
return loc, name
}
}
return time.UTC, "UTC"
}
// GetWebhookSecret returns the decrypted webhook HMAC secret, or empty if none
// has been provisioned yet.
func (m *SettingsManager) GetWebhookSecret(ctx context.Context) (string, error) {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return "", err
}
enc := helpers.GetStringOr(s, "webhook_secret_encrypted", "")
if enc == "" {
return "", nil
}
return m.crypto.DecryptSecret(enc)
}
// SetWebhookSecret encrypts and persists the webhook HMAC secret. Pass an
// empty string to clear.
func (m *SettingsManager) SetWebhookSecret(ctx context.Context, secret string) error {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return err
}
if secret == "" {
delete(s, "webhook_secret_encrypted")
} else {
enc, err := m.crypto.EncryptSecret(secret)
if err != nil {
return err
}
s["webhook_secret_encrypted"] = enc
}
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
}
// UpdateLastEvent records that the webhook handler received a Cal.com event
// at the given time. Surfaced to the admin settings panel via GetSettings.
func (m *SettingsManager) UpdateLastEvent(ctx context.Context, triggerEvent, createdAt string) error {
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
if err != nil {
return err
}
s["last_event_type"] = triggerEvent
s["last_event_at"] = createdAt
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
}
// generateWebhookSecret returns a fresh 32-byte secret encoded as a 64-char
// hex string. Cryptographically random via crypto/rand.
func generateWebhookSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// capabilityQuerier adapts the SDK settings capabilities to
// helpers.PluginSettingsQuerier. Under the wasm ABI a plugin's Postgres role is
// sandboxed to its OWN schema, so the CMS `settings` table (public schema) is
// unreachable by direct SQL — settings persistence MUST go through the
// host-side capability interfaces (settings.Settings / settings.Updater), which
// perform the DB work on the host's behalf.
//
// It recognises the two keys the settings helpers request: "plugin:<name>"
// (round-tripped through Get/UpdatePluginSettings) and "site" (read-only via
// GetSiteSettings). Values are JSON, matching the shape the helpers expect from
// the old `settings.value` bytea column.
type capabilityQuerier struct {
settings settings.Settings
updater settings.Updater
}
func (q *capabilityQuerier) GetSetting(ctx context.Context, key string) (db.Setting, error) {
var (
m map[string]any
err error
)
if key == "site" {
m, err = q.settings.GetSiteSettings(ctx)
} else {
m, err = q.settings.GetPluginSettings(ctx, strings.TrimPrefix(key, "plugin:"))
}
if err != nil {
return db.Setting{}, err
}
value, err := json.Marshal(m)
if err != nil {
return db.Setting{}, err
}
return db.Setting{Key: key, Value: value}, nil
}
func (q *capabilityQuerier) UpsertSetting(ctx context.Context, arg db.UpsertSettingParams) (db.Setting, error) {
var m map[string]any
if len(arg.Value) > 0 {
if err := json.Unmarshal(arg.Value, &m); err != nil {
return db.Setting{}, err
}
}
if m == nil {
m = map[string]any{}
}
if err := q.updater.UpdatePluginSettings(ctx, strings.TrimPrefix(arg.Key, "plugin:"), m); err != nil {
return db.Setting{}, err
}
return db.Setting(arg), nil
}

400
timezone_test.go Normal file
View File

@ -0,0 +1,400 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// These tests pin the timezone contract for the public booking flow: every
// surface (slot list, form recap, confirmation, attendee record) renders in
// the MEETING's timezone — the event's Cal.com availability schedule — never
// the visitor's. Ladder: event schedule timezone → site Localization setting
// → UTC.
//
// Written against the bidbuddy.com.au incidents (2026-06-10). First: the
// handler windowed and rendered slots in UTC (Perth visitors saw "12:00 AM"),
// leaked the next day (Cal.com's end bound is date-granular), and assembled
// slots from an unsorted map range. Second: display depended on client-side
// timezone detection, which shipped a never-updated "UTC" sentinel on cached
// pages. The meeting timezone is a server-side fact; visitor state is out.
// seedSiteTimezone writes a site settings blob with the given localization
// timezone into the fake querier, mirroring Site Settings → Localization.
func seedSiteTimezone(q *fakeQuerier, tz string) {
q.mu.Lock()
defer q.mu.Unlock()
q.data["site"] = []byte(`{"localization":{"timezone":"` + tz + `"}}`)
}
// fakeCalcom builds a Cal.com test double serving the event-timezone
// resolution pair — /event-types (scheduleId: null → default schedule) and
// /schedules (the schedule carrying scheduleTZ) — and delegates every other
// path (slots, bookings) to rest. scheduleTZ "" makes /schedules fail with a
// 500, simulating Cal.com being unreachable for the timezone lookup. The
// double is installed as the package base URL for the duration of the test.
func fakeCalcom(t *testing.T, scheduleTZ string, rest http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/event-types"):
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`)
case strings.HasPrefix(r.URL.Path, "/schedules"):
if scheduleTZ == "" {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"`+scheduleTZ+`","isDefault":true}]}`)
default:
if rest != nil {
rest(w, r)
return
}
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
}
}))
t.Cleanup(srv.Close)
withCalcomBaseURL(t, srv.URL)
return srv
}
// newKeyedHandler returns a handler whose settings carry an API key — the
// precondition for the event-timezone fetch.
func newKeyedHandler(t *testing.T) (*CalcomHandler, *fakeQuerier) {
t.Helper()
h, q := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
return h, q
}
// TestHandleGetSlots_WindowBuiltInEventTimezone asserts the [start, end)
// window sent to Cal.com covers the selected date in the MEETING's timezone —
// resolved from the event's availability schedule, with no timezone hint on
// the request at all. For date=2026-06-11 and a Perth schedule the window
// must be 2026-06-11T00:00:00+08:00 .. 2026-06-12T00:00:00+08:00.
func TestHandleGetSlots_WindowBuiltInEventTimezone(t *testing.T) {
var gotQuery url.Values
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
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())
}
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
t.Errorf("outbound start: got %q, want 2026-06-11T00:00:00+08:00", got)
}
if got := gotQuery.Get("end"); got != "2026-06-12T00:00:00+08:00" {
t.Errorf("outbound end: got %q, want 2026-06-12T00:00:00+08:00", got)
}
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
t.Errorf("outbound timeZone: got %q, want Australia/Perth", got)
}
}
// TestHandleGetSlots_VisitorTimezoneParamIgnored pins the design decision: a
// timezone query param — whether a visitor's real browser zone or a stale
// cached page's sentinel — must NOT move the display away from the meeting's
// timezone. Availability is a fact about the host's calendar.
func TestHandleGetSlots_VisitorTimezoneParamIgnored(t *testing.T) {
var gotQuery url.Values
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
h, _ := newKeyedHandler(t)
req := httptest.NewRequest(http.MethodGet,
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1&timezone=America/New_York", 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())
}
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
t.Errorf("outbound timeZone: got %q, want Australia/Perth (visitor param must be ignored)", got)
}
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
t.Errorf("outbound start: got %q, want Perth-midnight window despite visitor param", got)
}
}
// TestHandleGetSlots_FiltersSlotsOutsideEventDay reproduces the production
// leak: Cal.com treats the end bound date-granularly and returns the entire
// next day too. Slots not falling on the requested date in the meeting's
// timezone must be dropped, not rendered under the wrong heading (where
// same-wall-clock times read as duplicates).
func TestHandleGetSlots_FiltersSlotsOutsideEventDay(t *testing.T) {
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
// Perth-local response shape with a leaked next-day group, as observed
// live on 2026-06-10 against api.cal.com.
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"2026-06-11":[{"start":"2026-06-11T08:00:00.000+08:00"},{"start":"2026-06-11T17:30:00.000+08:00"}],
"2026-06-12":[{"start":"2026-06-12T13:30:00.000+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)
body := rec.Body.String()
if !strings.Contains(body, "8:00 AM") {
t.Errorf("expected '8:00 AM' (Perth-local slot) in body, got: %q", body)
}
if !strings.Contains(body, "5:30 PM") {
t.Errorf("expected '5:30 PM' (Perth-local slot) in body, got: %q", body)
}
if strings.Contains(body, "1:30 PM") {
t.Errorf("next-day slot (2026-06-12 13:30) leaked into the 2026-06-11 view: %q", body)
}
}
// TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone asserts display times are
// converted into the meeting's timezone even when Cal.com returns UTC (Z)
// timestamps — display must never depend on the offset Cal.com happens to
// format with.
func TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone(t *testing.T) {
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
// 2026-06-11 00:00Z == 8:00 AM Perth; 09:30Z == 5:30 PM Perth.
_, _ = io.WriteString(w, `{
"status":"success",
"data":{"2026-06-11":[{"start":"2026-06-11T00:00:00.000Z"},{"start":"2026-06-11T09:30:00.000Z"}]}
}`)
})
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)
body := rec.Body.String()
if !strings.Contains(body, "8:00 AM") {
t.Errorf("expected UTC slot rendered as '8:00 AM' Perth time, got: %q", body)
}
if !strings.Contains(body, "5:30 PM") {
t.Errorf("expected UTC slot rendered as '5:30 PM' Perth time, got: %q", body)
}
if strings.Contains(body, "12:00 AM") {
t.Errorf("slot rendered as raw UTC wall time ('12:00 AM') instead of Perth time: %q", body)
}
}
// TestHandleGetSlots_SortsSlotsChronologically guards against Go map iteration
// order: a Perth-local day can span two UTC date keys, so slots assembled
// across keys must be explicitly sorted. (2026-06-10T23:00Z == 7:00 AM Perth
// on June 11; 2026-06-11T01:00:00Z == 9:00 AM Perth on June 11.)
func TestHandleGetSlots_SortsSlotsChronologically(t *testing.T) {
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"2026-06-10":[{"start":"2026-06-10T23:00:00.000Z"}],
"2026-06-11":[{"start":"2026-06-11T01:00:00.000Z"}]
}
}`)
})
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)
body := rec.Body.String()
early := strings.Index(body, "7:00 AM")
late := strings.Index(body, "9:00 AM")
if early == -1 || late == -1 {
t.Fatalf("expected both '7:00 AM' and '9:00 AM' in body, got: %q", body)
}
if early > late {
t.Errorf("slots out of chronological order: '7:00 AM' rendered after '9:00 AM'")
}
}
// TestHandleGetSlots_FallsBackToSiteTimezone asserts that when the event
// timezone can't be fetched (Cal.com schedules endpoint down), the site-wide
// Localization timezone is used instead of UTC.
func TestHandleGetSlots_FallsBackToSiteTimezone(t *testing.T) {
var gotQuery url.Values
fakeCalcom(t, "" /* schedules endpoint fails */, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
h, q := newKeyedHandler(t)
seedSiteTimezone(q, "Australia/Perth")
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())
}
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
t.Errorf("outbound timeZone: got %q, want Australia/Perth (site fallback)", got)
}
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
t.Errorf("outbound start: got %q, want Perth-midnight window", got)
}
}
// TestHandleGetSlots_InvalidEventTimezoneFallsBack asserts a garbage timezone
// on the Cal.com schedule degrades to the site timezone rather than erroring
// or silently using the bogus value.
func TestHandleGetSlots_InvalidEventTimezoneFallsBack(t *testing.T) {
var gotQuery url.Values
fakeCalcom(t, "Not/AZone", func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
h, q := newKeyedHandler(t)
seedSiteTimezone(q, "Australia/Perth")
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())
}
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
t.Errorf("outbound timeZone: got %q, want Australia/Perth (invalid event tz must fall back)", got)
}
}
// TestHandleGetForm_DisplayTimeInEventTimezone asserts the form's "selected
// time" recap renders in the meeting's timezone when the slot start arrives
// as a UTC timestamp.
func TestHandleGetForm_DisplayTimeInEventTimezone(t *testing.T) {
fakeCalcom(t, "Australia/Perth", nil)
h, _ := newKeyedHandler(t)
// 2026-06-11T01:00:00Z == 9:00 AM Australia/Perth.
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-11T01:00:00Z&date=2026-06-11&username=alice&eventType=30min", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "9:00 AM") {
t.Errorf("expected form recap '9:00 AM' (Perth), got: %q", body)
}
if strings.Contains(body, "1:00 AM") {
t.Errorf("form recap rendered raw UTC wall time ('1:00 AM'): %q", body)
}
}
// TestHandleCreateBooking_ConfirmationInEventTimezone asserts the booking
// confirmation panel renders the meeting time in the meeting's timezone, and
// that the attendee record sent to Cal.com carries the meeting timezone too —
// even when a stale cached page still submits a visitor timezone field.
func TestHandleCreateBooking_ConfirmationInEventTimezone(t *testing.T) {
var attendeeTZ string
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/bookings") {
var payload struct {
Attendee struct {
TimeZone string `json:"timeZone"`
} `json:"attendee"`
}
_ = json.NewDecoder(r.Body).Decode(&payload)
attendeeTZ = payload.Attendee.TimeZone
_, _ = io.WriteString(w, `{
"status":"success",
"data":{"uid":"uid-1","start":"2026-06-11T01:00:00Z","end":"2026-06-11T01:30:00Z","location":"https://meet.example.com/uid-1"}
}`)
return
}
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
h, _ := newKeyedHandler(t)
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-11T01:00:00Z"},
"name": {"Sue Findlay"},
"email": {"sue@example.test"},
// A page cached before the rework still posts this — must be ignored.
"timezone": {"America/New_York"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
body := rec.Body.String()
// 01:00Z on June 11 is 9:00 AM Thursday June 11 in Perth.
if !strings.Contains(body, "9:00 AM") {
t.Errorf("expected confirmation in Perth time ('9:00 AM'), got: %q", body)
}
if strings.Contains(body, "1:00 AM") {
t.Errorf("confirmation rendered the UTC instant ('1:00 AM') instead of meeting time: %q", body)
}
if attendeeTZ != "Australia/Perth" {
t.Errorf("attendee timeZone sent to Cal.com: got %q, want Australia/Perth", attendeeTZ)
}
}
// TestCalcomBookingWidget_NoClientTimezonePlumbing pins the removal of
// client-side timezone detection — the mechanism behind the original bidbuddy
// incident (a hidden input's "UTC" default that never updated on cached
// pages). The widget must carry no timezone input, attribute, or detection
// call; the locale capture must still defer to DOMContentLoaded (the input it
// fills sits after the script in parse order); and the visible label must be
// the server-resolved zone.
func TestCalcomBookingWidget_NoClientTimezonePlumbing(t *testing.T) {
var buf strings.Builder
cfg := BookingConfig{
BlockID: "b1",
Username: "alice",
EventTypeSlug: "30min",
WeeksToShow: 2,
WeekStart: "2026-06-11",
Today: "2026-06-11",
TimeZone: "Australia/Perth",
Dates: nil,
ShowTimezone: true,
}
if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil {
t.Fatalf("render widget: %v", err)
}
html := buf.String()
for _, leftover := range []string{`name="timezone"`, "data-timezone", "Intl.DateTimeFormat"} {
if strings.Contains(html, leftover) {
t.Errorf("widget still ships client timezone plumbing (%s)", leftover)
}
}
if !strings.Contains(html, "DOMContentLoaded") {
t.Errorf("widget init script must defer until DOMContentLoaded — running during parse leaves the locale input at its empty default")
}
if !strings.Contains(html, "Australia/Perth") {
t.Errorf("ShowTimezone label must render the server-resolved zone, got: %q", html)
}
}

140
types.go Normal file
View File

@ -0,0 +1,140 @@
package main
// TimeSlot represents an available booking time slot
type TimeSlot struct {
Start string // ISO 8601 timestamp
End string // ISO 8601 timestamp
DisplayTime string // Human-readable time, e.g., "10:00 AM"
DateKey string // Date key from Cal.com API response
}
// SlotConfig holds configuration for rendering time slots
type SlotConfig struct {
BlockID string
Username string
EventType string
SelectedDate string // YYYY-MM-DD format
}
// FormConfig holds configuration for the booking form
type FormConfig struct {
BlockID string
Username string
EventType string
SelectedDate string // Human-readable date
SelectedTime string // Human-readable time
StartTime string // ISO 8601 timestamp for the booking
TimeZone string // IANA timezone of the event's availability schedule
BookingFields []BookingField
// DefaultCountryCode is the ISO alpha-2 region pre-selected in the phone
// country combobox, inferred from the visitor's timezone/locale. "" = none.
DefaultCountryCode string
// CaptchaEnabled renders the shared Cap proof-of-work widget in the confirm
// step. Server-authoritative: resolved from the block's stored captchaEnabled
// flag by blockId (HandleGetForm), the same source HandleCreateBooking
// enforces against — a bot cannot influence it.
CaptchaEnabled bool
}
// BookingResult holds the result of a successful booking
type BookingResult struct {
Success bool
BookingID string // Cal.com booking UID
FormattedDateTime string // e.g., "Monday, January 15, 2025 at 10:00 AM"
Email string
MeetingLink string // Join URL from the booking's `location` (video events only)
}
// BookingField is a single field configured on a Cal.com event type.
// Mirrors the Cal.com v2 event-type `bookingFields` array.
//
// Cal.com's v2 API (cal-api-version 2024-06-14) keys the field identifier as
// `slug`, NOT `name`, and returns an empty `label` for default/system fields
// (its own booker derives the visible label client-side via i18n). It also
// marks some system fields `hidden:true`. The raw decode therefore leaves
// Name/Label empty for the common case; GetEventType runs normalizeBookingFields
// to resolve Name (from Slug) and Label (from the system-field map) and to drop
// hidden fields before the form template ever sees them.
type BookingField struct {
Name string `json:"name"` // legacy/custom key; Slug is the v2 canonical identifier
Slug string `json:"slug"`
Label string `json:"label"`
Type string `json:"type"` // name|email|phone|text|textarea|number|boolean|select|multiselect|radio|checkbox
Required bool `json:"required"`
Hidden bool `json:"hidden"`
Placeholder string `json:"placeholder,omitempty"`
DefaultValue string `json:"defaultValue,omitempty"`
Options []BookingFieldOption `json:"options,omitempty"`
// Editable mirrors Cal.com's system/custom classification: "system",
// "system-but-optional", "system-but-hidden", "user", "user-readonly".
// Used to drop system-but-hidden fields before they reach the form.
Editable string `json:"editable,omitempty"`
// Views restricts a field to specific booker flows. rescheduleReason carries
// views:[{id:"reschedule"}] — i.e. it must render only when rescheduling,
// never on a new booking (see Cal.com getBookingFields.ts).
Views []BookingFieldView `json:"views,omitempty"`
}
// BookingFieldOption is a single choice for a select/radio/checkbox field.
type BookingFieldOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
// BookingFieldView mirrors a Cal.com bookingField `views` entry. A non-empty
// views array scopes a field to particular booker flows (e.g. reschedule).
type BookingFieldView struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
}
// EventTypeSummary is a compact projection used to populate the editor dropdown.
type EventTypeSummary struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
LengthInMinutes int `json:"lengthInMinutes"`
}
// EventType is the full event-type record including its booking fields.
type EventType struct {
EventTypeSummary
BookingFields []BookingField `json:"bookingFields"`
// ScheduleID is the availability schedule backing this event type. nil
// means "the host's default schedule" — resolve via Schedule.IsDefault.
ScheduleID *int64 `json:"scheduleId"`
}
// Schedule is a Cal.com availability schedule. Its TimeZone is the meeting's
// timezone: the zone the host configured their availability in, and the zone
// all booking times are displayed in (the visitor's browser zone is ignored
// by design — see eventLocation in handler.go).
type Schedule struct {
ID int64 `json:"id"`
Name string `json:"name"`
TimeZone string `json:"timeZone"`
IsDefault bool `json:"isDefault"`
}
// WebhookPayload is the inner `payload` object of a Cal.com webhook event.
type WebhookPayload struct {
UID string `json:"uid"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
EventType struct {
Title string `json:"title"`
Slug string `json:"slug"`
} `json:"eventType"`
Attendees []struct {
Name string `json:"name"`
Email string `json:"email"`
TimeZone string `json:"timeZone"`
} `json:"attendees"`
}
// WebhookEvent is the top-level Cal.com webhook envelope.
type WebhookEvent struct {
TriggerEvent string `json:"triggerEvent"`
CreatedAt string `json:"createdAt"`
Payload WebhookPayload `json:"payload"`
}

7
tzdata.go Normal file
View File

@ -0,0 +1,7 @@
package main
// The production runtime image (alpine, no tzdata package) has no
// /usr/share/zoneinfo, and booking-time windowing/rendering resolves IANA
// zones via time.LoadLocation. Embed the zone database in the binary so
// timezone resolution never depends on the container's filesystem.
import _ "time/tzdata"

View File

@ -0,0 +1,550 @@
import { importShared } from "./__federation_fn_import-hlt2XzeI.js";
import { j as jsxRuntimeExports } from "./jsx-runtime-CvJTHeKY.js";
const { useCallback, useEffect, useState } = await importShared("react");
const {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Settings,
Edit3,
CheckCircle2,
XCircle,
Loader2,
Key,
AlertCircle,
ExternalLink,
RefreshCw,
t,
} = await importShared("@block-ninja/ui");
function renderConnectedAccount(apiKeyConfigured, calcomUsername) {
if (apiKeyConfigured === false) {
return /* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.connectedAs.noKey", "Configure the API key in plugin settings to connect an account."),
});
}
if (calcomUsername) {
return /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-sm font-mono", children: calcomUsername });
}
return /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-xs text-muted-foreground", children: t("calcom.editor.connectedAs.resolving", "Resolving account from API key…") });
}
function CalcomBlockEditor({ content, onChange }) {
const [activeTab, setActiveTab] = useState("config");
const [apiKeyConfigured, setApiKeyConfigured] = useState(null);
const [calcomUsername, setCalcomUsername] = useState("");
const [eventTypes, setEventTypes] = useState([]);
const [loadingEventTypes, setLoadingEventTypes] = useState(false);
const [fetchFailed, setFetchFailed] = useState(false);
const [retryCounter, setRetryCounter] = useState(0);
const handleFieldChange = useCallback(
(field, value) => {
onChange({ ...content, [field]: value });
},
[content, onChange],
);
const getString = (field, defaultValue = "") => {
return content[field] || defaultValue;
};
const getNumber = (field, defaultValue = 0) => {
const val = content[field];
if (typeof val === "number") return val;
if (typeof val === "string") return parseInt(val, 10) || defaultValue;
return defaultValue;
};
const getBool = (field, defaultValue = false) => {
const val = content[field];
if (typeof val === "boolean") return val;
return defaultValue;
};
const eventTypeSlug = getString("eventTypeSlug");
useEffect(() => {
fetch(`/api/plugins/calcomblock/settings`)
.then((r) => r.json())
.then((d) => {
setApiKeyConfigured(!!d.api_key_configured);
setCalcomUsername(d.username ?? "");
})
.catch(() => setApiKeyConfigured(false));
}, []);
useEffect(() => {
if (calcomUsername && content.username !== calcomUsername) {
onChange({ ...content, username: calcomUsername });
}
}, [calcomUsername, content, onChange]);
useEffect(() => {
if (!apiKeyConfigured) {
setEventTypes([]);
setFetchFailed(false);
return;
}
const timer = setTimeout(() => {
setLoadingEventTypes(true);
setFetchFailed(false);
fetch(`/api/plugins/calcomblock/event-types?username=${encodeURIComponent(calcomUsername)}`)
.then((r) => r.json())
.then((d) => {
if (d.success) {
setEventTypes(d.event_types ?? []);
} else {
setEventTypes([]);
setFetchFailed(true);
}
})
.catch(() => {
setEventTypes([]);
setFetchFailed(true);
})
.finally(() => setLoadingEventTypes(false));
}, 300);
return () => clearTimeout(timer);
}, [apiKeyConfigured, calcomUsername, retryCounter]);
const handleRetry = useCallback(() => {
setRetryCounter((n) => n + 1);
}, []);
const selectPlaceholder = (() => {
if (apiKeyConfigured === false) {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
className: "inline-flex items-center gap-1.5 text-muted-foreground",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Key, { className: "h-3 w-3" }), t("calcom.editor.eventType.placeholder.noApiKey", "Configure API key first")],
});
}
if (loadingEventTypes) {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
className: "inline-flex items-center gap-1.5 text-muted-foreground",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "h-3 w-3 animate-spin" }), t("calcom.editor.eventType.placeholder.loading", "Loading…")],
});
}
if (!calcomUsername) {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
className: "inline-flex items-center gap-1.5 text-muted-foreground",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "h-3 w-3 animate-spin" }), t("calcom.editor.eventType.placeholder.resolvingUser", "Resolving account…")],
});
}
if (eventTypes.length === 0) {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
className: "inline-flex items-center gap-1.5 text-muted-foreground",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(XCircle, { className: "h-3 w-3" }), t("calcom.editor.eventType.placeholder.noEventTypes", "No event types found")],
});
}
return t("calcom.editor.eventType.placeholder.select", "Select event type");
})();
const apiKeyReady = apiKeyConfigured === true;
const usernameReady = calcomUsername.length > 0;
const eventTypeReady = eventTypeSlug.length > 0;
const canOpenInCalcom = usernameReady && eventTypeReady;
const renderStatusRow = (ok, label, action) =>
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-2 text-sm",
children: [
ok ?
/* @__PURE__ */ jsxRuntimeExports.jsx(CheckCircle2, { "className": "h-4 w-4 text-primary", "aria-hidden": "true" })
: /* @__PURE__ */ jsxRuntimeExports.jsx(XCircle, { "className": "h-4 w-4 text-destructive", "aria-hidden": "true" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: ok ? "text-foreground" : "text-muted-foreground", children: label }),
action ? /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "ml-auto", children: action }) : null,
],
});
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", {
className: "space-y-4",
children: /* @__PURE__ */ jsxRuntimeExports.jsxs(Tabs, {
value: activeTab,
onValueChange: setActiveTab,
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(TabsList, {
className: "grid w-full grid-cols-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(TabsTrigger, {
value: "config",
className: "gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Settings, { className: "h-4 w-4" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: t("calcom.editor.tab.configuration", "Configuration") }),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(TabsTrigger, {
value: "content",
className: "gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Edit3, { className: "h-4 w-4" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: t("calcom.editor.tab.content", "Content") }),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(TabsContent, {
value: "config",
className: "space-y-4 mt-4",
children: [
apiKeyConfigured === false &&
/* @__PURE__ */ jsxRuntimeExports.jsx(Alert, {
children: /* @__PURE__ */ jsxRuntimeExports.jsxs(AlertDescription, {
children: [
t("calcom.editor.alert.apiKeyMissing.prefix", "Configure your Cal.com API key in"),
" ",
/* @__PURE__ */ jsxRuntimeExports.jsx("a", {
href: "/admin/plugins?plugin=calcomblock",
className: "text-primary hover:underline",
children: t("calcom.editor.alert.apiKeyMissing.linkLabel", "plugin settings"),
}),
" ",
t("calcom.editor.alert.apiKeyMissing.suffix", "to enable the event-type dropdown."),
],
}),
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CardTitle, { className: "text-base", children: t("calcom.editor.section.config.title", "Cal.com Settings") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, {
children: t("calcom.editor.section.config.description", "Connect to your Cal.com account and select an event type"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-4",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { children: t("calcom.editor.connectedAs.label", "Connected Account") }),
renderConnectedAccount(apiKeyConfigured, calcomUsername),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(Label, {
htmlFor: "eventTypeSlug",
children: [
t("calcom.editor.eventType.label", "Event Type"),
" ",
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-destructive", children: "*" }),
],
}),
fetchFailed ?
/* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Input, {
id: "eventTypeSlug",
value: eventTypeSlug,
onChange: (e) => handleFieldChange("eventTypeSlug", e.target.value),
placeholder: t("calcom.editor.eventType.fallbackPlaceholder", "30min"),
className: "flex-1",
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Button, {
"type": "button",
"size": "sm",
"variant": "outline",
"action": "retry",
"entity": "event-types",
"onClick": handleRetry,
"disabled": loadingEventTypes,
"aria-label": t("calcom.editor.eventType.retryAria", "Retry loading event types"),
"children": [
/* @__PURE__ */ jsxRuntimeExports.jsx(RefreshCw, {
"className": `h-4 w-4 ${loadingEventTypes ? "animate-spin" : ""}`,
"aria-hidden": "true",
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", {
className: "ml-1",
children: t("calcom.editor.eventType.retryLabel", "Retry"),
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Alert, {
variant: "destructive",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(AlertCircle, { "className": "h-4 w-4", "aria-hidden": "true" }),
/* @__PURE__ */ jsxRuntimeExports.jsx(AlertDescription, {
children: t("calcom.editor.eventType.fetchFailed", "Couldn't load event types from Cal.com. Enter the slug manually or retry."),
}),
],
}),
],
})
: /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(Select, {
value: eventTypeSlug,
onValueChange: (v) => handleFieldChange("eventTypeSlug", v),
disabled: loadingEventTypes || !apiKeyConfigured || eventTypes.length === 0,
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(SelectTrigger, {
"aria-label": t("calcom.editor.eventType.label", "Event Type"),
"children": /* @__PURE__ */ jsxRuntimeExports.jsx(SelectValue, { placeholder: selectPlaceholder }),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(SelectContent, {
children: eventTypes.map((et) =>
/* @__PURE__ */ jsxRuntimeExports.jsxs(
SelectItem,
{
value: et.slug,
children: [
et.title,
/* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
className: "text-muted-foreground",
children: [" ", "(", et.slug, " · ", et.lengthInMinutes, "m)"],
}),
],
},
et.id,
),
),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.eventType.help", "Fetched from your Cal.com account"),
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { htmlFor: "weeksToShow", children: t("calcom.editor.weeksToShow.label", "Weeks to Display") }),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Select, {
value: String(getNumber("weeksToShow", 2)),
onValueChange: (v) => handleFieldChange("weeksToShow", parseInt(v, 10)),
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(SelectTrigger, {
"aria-label": t("calcom.editor.weeksToShow.label", "Weeks to Display"),
"children": /* @__PURE__ */ jsxRuntimeExports.jsx(SelectValue, { placeholder: t("calcom.editor.weeksToShow.placeholder", "Select weeks") }),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(SelectContent, {
children: [1, 2, 3, 4, 5, 6, 7, 8].map((n) =>
/* @__PURE__ */ jsxRuntimeExports.jsxs(
SelectItem,
{
value: String(n),
children: [
n,
" ",
n > 1 ? t("calcom.editor.weeksToShow.unitPlural", "weeks") : t("calcom.editor.weeksToShow.unitSingular", "week"),
],
},
n,
),
),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.weeksToShow.help", "How many weeks of dates to show in the calendar"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center justify-between rounded-lg border p-3",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-0.5",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { children: t("calcom.editor.showTimezone.label", "Show Timezone Selector") }),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.showTimezone.help", "Show a label above the date grid indicating which timezone times are displayed in."),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Switch, {
"checked": getBool("showTimezone", true),
"onCheckedChange": (v) => handleFieldChange("showTimezone", v),
"aria-label": t("calcom.editor.showTimezone.label", "Show Timezone Selector"),
}),
],
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CardTitle, { className: "text-base", children: t("calcom.editor.status.title", "Configuration status") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, {
children: t("calcom.editor.status.description", "Quick check of the values needed before this block renders bookings."),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-2",
children: [
renderStatusRow(
apiKeyReady,
t("calcom.editor.status.apiKey", "API key configured"),
!apiKeyReady ?
/* @__PURE__ */ jsxRuntimeExports.jsx("a", {
href: "/admin/plugins?plugin=calcomblock",
className: "text-xs text-primary hover:underline",
children: t("calcom.editor.status.apiKey.configureLink", "Configure"),
})
: null,
),
renderStatusRow(usernameReady, t("calcom.editor.status.username", "Account resolved")),
renderStatusRow(eventTypeReady, t("calcom.editor.status.eventType", "Event type selected")),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-2 text-sm",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "inline-flex h-4 w-4 items-center justify-center text-muted-foreground", children: "·" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", {
className: "text-muted-foreground",
children: t("calcom.editor.status.eventTypesAvailable", "{count} event types available").replace("{count}", String(eventTypes.length)),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("div", {
className: "pt-2",
children: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"type": "button",
"size": "sm",
"variant": "outline",
"action": "open",
"entity": "calcom-page",
"asChild": canOpenInCalcom,
"disabled": !canOpenInCalcom,
"aria-label": t("calcom.editor.status.openExternalAria", "Open Cal.com booking page in a new tab"),
"children":
canOpenInCalcom ?
/* @__PURE__ */ jsxRuntimeExports.jsxs("a", {
href: `https://cal.com/${calcomUsername}/${eventTypeSlug}`,
target: "_blank",
rel: "noopener noreferrer",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(ExternalLink, { "className": "h-4 w-4", "aria-hidden": "true" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", {
className: "ml-1",
children: t("calcom.editor.status.openExternal", "Open in Cal.com"),
}),
],
})
: /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(ExternalLink, { "className": "h-4 w-4", "aria-hidden": "true" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", {
className: "ml-1",
children: t("calcom.editor.status.openExternal", "Open in Cal.com"),
}),
],
}),
}),
}),
],
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(TabsContent, {
value: "content",
className: "space-y-4 mt-4",
children: /* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CardTitle, { className: "text-base", children: t("calcom.editor.section.content.title", "Widget Content") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, {
children: t("calcom.editor.section.content.description", "Customize the text shown in the booking widget"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-4",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { htmlFor: "title", children: t("calcom.editor.title.label", "Title") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(Input, {
id: "title",
value: getString("title"),
onChange: (e) => handleFieldChange("title", e.target.value),
placeholder: t("calcom.editor.title.placeholder", "Book a Meeting"),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.title.help", "Main heading shown above the calendar"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { htmlFor: "description", children: t("calcom.editor.description.label", "Description") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(Textarea, {
id: "description",
value: getString("description"),
onChange: (e) => handleFieldChange("description", e.target.value),
placeholder: t("calcom.editor.description.placeholder", "Choose a convenient time for your consultation..."),
rows: 3,
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.description.help", "Optional text shown below the title"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, {
htmlFor: "unavailableMessage",
children: t("calcom.editor.unavailableMessage.label", "Booking unavailable message"),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Textarea, {
id: "unavailableMessage",
value: getString("unavailableMessage"),
onChange: (e) => handleFieldChange("unavailableMessage", e.target.value),
placeholder: t(
"calcom.editor.unavailableMessage.placeholder",
"Sorry — we couldn't complete your booking online. Please use our contact form and we'll get you booked in.",
),
rows: 3,
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.editor.unavailableMessage.help", "Shown if an online booking can't be completed. Leave blank to use the default."),
}),
],
}),
],
}),
],
}),
}),
],
}),
});
}
export { CalcomBlockEditor as default };

View File

@ -0,0 +1,619 @@
import { importShared } from "./__federation_fn_import-hlt2XzeI.js";
import { j as jsxRuntimeExports } from "./jsx-runtime-CvJTHeKY.js";
const { useState, useCallback, useEffect } = await importShared("react");
const {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Button,
Alert,
AlertDescription,
Badge,
ScrollArea,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
Eye,
EyeOff,
Key,
Zap,
Settings,
Send,
Copy,
RefreshCw,
toast,
t,
useAlertDialog,
} = await importShared("@block-ninja/ui");
const copyToClipboard = async (text) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
toast.success(t("calcom.settings.copied", "Copied to clipboard"));
} catch (_error) {
toast.error(t("calcom.settings.copyFailed", "Could not copy to clipboard"));
}
};
const formatRelativeTime = (iso) => {
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) return iso;
const diffMs = Date.now() - parsed;
const minutes = Math.round(diffMs / 6e4);
if (minutes < 1) return t("calcom.settings.relative.justNow", "just now");
if (minutes < 60) {
return minutes === 1 ? t("calcom.settings.relative.minute", "1 minute ago") : t("calcom.settings.relative.minutes", "{count} minutes ago").replace("{count}", String(minutes));
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return hours === 1 ? t("calcom.settings.relative.hour", "1 hour ago") : t("calcom.settings.relative.hours", "{count} hours ago").replace("{count}", String(hours));
}
const days = Math.round(hours / 24);
return days === 1 ? t("calcom.settings.relative.day", "1 day ago") : t("calcom.settings.relative.days", "{count} days ago").replace("{count}", String(days));
};
function CalcomSettings({ pluginName }) {
const { confirm } = useAlertDialog();
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [rotating, setRotating] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [settings, setSettings] = useState(null);
const [testResult, setTestResult] = useState(null);
const [saveMessage, setSaveMessage] = useState(null);
const baseUrl = `/api/plugins/${pluginName}`;
const loadSettings = useCallback(async () => {
try {
const res = await fetch(`${baseUrl}/settings`);
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch (_error) {
console.error("Failed to load settings");
} finally {
setLoading(false);
}
}, [baseUrl]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const handleSave = async () => {
if (!apiKey.trim()) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.errorEmpty", "Please enter an API key") });
return;
}
setSaving(true);
setSaveMessage(null);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
if (res.ok) {
await loadSettings();
setApiKey("");
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.saved", "API key saved successfully") });
} else {
const text = await res.text();
setSaveMessage({ type: "error", text: text || t("calcom.settings.apiKey.saveFailed", "Failed to save API key") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data);
} catch (_error) {
setTestResult({ success: false, error: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setTesting(false);
}
};
const handleClear = async () => {
setSaving(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: "" }),
});
if (res.ok) {
await loadSettings();
setTestResult(null);
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.removed", "API key removed") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.clearFailed", "Failed to clear API key") });
} finally {
setSaving(false);
}
};
const handleRotate = async () => {
const confirmed = await confirm({
title: t("calcom.settings.webhook.rotateConfirmTitle", "Rotate webhook secret?"),
message: t("calcom.settings.webhook.rotateConfirmMessage", "This invalidates the existing secret immediately. You'll need to update Cal.com to keep webhooks flowing."),
confirmLabel: t("calcom.settings.webhook.rotateConfirmLabel", "Rotate"),
variant: "destructive",
});
if (!confirmed) return;
setRotating(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings/rotate-webhook-secret`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setSettings((prev) => (prev ? { ...prev, webhook_secret_masked: data.webhook_secret_masked, webhook_secret_configured: true } : prev));
const successMsg = t("calcom.settings.webhook.rotated", "Webhook secret rotated. Update it in Cal.com.");
setSaveMessage({ type: "success", text: successMsg });
toast.success(successMsg);
} else {
setSaveMessage({ type: "error", text: t("calcom.settings.webhook.rotateFailed", "Failed to rotate webhook secret") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setRotating(false);
}
};
if (loading) {
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", {
className: "flex items-center justify-center py-8",
children: /* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }),
});
}
const apiKeyVisibilityLabel = showApiKey ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
const secretVisibilityLabel = showSecret ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-6",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("h2", {
className: "text-xl font-semibold flex items-center gap-2",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Settings, { className: "h-5 w-5" }), t("calcom.settings.heading", "Cal.com Integration")],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-sm text-muted-foreground",
children: t("calcom.settings.headingDescription", "Configure your Cal.com API key to enable booking functionality"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardTitle, {
className: "text-base flex items-center gap-2",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Key, { className: "h-4 w-4" }), t("calcom.settings.apiKey.title", "API Key Status")],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, {
children: t("calcom.settings.apiKey.description", "Your Cal.com API key is required for the booking widget to function"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-4",
children: [
settings?.api_key_configured ?
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center justify-between rounded-lg border bg-muted/30 p-3",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-3",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(Badge, {
variant: "default",
className: "gap-1.5",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CheckCircle2, { className: "h-3.5 w-3.5" }),
t("calcom.settings.apiKey.configuredLabel", "Configured"),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground font-mono",
children: settings.api_key_masked || t("calcom.settings.apiKey.maskedFallback", "***configured***"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
action: "delete",
entity: "plugin-setting",
variant: "ghost",
size: "sm",
onClick: handleClear,
disabled: saving,
children: t("calcom.settings.apiKey.remove", "Remove"),
}),
],
})
: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-3 rounded-lg border bg-muted/30 p-3",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(Badge, {
variant: "outline",
className: "gap-1.5",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(XCircle, { className: "h-3.5 w-3.5" }), t("calcom.settings.apiKey.notConfiguredLabel", "Not configured")],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t("calcom.settings.apiKey.notConfiguredHelp", "Enter your Cal.com API key below to enable booking"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, {
htmlFor: "apiKey",
children: settings?.api_key_configured ? t("calcom.settings.apiKey.updateLabel", "Update API Key") : t("calcom.settings.apiKey.label", "API Key"),
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "relative flex-1",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Input, {
id: "apiKey",
type: showApiKey ? "text" : "password",
value: apiKey,
onChange: (e) => setApiKey(e.target.value),
placeholder: t("calcom.settings.apiKey.placeholder", "cal_live_..."),
className: "pr-10",
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"type": "button",
"variant": "ghost",
"action": "toggle",
"entity": "api-key-visibility",
"onClick": () => setShowApiKey(!showApiKey),
"aria-label": apiKeyVisibilityLabel,
"className": "absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
"children":
showApiKey ?
/* @__PURE__ */ jsxRuntimeExports.jsx(EyeOff, { className: "h-4 w-4" })
: /* @__PURE__ */ jsxRuntimeExports.jsx(Eye, { className: "h-4 w-4" }),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
action: "save",
entity: "plugin-setting",
onClick: handleSave,
disabled: saving || !apiKey.trim(),
children: saving ? /* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : t("calcom.settings.apiKey.save", "Save"),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("p", {
className: "text-xs text-muted-foreground",
children: [
t("calcom.settings.apiKey.helpPrefix", "Get your API key from"),
" ",
/* @__PURE__ */ jsxRuntimeExports.jsx("a", {
href: "https://app.cal.com/settings/developer/api-keys",
target: "_blank",
rel: "noopener noreferrer",
className: "text-primary hover:underline",
children: t("calcom.settings.apiKey.helpLink", "Cal.com API Settings"),
}),
],
}),
],
}),
saveMessage &&
/* @__PURE__ */ jsxRuntimeExports.jsx(Alert, {
variant: saveMessage.type === "error" ? "destructive" : "default",
children: /* @__PURE__ */ jsxRuntimeExports.jsx(AlertDescription, { children: saveMessage.text }),
}),
],
}),
],
}),
settings?.api_key_configured &&
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardTitle, {
className: "text-base flex items-center gap-2",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Zap, { className: "h-4 w-4" }), t("calcom.settings.test.title", "Test Connection")],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, { children: t("calcom.settings.test.description", "Verify your API key works and see available event types") }),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-4",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
action: "settings",
entity: "plugin-connection",
onClick: handleTest,
disabled: testing,
children:
testing ?
/* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t("calcom.settings.test.testing", "Testing...")],
})
: t("calcom.settings.test.button", "Test Connection"),
}),
testResult &&
/* @__PURE__ */ jsxRuntimeExports.jsx("div", {
className: "space-y-3",
children:
testResult.success ?
/* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(Alert, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CheckCircle2, { className: "h-4 w-4 text-success" }),
/* @__PURE__ */ jsxRuntimeExports.jsxs(AlertDescription, {
className: "ml-2",
children: [
t("calcom.settings.test.successPrefix", "Connection successful! Found"),
" ",
testResult.event_types?.length || 0,
" ",
t("calcom.settings.test.successSuffix", "event types."),
],
}),
],
}),
testResult.event_types &&
testResult.event_types.length > 0 &&
/* @__PURE__ */ jsxRuntimeExports.jsx(ScrollArea, {
className: "max-h-96 rounded-lg border",
children: /* @__PURE__ */ jsxRuntimeExports.jsxs("table", {
className: "w-full text-sm",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("thead", {
className: "bg-muted",
children: /* @__PURE__ */ jsxRuntimeExports.jsxs("tr", {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("th", {
className: "text-left px-3 py-2 font-medium",
children: t("calcom.settings.test.tableEventType", "Event Type"),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("th", {
className: "text-left px-3 py-2 font-medium",
children: t("calcom.settings.test.tableSlug", "Slug"),
}),
],
}),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("tbody", {
children: testResult.event_types.map((et) =>
/* @__PURE__ */ jsxRuntimeExports.jsxs(
"tr",
{
className: "border-t",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "px-3 py-2", children: et.title }),
/* @__PURE__ */ jsxRuntimeExports.jsx("td", {
className: "px-3 py-2 font-mono text-xs",
children: et.slug,
}),
],
},
et.id,
),
),
}),
],
}),
}),
],
})
: /* @__PURE__ */ jsxRuntimeExports.jsxs(Alert, {
variant: "destructive",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(XCircle, { className: "h-4 w-4" }),
/* @__PURE__ */ jsxRuntimeExports.jsx(AlertDescription, {
className: "ml-2",
children: testResult.error || t("calcom.settings.test.failure", "Connection failed"),
}),
],
}),
}),
],
}),
],
}),
settings?.api_key_configured &&
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardTitle, {
className: "text-base flex items-center gap-2",
children: [/* @__PURE__ */ jsxRuntimeExports.jsx(Send, { className: "h-4 w-4" }), t("calcom.settings.webhook.title", "Webhook")],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, {
children: t("calcom.settings.webhook.description", "Configure this URL in Cal.com → Settings → Developer → Webhooks to receive booking events."),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-4",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { htmlFor: "webhookUrl", children: t("calcom.settings.webhook.urlLabel", "Webhook URL") }),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Input, {
id: "webhookUrl",
readOnly: true,
value: settings?.webhook_url || "",
className: "flex-1 font-mono text-xs",
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"action": "copy",
"entity": "webhook-url",
"variant": "outline",
"size": "sm",
"aria-label": t("calcom.settings.webhook.copyUrl", "Copy webhook URL"),
"onClick": () => copyToClipboard(settings?.webhook_url || ""),
"children": /* @__PURE__ */ jsxRuntimeExports.jsx(Copy, { className: "h-4 w-4" }),
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "space-y-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { htmlFor: "webhookSecret", children: t("calcom.settings.webhook.secretLabel", "Webhook Secret") }),
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex gap-2",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "relative flex-1",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(Input, {
id: "webhookSecret",
type: showSecret ? "text" : "password",
readOnly: true,
value: settings?.webhook_secret_masked || t("calcom.settings.webhook.secretNotConfigured", "Not configured"),
className: "pr-10 font-mono text-xs",
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"type": "button",
"variant": "ghost",
"action": "toggle",
"entity": "webhook-secret-visibility",
"onClick": () => setShowSecret(!showSecret),
"aria-label": secretVisibilityLabel,
"className": "absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
"children":
showSecret ?
/* @__PURE__ */ jsxRuntimeExports.jsx(EyeOff, { className: "h-4 w-4" })
: /* @__PURE__ */ jsxRuntimeExports.jsx(Eye, { className: "h-4 w-4" }),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"action": "copy",
"entity": "webhook-secret",
"variant": "outline",
"size": "sm",
"aria-label": t("calcom.settings.webhook.copySecret", "Copy webhook secret"),
"onClick": () => copyToClipboard(settings?.webhook_secret_masked || ""),
"children": /* @__PURE__ */ jsxRuntimeExports.jsx(Copy, { className: "h-4 w-4" }),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx(Button, {
"action": "rotate",
"entity": "webhook-secret",
"variant": "outline",
"size": "sm",
"aria-label": t("calcom.settings.webhook.rotate", "Rotate webhook secret"),
"onClick": handleRotate,
"disabled": rotating,
"children":
rotating ?
/* @__PURE__ */ jsxRuntimeExports.jsx(Loader2, { className: "h-4 w-4 animate-spin" })
: /* @__PURE__ */ jsxRuntimeExports.jsx(RefreshCw, { className: "h-4 w-4" }),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
className: "text-xs text-muted-foreground",
children: t(
"calcom.settings.webhook.secretHelp",
"Cal.com displays the secret only once. Regenerating invalidates the previous value immediately — update it in Cal.com after rotating.",
),
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("div", {
className: "text-sm",
children:
settings?.last_event_at ?
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-2 text-foreground",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CheckCircle2, { className: "h-4 w-4 text-success" }),
/* @__PURE__ */ jsxRuntimeExports.jsxs("span", {
children: [
t("calcom.settings.webhook.lastEventPrefix", "Last event"),
" ",
formatRelativeTime(settings.last_event_at),
settings.last_event_type &&
/* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
children: [
" — ",
/* @__PURE__ */ jsxRuntimeExports.jsx("code", { className: "font-mono text-xs", children: settings.last_event_type }),
],
}),
],
}),
],
})
: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
className: "flex items-center gap-2 text-muted-foreground",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(AlertCircle, { className: "h-4 w-4" }),
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: t("calcom.settings.webhook.waitingForEvent", "Status: Waiting for first event") }),
],
}),
}),
],
}),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(Card, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardHeader, {
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx(CardTitle, { className: "text-base", children: t("calcom.settings.docs.title", "Documentation") }),
/* @__PURE__ */ jsxRuntimeExports.jsx(CardDescription, { children: t("calcom.settings.docs.description", "Reference material for working with the Cal.com plugin.") }),
],
}),
/* @__PURE__ */ jsxRuntimeExports.jsxs(CardContent, {
className: "space-y-2 text-sm",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("a", {
href: "https://cal.com/docs/api-reference/v2",
target: "_blank",
rel: "noopener noreferrer",
className: "text-primary hover:underline block",
children: t("calcom.settings.docs.calcomApi", "Cal.com API documentation"),
}),
/* @__PURE__ */ jsxRuntimeExports.jsx("a", {
href: "/admin/docs/calcom-plugin",
target: "_blank",
rel: "noopener noreferrer",
className: "text-primary hover:underline block",
children: t("calcom.settings.docs.pluginGuide", "BlockNinja plugin guide"),
}),
],
}),
],
}),
],
});
}
export { CalcomSettings as default };

View File

@ -0,0 +1,392 @@
const buildIdentifier = "[0-9A-Za-z-]+";
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
const numericIdentifier = "0|[1-9]\\d*";
const numericIdentifierLoose = "[0-9]+";
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
const gtlt = "((?:<|>)?=?)";
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
const loneTilde = "(?:~>?)";
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
const loneCaret = "(?:\\^)";
const caretTrim = `(\\s*)${loneCaret}\\s+`;
const star = "(<|>)?=?\\s*\\*";
const caret = `^${loneCaret}${xRangePlain}$`;
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
const tilde = `^${loneTilde}${xRangePlain}$`;
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
function parseRegex(source) {
return new RegExp(source);
}
function isXVersion(version) {
return !version || version.toLowerCase() === "x" || version === "*";
}
function pipe(...fns) {
return (x) => {
return fns.reduce((v, f) => f(v), x);
};
}
function extractComparator(comparatorString) {
return comparatorString.match(parseRegex(comparator));
}
function combineVersion(major, minor, patch, preRelease2) {
const mainVersion2 = `${major}.${minor}.${patch}`;
if (preRelease2) {
return `${mainVersion2}-${preRelease2}`;
}
return mainVersion2;
}
function parseHyphen(range) {
return range.replace(parseRegex(hyphenRange), (_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
if (isXVersion(fromMajor)) {
from = "";
} else if (isXVersion(fromMinor)) {
from = `>=${fromMajor}.0.0`;
} else if (isXVersion(fromPatch)) {
from = `>=${fromMajor}.${fromMinor}.0`;
} else {
from = `>=${from}`;
}
if (isXVersion(toMajor)) {
to = "";
} else if (isXVersion(toMinor)) {
to = `<${+toMajor + 1}.0.0-0`;
} else if (isXVersion(toPatch)) {
to = `<${toMajor}.${+toMinor + 1}.0-0`;
} else if (toPreRelease) {
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
} else {
to = `<=${to}`;
}
return `${from} ${to}`.trim();
});
}
function parseComparatorTrim(range) {
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
}
function parseTildeTrim(range) {
return range.replace(parseRegex(tildeTrim), "$1~");
}
function parseCaretTrim(range) {
return range.replace(parseRegex(caretTrim), "$1^");
}
function parseCarets(range) {
return range
.trim()
.split(/\s+/)
.map((rangeVersion) => {
return rangeVersion.replace(parseRegex(caret), (_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
if (major === "0") {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else {
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
}
} else if (preRelease2) {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
}
} else {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
}
}
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
}
});
})
.join(" ");
}
function parseTildes(range) {
return range
.trim()
.split(/\s+/)
.map((rangeVersion) => {
return rangeVersion.replace(parseRegex(tilde), (_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else if (preRelease2) {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
});
})
.join(" ");
}
function parseXRanges(range) {
return range
.split(/\s+/)
.map((rangeVersion) => {
return rangeVersion.trim().replace(parseRegex(xRange), (ret, gtlt2, major, minor, patch, preRelease2) => {
const isXMajor = isXVersion(major);
const isXMinor = isXMajor || isXVersion(minor);
const isXPatch = isXMinor || isXVersion(patch);
if (gtlt2 === "=" && isXPatch) {
gtlt2 = "";
}
preRelease2 = "";
if (isXMajor) {
if (gtlt2 === ">" || gtlt2 === "<") {
return "<0.0.0-0";
} else {
return "*";
}
} else if (gtlt2 && isXPatch) {
if (isXMinor) {
minor = 0;
}
patch = 0;
if (gtlt2 === ">") {
gtlt2 = ">=";
if (isXMinor) {
major = +major + 1;
minor = 0;
patch = 0;
} else {
minor = +minor + 1;
patch = 0;
}
} else if (gtlt2 === "<=") {
gtlt2 = "<";
if (isXMinor) {
major = +major + 1;
} else {
minor = +minor + 1;
}
}
if (gtlt2 === "<") {
preRelease2 = "-0";
}
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
} else if (isXMinor) {
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
} else if (isXPatch) {
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return ret;
});
})
.join(" ");
}
function parseStar(range) {
return range.trim().replace(parseRegex(star), "");
}
function parseGTE0(comparatorString) {
return comparatorString.trim().replace(parseRegex(gte0), "");
}
function compareAtom(rangeAtom, versionAtom) {
rangeAtom = +rangeAtom || rangeAtom;
versionAtom = +versionAtom || versionAtom;
if (rangeAtom > versionAtom) {
return 1;
}
if (rangeAtom === versionAtom) {
return 0;
}
return -1;
}
function comparePreRelease(rangeAtom, versionAtom) {
const { preRelease: rangePreRelease } = rangeAtom;
const { preRelease: versionPreRelease } = versionAtom;
if (rangePreRelease === void 0 && !!versionPreRelease) {
return 1;
}
if (!!rangePreRelease && versionPreRelease === void 0) {
return -1;
}
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
return 0;
}
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
const rangeElement = rangePreRelease[i];
const versionElement = versionPreRelease[i];
if (rangeElement === versionElement) {
continue;
}
if (rangeElement === void 0 && versionElement === void 0) {
return 0;
}
if (!rangeElement) {
return 1;
}
if (!versionElement) {
return -1;
}
return compareAtom(rangeElement, versionElement);
}
return 0;
}
function compareVersion(rangeAtom, versionAtom) {
return (
compareAtom(rangeAtom.major, versionAtom.major) ||
compareAtom(rangeAtom.minor, versionAtom.minor) ||
compareAtom(rangeAtom.patch, versionAtom.patch) ||
comparePreRelease(rangeAtom, versionAtom)
);
}
function eq(rangeAtom, versionAtom) {
return rangeAtom.version === versionAtom.version;
}
function compare(rangeAtom, versionAtom) {
switch (rangeAtom.operator) {
case "":
case "=":
return eq(rangeAtom, versionAtom);
case ">":
return compareVersion(rangeAtom, versionAtom) < 0;
case ">=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
case "<":
return compareVersion(rangeAtom, versionAtom) > 0;
case "<=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
case void 0: {
return true;
}
default:
return false;
}
}
function parseComparatorString(range) {
return pipe(parseCarets, parseTildes, parseXRanges, parseStar)(range);
}
function parseRange(range) {
return pipe(parseHyphen, parseComparatorTrim, parseTildeTrim, parseCaretTrim)(range.trim()).split(/\s+/).join(" ");
}
function satisfy(version, range) {
if (!version) {
return false;
}
const parsedRange = parseRange(range);
const parsedComparator = parsedRange
.split(" ")
.map((rangeVersion) => parseComparatorString(rangeVersion))
.join(" ");
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
const extractedVersion = extractComparator(version);
if (!extractedVersion) {
return false;
}
const [, versionOperator, , versionMajor, versionMinor, versionPatch, versionPreRelease] = extractedVersion;
const versionAtom = {
version: combineVersion(versionMajor, versionMinor, versionPatch, versionPreRelease),
major: versionMajor,
minor: versionMinor,
patch: versionPatch,
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split("."),
};
for (const comparator2 of comparators) {
const extractedComparator = extractComparator(comparator2);
if (!extractedComparator) {
return false;
}
const [, rangeOperator, , rangeMajor, rangeMinor, rangePatch, rangePreRelease] = extractedComparator;
const rangeAtom = {
operator: rangeOperator,
version: combineVersion(rangeMajor, rangeMinor, rangePatch, rangePreRelease),
major: rangeMajor,
minor: rangeMinor,
patch: rangePatch,
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split("."),
};
if (!compare(rangeAtom, versionAtom)) {
return false;
}
}
return true;
}
const currentImports = {};
// eslint-disable-next-line no-undef
const moduleMap = {
"react": { get: () => () => __federation_import(new URL("__federation_shared_react-DoKb58Ht.js", import.meta.url).href), import: true },
"react-dom": { get: () => () => __federation_import(new URL("__federation_shared_react-dom-DU2-P0kt.js", import.meta.url).href), import: true },
"@block-ninja/ui": { get: () => () => __federation_import(new URL("__federation_shared_@block-ninja/ui-C3CAr7wz.js", import.meta.url).href), import: true },
};
const moduleCache = Object.create(null);
async function importShared(name, shareScope = "default") {
return moduleCache[name] ? new Promise((r) => r(moduleCache[name])) : (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name);
}
// eslint-disable-next-line
async function __federation_import(name) {
currentImports[name] ??= import(name);
return currentImports[name];
}
async function getSharedFromRuntime(name, shareScope) {
let module = null;
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
const versionObj = globalThis.__federation_shared__[shareScope][name];
const requiredVersion = moduleMap[name]?.requiredVersion;
const hasRequiredVersion = !!requiredVersion;
if (hasRequiredVersion) {
const versionKey = Object.keys(versionObj).find((version) => satisfy(version, requiredVersion));
if (versionKey) {
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
} else {
console.log(`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`);
}
} else {
const versionKey = Object.keys(versionObj)[0];
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
}
}
if (module) {
return flattenModule(module, name);
}
}
async function getSharedFromLocal(name) {
if (moduleMap[name]?.import) {
let module = await (await moduleMap[name].get())();
return flattenModule(module, name);
} else {
console.error(`consumer config import=false,so cant use callback shared module`);
}
}
function flattenModule(module, name) {
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
if (typeof module.default === "function") {
Object.keys(module).forEach((key) => {
if (key !== "default") {
module.default[key] = module[key];
}
});
moduleCache[name] = module.default;
return module.default;
}
if (module.default) module = Object.assign({}, module.default, module);
moduleCache[name] = module;
return module;
}
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
import { g as getDefaultExportFromCjs } from "./_commonjsHelpers-B85MJLTf.js";
import { r as requireReact } from "./index-DQGM2Mpm.js";
var reactExports = requireReact();
const index = /*@__PURE__*/ getDefaultExportFromCjs(reactExports);
export { index as default };

View File

@ -0,0 +1,7 @@
import { g as getDefaultExportFromCjs } from "./_commonjsHelpers-B85MJLTf.js";
import { r as requireReactDom } from "./index-eoEhLOdg.js";
var reactDomExports = requireReactDom();
const index = /*@__PURE__*/ getDefaultExportFromCjs(reactDomExports);
export { index as default };

View File

@ -0,0 +1,5 @@
function getDefaultExportFromCjs(x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
}
export { getDefaultExportFromCjs as g };

File diff suppressed because it is too large Load Diff

77744
web/dist/assets/index-BSkdT-DR.js vendored Normal file

File diff suppressed because one or more lines are too long

22886
web/dist/assets/index-Bs--Ol2m.js vendored Normal file

File diff suppressed because it is too large Load Diff

105
web/dist/assets/index-Coq9nAfE.js vendored Normal file
View File

@ -0,0 +1,105 @@
import { importShared } from "./__federation_fn_import-hlt2XzeI.js";
import { X as requireShim } from "./index-Bs--Ol2m.js";
/**
* @license lucide-react v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
const mergeClasses = (...classes) =>
classes
.filter((className, index, array) => {
return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
})
.join(" ")
.trim();
/**
* @license lucide-react v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
var defaultAttributes = {
xmlns: "http://www.w3.org/2000/svg",
width: 24,
height: 24,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
};
/**
* @license lucide-react v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const { forwardRef: forwardRef$1, createElement: createElement$1 } = await importShared("react");
const Icon = forwardRef$1(({ color = "currentColor", size = 24, strokeWidth = 2, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
return createElement$1(
"svg",
{
ref,
...defaultAttributes,
width: size,
height: size,
stroke: color,
strokeWidth: absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
className: mergeClasses("lucide", className),
...rest,
},
[...iconNode.map(([tag, attrs]) => createElement$1(tag, attrs)), ...(Array.isArray(children) ? children : [children])],
);
});
/**
* @license lucide-react v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const { forwardRef, createElement } = await importShared("react");
const createLucideIcon = (iconName, iconNode) => {
const Component = forwardRef(({ className, ...props }, ref) =>
createElement(Icon, {
ref,
iconNode,
className: mergeClasses(`lucide-${toKebabCase(iconName)}`, className),
...props,
}),
);
Component.displayName = `${iconName}`;
return Component;
};
/**
* @license lucide-react v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const List = createLucideIcon("List", [
["path", { d: "M3 12h.01", key: "nlz23k" }],
["path", { d: "M3 18h.01", key: "1tta3j" }],
["path", { d: "M3 6h.01", key: "1rqtza" }],
["path", { d: "M8 12h13", key: "1za7za" }],
["path", { d: "M8 18h13", key: "1lx6n3" }],
["path", { d: "M8 6h13", key: "ik3vkj" }],
]);
var shimExports = requireShim();
export { List as L, createLucideIcon as c, shimExports as s };

959
web/dist/assets/index-D75C7LcR.js vendored Normal file
View File

@ -0,0 +1,959 @@
import { importShared } from "./__federation_fn_import-hlt2XzeI.js";
function _arrayLikeToArray(r, a) {
(null == a || a > r.length) && (a = r.length);
for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
return n;
}
function _arrayWithHoles(r) {
if (Array.isArray(r)) return r;
}
function _defineProperty$1(e, r, t) {
return (
(r = _toPropertyKey(r)) in e ?
Object.defineProperty(e, r, {
value: t,
enumerable: true,
configurable: true,
writable: true,
})
: (e[r] = t),
e
);
}
function _iterableToArrayLimit(r, l) {
var t = null == r ? null : ("undefined" != typeof Symbol && r[Symbol.iterator]) || r["@@iterator"];
if (null != t) {
var e,
n,
i,
u,
a = [],
f = true,
o = false;
try {
if (((i = (t = t.call(r)).next), 0 === l));
else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);
} catch (r) {
((o = true), (n = r));
} finally {
try {
if (!f && null != t.return && ((u = t.return()), Object(u) !== u)) return;
} finally {
if (o) throw n;
}
}
return a;
}
}
function _nonIterableRest() {
throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
function ownKeys$1(e, r) {
var t = Object.keys(e);
if (Object.getOwnPropertySymbols) {
var o = Object.getOwnPropertySymbols(e);
(r &&
(o = o.filter(function (r) {
return Object.getOwnPropertyDescriptor(e, r).enumerable;
})),
t.push.apply(t, o));
}
return t;
}
function _objectSpread2$1(e) {
for (var r = 1; r < arguments.length; r++) {
var t = null != arguments[r] ? arguments[r] : {};
r % 2 ?
ownKeys$1(Object(t), true).forEach(function (r) {
_defineProperty$1(e, r, t[r]);
})
: Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t))
: ownKeys$1(Object(t)).forEach(function (r) {
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
});
}
return e;
}
function _objectWithoutProperties(e, t) {
if (null == e) return {};
var o,
r,
i = _objectWithoutPropertiesLoose(e, t);
if (Object.getOwnPropertySymbols) {
var n = Object.getOwnPropertySymbols(e);
for (r = 0; r < n.length; r++) ((o = n[r]), -1 === t.indexOf(o) && {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]));
}
return i;
}
function _objectWithoutPropertiesLoose(r, e) {
if (null == r) return {};
var t = {};
for (var n in r)
if ({}.hasOwnProperty.call(r, n)) {
if (-1 !== e.indexOf(n)) continue;
t[n] = r[n];
}
return t;
}
function _slicedToArray(r, e) {
return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
}
function _toPrimitive(t, r) {
if ("object" != typeof t || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r);
if ("object" != typeof i) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
function _toPropertyKey(t) {
var i = _toPrimitive(t, "string");
return "symbol" == typeof i ? i : i + "";
}
function _unsupportedIterableToArray(r, a) {
if (r) {
if ("string" == typeof r) return _arrayLikeToArray(r, a);
var t = {}.toString.call(r).slice(8, -1);
return (
"Object" === t && r.constructor && (t = r.constructor.name),
"Map" === t || "Set" === t ? Array.from(r)
: "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a)
: void 0
);
}
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true,
});
} else {
obj[key] = value;
}
return obj;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly)
symbols = symbols.filter(function (sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread2(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(Object(source), true).forEach(function (key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function (key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
}
return target;
}
function compose$1() {
for (var _len = arguments.length, fns = new Array(_len), _key = 0; _key < _len; _key++) {
fns[_key] = arguments[_key];
}
return function (x) {
return fns.reduceRight(function (y, f) {
return f(y);
}, x);
};
}
function curry$1(fn) {
return function curried() {
var _this = this;
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
args[_key2] = arguments[_key2];
}
return args.length >= fn.length ?
fn.apply(this, args)
: function () {
for (var _len3 = arguments.length, nextArgs = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
nextArgs[_key3] = arguments[_key3];
}
return curried.apply(_this, [].concat(args, nextArgs));
};
};
}
function isObject$1(value) {
return {}.toString.call(value).includes("Object");
}
function isEmpty(obj) {
return !Object.keys(obj).length;
}
function isFunction(value) {
return typeof value === "function";
}
function hasOwnProperty(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
}
function validateChanges(initial, changes) {
if (!isObject$1(changes)) errorHandler$1("changeType");
if (
Object.keys(changes).some(function (field) {
return !hasOwnProperty(initial, field);
})
)
errorHandler$1("changeField");
return changes;
}
function validateSelector(selector) {
if (!isFunction(selector)) errorHandler$1("selectorType");
}
function validateHandler(handler) {
if (!(isFunction(handler) || isObject$1(handler))) errorHandler$1("handlerType");
if (
isObject$1(handler) &&
Object.values(handler).some(function (_handler) {
return !isFunction(_handler);
})
)
errorHandler$1("handlersType");
}
function validateInitial(initial) {
if (!initial) errorHandler$1("initialIsRequired");
if (!isObject$1(initial)) errorHandler$1("initialType");
if (isEmpty(initial)) errorHandler$1("initialContent");
}
function throwError$1(errorMessages, type) {
throw new Error(errorMessages[type] || errorMessages["default"]);
}
var errorMessages$1 = {
initialIsRequired: "initial state is required",
initialType: "initial state should be an object",
initialContent: "initial state shouldn't be an empty object",
handlerType: "handler should be an object or a function",
handlersType: "all handlers should be a functions",
selectorType: "selector should be a function",
changeType: "provided value of changes should be an object",
changeField: 'it seams you want to change a field in the state which is not specified in the "initial" state',
default: "an unknown error accured in `state-local` package",
};
var errorHandler$1 = curry$1(throwError$1)(errorMessages$1);
var validators$1 = {
changes: validateChanges,
selector: validateSelector,
handler: validateHandler,
initial: validateInitial,
};
function create(initial) {
var handler = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
validators$1.initial(initial);
validators$1.handler(handler);
var state = {
current: initial,
};
var didUpdate = curry$1(didStateUpdate)(state, handler);
var update = curry$1(updateState)(state);
var validate = curry$1(validators$1.changes)(initial);
var getChanges = curry$1(extractChanges)(state);
function getState() {
var selector =
arguments.length > 0 && arguments[0] !== undefined ?
arguments[0]
: function (state) {
return state;
};
validators$1.selector(selector);
return selector(state.current);
}
function setState(causedChanges) {
compose$1(didUpdate, update, validate, getChanges)(causedChanges);
}
return [getState, setState];
}
function extractChanges(state, causedChanges) {
return isFunction(causedChanges) ? causedChanges(state.current) : causedChanges;
}
function updateState(state, changes) {
state.current = _objectSpread2(_objectSpread2({}, state.current), changes);
return changes;
}
function didStateUpdate(state, handler, changes) {
isFunction(handler) ?
handler(state.current)
: Object.keys(changes).forEach(function (field) {
var _handler$field;
return (_handler$field = handler[field]) === null || _handler$field === void 0 ? void 0 : _handler$field.call(handler, state.current[field]);
});
return changes;
}
var index = {
create: create,
};
var config$1 = {
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs",
},
};
function curry(fn) {
return function curried() {
var _this = this;
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return args.length >= fn.length ?
fn.apply(this, args)
: function () {
for (var _len2 = arguments.length, nextArgs = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
nextArgs[_key2] = arguments[_key2];
}
return curried.apply(_this, [].concat(args, nextArgs));
};
};
}
function isObject(value) {
return {}.toString.call(value).includes("Object");
}
/**
* validates the configuration object and informs about deprecation
* @param {Object} config - the configuration object
* @return {Object} config - the validated configuration object
*/
function validateConfig(config) {
if (!config) errorHandler("configIsRequired");
if (!isObject(config)) errorHandler("configType");
if (config.urls) {
informAboutDeprecation();
return {
paths: {
vs: config.urls.monacoBase,
},
};
}
return config;
}
/**
* logs deprecation message
*/
function informAboutDeprecation() {
console.warn(errorMessages.deprecation);
}
function throwError(errorMessages, type) {
throw new Error(errorMessages[type] || errorMessages["default"]);
}
var errorMessages = {
configIsRequired: "the configuration object is required",
configType: "the configuration object should be an object",
default: "an unknown error accured in `@monaco-editor/loader` package",
deprecation:
"Deprecation warning!\n You are using deprecated way of configuration.\n\n Instead of using\n monaco.config({ urls: { monacoBase: '...' } })\n use\n monaco.config({ paths: { vs: '...' } })\n\n For more please check the link https://github.com/suren-atoyan/monaco-loader#config\n ",
};
var errorHandler = curry(throwError)(errorMessages);
var validators = {
config: validateConfig,
};
var compose = function compose() {
for (var _len = arguments.length, fns = new Array(_len), _key = 0; _key < _len; _key++) {
fns[_key] = arguments[_key];
}
return function (x) {
return fns.reduceRight(function (y, f) {
return f(y);
}, x);
};
};
function merge(target, source) {
Object.keys(source).forEach(function (key) {
if (source[key] instanceof Object) {
if (target[key]) {
Object.assign(source[key], merge(target[key], source[key]));
}
}
});
return _objectSpread2$1(_objectSpread2$1({}, target), source);
}
// The source (has been changed) is https://github.com/facebook/react/issues/5465#issuecomment-157888325
var CANCELATION_MESSAGE = {
type: "cancelation",
msg: "operation is manually canceled",
};
function makeCancelable(promise) {
var hasCanceled_ = false;
var wrappedPromise = new Promise(function (resolve, reject) {
promise.then(function (val) {
return hasCanceled_ ? reject(CANCELATION_MESSAGE) : resolve(val);
});
promise["catch"](reject);
});
return (
(wrappedPromise.cancel = function () {
return (hasCanceled_ = true);
}),
wrappedPromise
);
}
var _excluded = ["monaco"];
/** the local state of the module */
var _state$create = index.create({
config: config$1,
isInitialized: false,
resolve: null,
reject: null,
monaco: null,
}),
_state$create2 = _slicedToArray(_state$create, 2),
getState = _state$create2[0],
setState = _state$create2[1];
/**
* set the loader configuration
* @param {Object} config - the configuration object
*/
function config(globalConfig) {
var _validators$config = validators.config(globalConfig),
monaco = _validators$config.monaco,
config = _objectWithoutProperties(_validators$config, _excluded);
setState(function (state) {
return {
config: merge(state.config, config),
monaco: monaco,
};
});
}
/**
* handles the initialization of the monaco-editor
* @return {Promise} - returns an instance of monaco (with a cancelable promise)
*/
function init() {
var state = getState(function (_ref) {
var monaco = _ref.monaco,
isInitialized = _ref.isInitialized,
resolve = _ref.resolve;
return {
monaco: monaco,
isInitialized: isInitialized,
resolve: resolve,
};
});
if (!state.isInitialized) {
setState({
isInitialized: true,
});
if (state.monaco) {
state.resolve(state.monaco);
return makeCancelable(wrapperPromise);
}
if (window.monaco && window.monaco.editor) {
storeMonacoInstance(window.monaco);
state.resolve(window.monaco);
return makeCancelable(wrapperPromise);
}
compose(injectScripts, getMonacoLoaderScript)(configureLoader);
}
return makeCancelable(wrapperPromise);
}
/**
* injects provided scripts into the document.body
* @param {Object} script - an HTML script element
* @return {Object} - the injected HTML script element
*/
function injectScripts(script) {
return document.body.appendChild(script);
}
/**
* creates an HTML script element with/without provided src
* @param {string} [src] - the source path of the script
* @return {Object} - the created HTML script element
*/
function createScript(src) {
var script = document.createElement("script");
return (src && (script.src = src), script);
}
/**
* creates an HTML script element with the monaco loader src
* @return {Object} - the created HTML script element
*/
function getMonacoLoaderScript(configureLoader) {
var state = getState(function (_ref2) {
var config = _ref2.config,
reject = _ref2.reject;
return {
config: config,
reject: reject,
};
});
var loaderScript = createScript("".concat(state.config.paths.vs, "/loader.js"));
loaderScript.onload = function () {
return configureLoader();
};
loaderScript.onerror = state.reject;
return loaderScript;
}
/**
* configures the monaco loader
*/
function configureLoader() {
var state = getState(function (_ref3) {
var config = _ref3.config,
resolve = _ref3.resolve,
reject = _ref3.reject;
return {
config: config,
resolve: resolve,
reject: reject,
};
});
var require = window.require;
require.config(state.config);
require(["vs/editor/editor.main"], function (loaded) {
var monaco = loaded.m /* for 0.53 & 0.54 */ || loaded; /* for other versions */
storeMonacoInstance(monaco);
state.resolve(monaco);
}, function (error) {
state.reject(error);
});
}
/**
* store monaco instance in local state
*/
function storeMonacoInstance(monaco) {
if (!getState().monaco) {
setState({
monaco: monaco,
});
}
}
/**
* internal helper function
* extracts stored monaco instance
* @return {Object|null} - the monaco instance
*/
function __getMonacoInstance() {
return getState(function (_ref4) {
var monaco = _ref4.monaco;
return monaco;
});
}
var wrapperPromise = new Promise(function (resolve, reject) {
return setState({
resolve: resolve,
reject: reject,
});
});
var loader = {
config: config,
init: init,
__getMonacoInstance: __getMonacoInstance,
};
const { memo: Te } = await importShared("react");
const ke = await importShared("react");
const { useState: re, useRef: S, useCallback: oe, useEffect: ne } = ke;
const { memo: ye } = await importShared("react");
const K = await importShared("react");
var le = { wrapper: { display: "flex", position: "relative", textAlign: "initial" }, fullWidth: { width: "100%" }, hide: { display: "none" } },
v = le;
const me = await importShared("react");
var ae = { container: { display: "flex", height: "100%", width: "100%", justifyContent: "center", alignItems: "center" } },
Y = ae;
function Me({ children: e }) {
return me.createElement("div", { style: Y.container }, e);
}
var Z = Me;
var $ = Z;
function Ee({ width: e, height: r, isEditorReady: n, loading: t, _ref: a, className: m, wrapperProps: E }) {
return K.createElement(
"section",
{ style: { ...v.wrapper, width: e, height: r }, ...E },
!n && K.createElement($, null, t),
K.createElement("div", { ref: a, style: { ...v.fullWidth, ...(!n && v.hide) }, className: m }),
);
}
var ee = Ee;
var H = ye(ee);
const { useEffect: xe } = await importShared("react");
function Ce(e) {
xe(e, []);
}
var k = Ce;
const { useEffect: ge, useRef: Re } = await importShared("react");
function he(e, r, n = true) {
let t = Re(true);
ge(
t.current || !n ?
() => {
t.current = false;
}
: e,
r,
);
}
var l = he;
function D() {}
function h(e, r, n, t) {
return De(e, t) || be(e, r, n, t);
}
function De(e, r) {
return e.editor.getModel(te(e, r));
}
function be(e, r, n, t) {
return e.editor.createModel(r, n, t ? te(e, t) : void 0);
}
function te(e, r) {
return e.Uri.parse(r);
}
function Oe({
original: e,
modified: r,
language: n,
originalLanguage: t,
modifiedLanguage: a,
originalModelPath: m,
modifiedModelPath: E,
keepCurrentOriginalModel: g = false,
keepCurrentModifiedModel: N = false,
theme: x = "light",
loading: P = "Loading...",
options: y = {},
height: V = "100%",
width: z = "100%",
className: F,
wrapperProps: j = {},
beforeMount: A = D,
onMount: q = D,
}) {
let [M, O] = re(false),
[T, s] = re(true),
u = S(null),
c = S(null),
w = S(null),
d = S(q),
o = S(A),
b = S(false);
(k(() => {
let i = loader.init();
return (i.then((f) => (c.current = f) && s(false)).catch((f) => f?.type !== "cancelation" && console.error("Monaco initialization: error:", f)), () => (u.current ? I() : i.cancel()));
}),
l(
() => {
if (u.current && c.current) {
let i = u.current.getOriginalEditor(),
f = h(c.current, e || "", t || n || "text", m || "");
f !== i.getModel() && i.setModel(f);
}
},
[m],
M,
),
l(
() => {
if (u.current && c.current) {
let i = u.current.getModifiedEditor(),
f = h(c.current, r || "", a || n || "text", E || "");
f !== i.getModel() && i.setModel(f);
}
},
[E],
M,
),
l(
() => {
let i = u.current.getModifiedEditor();
i.getOption(c.current.editor.EditorOption.readOnly) ?
i.setValue(r || "")
: r !== i.getValue() && (i.executeEdits("", [{ range: i.getModel().getFullModelRange(), text: r || "", forceMoveMarkers: true }]), i.pushUndoStop());
},
[r],
M,
),
l(
() => {
u.current?.getModel()?.original.setValue(e || "");
},
[e],
M,
),
l(
() => {
let { original: i, modified: f } = u.current.getModel();
(c.current.editor.setModelLanguage(i, t || n || "text"), c.current.editor.setModelLanguage(f, a || n || "text"));
},
[n, t, a],
M,
),
l(
() => {
c.current?.editor.setTheme(x);
},
[x],
M,
),
l(
() => {
u.current?.updateOptions(y);
},
[y],
M,
));
let L = oe(() => {
if (!c.current) return;
o.current(c.current);
let i = h(c.current, e || "", t || n || "text", m || ""),
f = h(c.current, r || "", a || n || "text", E || "");
u.current?.setModel({ original: i, modified: f });
}, [n, r, a, e, t, m, E]),
U = oe(() => {
!b.current && w.current && ((u.current = c.current.editor.createDiffEditor(w.current, { automaticLayout: true, ...y })), L(), c.current?.editor.setTheme(x), O(true), (b.current = true));
}, [y, x, L]);
(ne(() => {
M && d.current(u.current, c.current);
}, [M]),
ne(() => {
!T && !M && U();
}, [T, M, U]));
function I() {
let i = u.current?.getModel();
(g || i?.original?.dispose(), N || i?.modified?.dispose(), u.current?.dispose());
}
return ke.createElement(H, { width: z, height: V, isEditorReady: M, loading: P, _ref: w, className: F, wrapperProps: j });
}
var ie = Oe;
var we = Te(ie);
const { useState: Ie } = await importShared("react");
function Pe() {
let [e, r] = Ie(loader.__getMonacoInstance());
return (
k(() => {
let n;
return (
e ||
((n = loader.init()),
n.then((t) => {
r(t);
})),
() => n?.cancel()
);
}),
e
);
}
var Le = Pe;
const { memo: ze } = await importShared("react");
const We = await importShared("react");
const { useState: ue, useEffect: W, useRef: C, useCallback: _e } = We;
const { useEffect: Ue, useRef: ve } = await importShared("react");
function He(e) {
let r = ve();
return (
Ue(() => {
r.current = e;
}, [e]),
r.current
);
}
var se = He;
var _ = new Map();
function Ve({
defaultValue: e,
defaultLanguage: r,
defaultPath: n,
value: t,
language: a,
path: m,
theme: E = "light",
line: g,
loading: N = "Loading...",
options: x = {},
overrideServices: P = {},
saveViewState: y = true,
keepCurrentModel: V = false,
width: z = "100%",
height: F = "100%",
className: j,
wrapperProps: A = {},
beforeMount: q = D,
onMount: M = D,
onChange: O,
onValidate: T = D,
}) {
let [s, u] = ue(false),
[c, w] = ue(true),
d = C(null),
o = C(null),
b = C(null),
L = C(M),
U = C(q),
I = C(),
i = C(t),
f = se(m),
Q = C(false),
B = C(false);
(k(() => {
let p = loader.init();
return (p.then((R) => (d.current = R) && w(false)).catch((R) => R?.type !== "cancelation" && console.error("Monaco initialization: error:", R)), () => (o.current ? pe() : p.cancel()));
}),
l(
() => {
let p = h(d.current, e || t || "", r || a || "", m || n || "");
p !== o.current?.getModel() && (y && _.set(f, o.current?.saveViewState()), o.current?.setModel(p), y && o.current?.restoreViewState(_.get(m)));
},
[m],
s,
),
l(
() => {
o.current?.updateOptions(x);
},
[x],
s,
),
l(
() => {
!o.current ||
t === void 0 ||
(o.current.getOption(d.current.editor.EditorOption.readOnly) ?
o.current.setValue(t)
: t !== o.current.getValue() &&
((B.current = true),
o.current.executeEdits("", [{ range: o.current.getModel().getFullModelRange(), text: t, forceMoveMarkers: true }]),
o.current.pushUndoStop(),
(B.current = false)));
},
[t],
s,
),
l(
() => {
let p = o.current?.getModel();
p && a && d.current?.editor.setModelLanguage(p, a);
},
[a],
s,
),
l(
() => {
g !== void 0 && o.current?.revealLine(g);
},
[g],
s,
),
l(
() => {
d.current?.editor.setTheme(E);
},
[E],
s,
));
let X = _e(() => {
if (!(!b.current || !d.current) && !Q.current) {
U.current(d.current);
let p = m || n,
R = h(d.current, t || e || "", r || a || "", p || "");
((o.current = d.current?.editor.create(b.current, { model: R, automaticLayout: true, ...x }, P)),
y && o.current.restoreViewState(_.get(p)),
d.current.editor.setTheme(E),
g !== void 0 && o.current.revealLine(g),
u(true),
(Q.current = true));
}
}, [e, r, n, t, a, m, x, P, y, E, g]);
(W(() => {
s && L.current(o.current, d.current);
}, [s]),
W(() => {
!c && !s && X();
}, [c, s, X]),
(i.current = t),
W(() => {
s &&
O &&
(I.current?.dispose(),
(I.current = o.current?.onDidChangeModelContent((p) => {
B.current || O(o.current.getValue(), p);
})));
}, [s, O]),
W(() => {
if (s) {
let p = d.current.editor.onDidChangeMarkers((R) => {
let G = o.current.getModel()?.uri;
if (G && R.find((J) => J.path === G.path)) {
let J = d.current.editor.getModelMarkers({ resource: G });
T?.(J);
}
});
return () => {
p?.dispose();
};
}
return () => {};
}, [s, T]));
function pe() {
(I.current?.dispose(), V ? y && _.set(m, o.current.saveViewState()) : o.current.getModel()?.dispose(), o.current.dispose());
}
return We.createElement(H, { width: z, height: F, isEditorReady: s, loading: N, _ref: b, className: j, wrapperProps: A });
}
var fe = Ve;
var de = ze(fe);
var Ft = de;
export { we as DiffEditor, de as Editor, Ft as default, loader, Le as useMonaco };

347
web/dist/assets/index-DQGM2Mpm.js vendored Normal file
View File

@ -0,0 +1,347 @@
var react = { exports: {} };
var react_production_min = {};
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
var hasRequiredReact_production_min;
function requireReact_production_min() {
if (hasRequiredReact_production_min) return react_production_min;
hasRequiredReact_production_min = 1;
var l = Symbol.for("react.element"),
n = Symbol.for("react.portal"),
p = Symbol.for("react.fragment"),
q = Symbol.for("react.strict_mode"),
r = Symbol.for("react.profiler"),
t = Symbol.for("react.provider"),
u = Symbol.for("react.context"),
v = Symbol.for("react.forward_ref"),
w = Symbol.for("react.suspense"),
x = Symbol.for("react.memo"),
y = Symbol.for("react.lazy"),
z = Symbol.iterator;
function A(a) {
if (null === a || "object" !== typeof a) return null;
a = (z && a[z]) || a["@@iterator"];
return "function" === typeof a ? a : null;
}
var B = {
isMounted: function () {
return false;
},
enqueueForceUpdate: function () {},
enqueueReplaceState: function () {},
enqueueSetState: function () {},
},
C = Object.assign,
D = {};
function E(a, b, e) {
this.props = a;
this.context = b;
this.refs = D;
this.updater = e || B;
}
E.prototype.isReactComponent = {};
E.prototype.setState = function (a, b) {
if ("object" !== typeof a && "function" !== typeof a && null != a)
throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");
this.updater.enqueueSetState(this, a, b, "setState");
};
E.prototype.forceUpdate = function (a) {
this.updater.enqueueForceUpdate(this, a, "forceUpdate");
};
function F() {}
F.prototype = E.prototype;
function G(a, b, e) {
this.props = a;
this.context = b;
this.refs = D;
this.updater = e || B;
}
var H = (G.prototype = new F());
H.constructor = G;
C(H, E.prototype);
H.isPureReactComponent = true;
var I = Array.isArray,
J = Object.prototype.hasOwnProperty,
K = { current: null },
L = { key: true, ref: true, __self: true, __source: true };
function M(a, b, e) {
var d,
c = {},
k = null,
h = null;
if (null != b) for (d in (void 0 !== b.ref && (h = b.ref), void 0 !== b.key && (k = "" + b.key), b)) J.call(b, d) && !L.hasOwnProperty(d) && (c[d] = b[d]);
var g = arguments.length - 2;
if (1 === g) c.children = e;
else if (1 < g) {
for (var f = Array(g), m = 0; m < g; m++) f[m] = arguments[m + 2];
c.children = f;
}
if (a && a.defaultProps) for (d in ((g = a.defaultProps), g)) void 0 === c[d] && (c[d] = g[d]);
return { $$typeof: l, type: a, key: k, ref: h, props: c, _owner: K.current };
}
function N(a, b) {
return { $$typeof: l, type: a.type, key: b, ref: a.ref, props: a.props, _owner: a._owner };
}
function O(a) {
return "object" === typeof a && null !== a && a.$$typeof === l;
}
function escape(a) {
var b = { "=": "=0", ":": "=2" };
return (
"$" +
a.replace(/[=:]/g, function (a) {
return b[a];
})
);
}
var P = /\/+/g;
function Q(a, b) {
return "object" === typeof a && null !== a && null != a.key ? escape("" + a.key) : b.toString(36);
}
function R(a, b, e, d, c) {
var k = typeof a;
if ("undefined" === k || "boolean" === k) a = null;
var h = false;
if (null === a) h = true;
else
switch (k) {
case "string":
case "number":
h = true;
break;
case "object":
switch (a.$$typeof) {
case l:
case n:
h = true;
}
}
if (h)
return (
(h = a),
(c = c(h)),
(a = "" === d ? "." + Q(h, 0) : d),
I(c) ?
((e = ""),
null != a && (e = a.replace(P, "$&/") + "/"),
R(c, b, e, "", function (a) {
return a;
}))
: null != c && (O(c) && (c = N(c, e + (!c.key || (h && h.key === c.key) ? "" : ("" + c.key).replace(P, "$&/") + "/") + a)), b.push(c)),
1
);
h = 0;
d = "" === d ? "." : d + ":";
if (I(a))
for (var g = 0; g < a.length; g++) {
k = a[g];
var f = d + Q(k, g);
h += R(k, b, e, f, c);
}
else if (((f = A(a)), "function" === typeof f)) for (a = f.call(a), g = 0; !(k = a.next()).done; ) ((k = k.value), (f = d + Q(k, g++)), (h += R(k, b, e, f, c)));
else if ("object" === k)
throw (
(b = String(a)),
Error(
"Objects are not valid as a React child (found: " +
("[object Object]" === b ? "object with keys {" + Object.keys(a).join(", ") + "}" : b) +
"). If you meant to render a collection of children, use an array instead.",
)
);
return h;
}
function S(a, b, e) {
if (null == a) return a;
var d = [],
c = 0;
R(a, d, "", "", function (a) {
return b.call(e, a, c++);
});
return d;
}
function T(a) {
if (-1 === a._status) {
var b = a._result;
b = b();
b.then(
function (b) {
if (0 === a._status || -1 === a._status) ((a._status = 1), (a._result = b));
},
function (b) {
if (0 === a._status || -1 === a._status) ((a._status = 2), (a._result = b));
},
);
-1 === a._status && ((a._status = 0), (a._result = b));
}
if (1 === a._status) return a._result.default;
throw a._result;
}
var U = { current: null },
V = { transition: null },
W = { ReactCurrentDispatcher: U, ReactCurrentBatchConfig: V, ReactCurrentOwner: K };
function X() {
throw Error("act(...) is not supported in production builds of React.");
}
react_production_min.Children = {
map: S,
forEach: function (a, b, e) {
S(
a,
function () {
b.apply(this, arguments);
},
e,
);
},
count: function (a) {
var b = 0;
S(a, function () {
b++;
});
return b;
},
toArray: function (a) {
return (
S(a, function (a) {
return a;
}) || []
);
},
only: function (a) {
if (!O(a)) throw Error("React.Children.only expected to receive a single React element child.");
return a;
},
};
react_production_min.Component = E;
react_production_min.Fragment = p;
react_production_min.Profiler = r;
react_production_min.PureComponent = G;
react_production_min.StrictMode = q;
react_production_min.Suspense = w;
react_production_min.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = W;
react_production_min.act = X;
react_production_min.cloneElement = function (a, b, e) {
if (null === a || void 0 === a) throw Error("React.cloneElement(...): The argument must be a React element, but you passed " + a + ".");
var d = C({}, a.props),
c = a.key,
k = a.ref,
h = a._owner;
if (null != b) {
void 0 !== b.ref && ((k = b.ref), (h = K.current));
void 0 !== b.key && (c = "" + b.key);
if (a.type && a.type.defaultProps) var g = a.type.defaultProps;
for (f in b) J.call(b, f) && !L.hasOwnProperty(f) && (d[f] = void 0 === b[f] && void 0 !== g ? g[f] : b[f]);
}
var f = arguments.length - 2;
if (1 === f) d.children = e;
else if (1 < f) {
g = Array(f);
for (var m = 0; m < f; m++) g[m] = arguments[m + 2];
d.children = g;
}
return { $$typeof: l, type: a.type, key: c, ref: k, props: d, _owner: h };
};
react_production_min.createContext = function (a) {
a = { $$typeof: u, _currentValue: a, _currentValue2: a, _threadCount: 0, Provider: null, Consumer: null, _defaultValue: null, _globalName: null };
a.Provider = { $$typeof: t, _context: a };
return (a.Consumer = a);
};
react_production_min.createElement = M;
react_production_min.createFactory = function (a) {
var b = M.bind(null, a);
b.type = a;
return b;
};
react_production_min.createRef = function () {
return { current: null };
};
react_production_min.forwardRef = function (a) {
return { $$typeof: v, render: a };
};
react_production_min.isValidElement = O;
react_production_min.lazy = function (a) {
return { $$typeof: y, _payload: { _status: -1, _result: a }, _init: T };
};
react_production_min.memo = function (a, b) {
return { $$typeof: x, type: a, compare: void 0 === b ? null : b };
};
react_production_min.startTransition = function (a) {
var b = V.transition;
V.transition = {};
try {
a();
} finally {
V.transition = b;
}
};
react_production_min.unstable_act = X;
react_production_min.useCallback = function (a, b) {
return U.current.useCallback(a, b);
};
react_production_min.useContext = function (a) {
return U.current.useContext(a);
};
react_production_min.useDebugValue = function () {};
react_production_min.useDeferredValue = function (a) {
return U.current.useDeferredValue(a);
};
react_production_min.useEffect = function (a, b) {
return U.current.useEffect(a, b);
};
react_production_min.useId = function () {
return U.current.useId();
};
react_production_min.useImperativeHandle = function (a, b, e) {
return U.current.useImperativeHandle(a, b, e);
};
react_production_min.useInsertionEffect = function (a, b) {
return U.current.useInsertionEffect(a, b);
};
react_production_min.useLayoutEffect = function (a, b) {
return U.current.useLayoutEffect(a, b);
};
react_production_min.useMemo = function (a, b) {
return U.current.useMemo(a, b);
};
react_production_min.useReducer = function (a, b, e) {
return U.current.useReducer(a, b, e);
};
react_production_min.useRef = function (a) {
return U.current.useRef(a);
};
react_production_min.useState = function (a) {
return U.current.useState(a);
};
react_production_min.useSyncExternalStore = function (a, b, e) {
return U.current.useSyncExternalStore(a, b, e);
};
react_production_min.useTransition = function () {
return U.current.useTransition();
};
react_production_min.version = "18.3.1";
return react_production_min;
}
var hasRequiredReact;
function requireReact() {
if (hasRequiredReact) return react.exports;
hasRequiredReact = 1;
{
react.exports = requireReact_production_min();
}
return react.exports;
}
export { requireReact as r };

7665
web/dist/assets/index-eoEhLOdg.js vendored Normal file

File diff suppressed because it is too large Load Diff

59
web/dist/assets/jsx-runtime-CvJTHeKY.js vendored Normal file
View File

@ -0,0 +1,59 @@
import { r as requireReact } from "./index-DQGM2Mpm.js";
var jsxRuntime = { exports: {} };
var reactJsxRuntime_production_min = {};
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
var hasRequiredReactJsxRuntime_production_min;
function requireReactJsxRuntime_production_min() {
if (hasRequiredReactJsxRuntime_production_min) return reactJsxRuntime_production_min;
hasRequiredReactJsxRuntime_production_min = 1;
var f = requireReact(),
k = Symbol.for("react.element"),
l = Symbol.for("react.fragment"),
m = Object.prototype.hasOwnProperty,
n = f.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,
p = { key: true, ref: true, __self: true, __source: true };
function q(c, a, g) {
var b,
d = {},
e = null,
h = null;
void 0 !== g && (e = "" + g);
void 0 !== a.key && (e = "" + a.key);
void 0 !== a.ref && (h = a.ref);
for (b in a) m.call(a, b) && !p.hasOwnProperty(b) && (d[b] = a[b]);
if (c && c.defaultProps) for (b in ((a = c.defaultProps), a)) void 0 === d[b] && (d[b] = a[b]);
return { $$typeof: k, type: c, key: e, ref: h, props: d, _owner: n.current };
}
reactJsxRuntime_production_min.Fragment = l;
reactJsxRuntime_production_min.jsx = q;
reactJsxRuntime_production_min.jsxs = q;
return reactJsxRuntime_production_min;
}
var hasRequiredJsxRuntime;
function requireJsxRuntime() {
if (hasRequiredJsxRuntime) return jsxRuntime.exports;
hasRequiredJsxRuntime = 1;
{
jsxRuntime.exports = requireReactJsxRuntime_production_min();
}
return jsxRuntime.exports;
}
var jsxRuntimeExports = requireJsxRuntime();
export { jsxRuntimeExports as j };

File diff suppressed because one or more lines are too long

2890
web/dist/assets/module-CfopjtOo.js vendored Normal file

File diff suppressed because one or more lines are too long

78
web/dist/assets/native-DB2Z31Rx.js vendored Normal file

File diff suppressed because one or more lines are too long

88
web/dist/assets/remoteEntry.js vendored Normal file
View File

@ -0,0 +1,88 @@
const currentImports = {};
const exportSet = new Set(["Module", "__esModule", "default", "_export_sfc"]);
let moduleMap = {
"./editor": () => {
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./editor");
return __federation_import("./__federation_expose_Editor-BDCHVRx7.js").then((module) => (Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module));
},
"./settings": () => {
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./settings");
return __federation_import("./__federation_expose_Settings-C7jFM1o4.js").then((module) => (Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module));
},
};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;
if (typeof metaUrl === "undefined") {
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
return;
}
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf("remoteEntry.js"));
const base = "/";
("assets");
cssFilePaths.forEach((cssPath) => {
let href = "";
const baseUrl = base || curUrl;
if (baseUrl) {
const trimmer = {
trailing: (path) => (path.endsWith("/") ? path.slice(0, -1) : path),
leading: (path) => (path.startsWith("/") ? path.slice(1) : path),
};
const isAbsoluteUrl = (url) => url.startsWith("http") || url.startsWith("//");
const cleanBaseUrl = trimmer.trailing(baseUrl);
const cleanCssPath = trimmer.leading(cssPath);
const cleanCurUrl = trimmer.trailing(curUrl);
if (isAbsoluteUrl(baseUrl)) {
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join("/");
} else {
if (cleanCurUrl.includes(cleanBaseUrl)) {
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join("/");
} else {
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join("/");
}
}
} else {
href = cssPath;
}
if (dontAppendStylesToHead) {
const key = "css__calcomblock__" + exposeItemName;
window[key] = window[key] || [];
window[key].push(href);
return;
}
if (href in seen) return;
seen[href] = true;
const element = document.createElement("link");
element.rel = "stylesheet";
element.href = href;
document.head.appendChild(element);
});
};
async function __federation_import(name) {
currentImports[name] ??= import(name);
return currentImports[name];
}
const get = (module) => {
if (!moduleMap[module]) throw new Error("Can not find remote module " + module);
return moduleMap[module]();
};
const init = (shareScope) => {
globalThis.__federation_shared__ = globalThis.__federation_shared__ || {};
Object.entries(shareScope).forEach(([key, value]) => {
for (const [versionKey, versionValue] of Object.entries(value)) {
const scope = versionValue.scope || "default";
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
const shared = globalThis.__federation_shared__[scope];
(shared[key] = shared[key] || {})[versionKey] = versionValue;
}
});
};
export { dynamicLoadingCss, get, init };

1028
web/dist/assets/style-Bs2zy9jQ.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
import { r as requireReact } from "./index-DQGM2Mpm.js";
import { X as requireShim } from "./index-Bs--Ol2m.js";
var withSelector = { exports: {} };
var withSelector_production = {};
/**
* @license React
* use-sync-external-store-shim/with-selector.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
var hasRequiredWithSelector_production;
function requireWithSelector_production() {
if (hasRequiredWithSelector_production) return withSelector_production;
hasRequiredWithSelector_production = 1;
var React = requireReact(),
shim = requireShim();
function is(x, y) {
return (x === y && (0 !== x || 1 / x === 1 / y)) || (x !== x && y !== y);
}
var objectIs = "function" === typeof Object.is ? Object.is : is,
useSyncExternalStore = shim.useSyncExternalStore,
useRef = React.useRef,
useEffect = React.useEffect,
useMemo = React.useMemo,
useDebugValue = React.useDebugValue;
withSelector_production.useSyncExternalStoreWithSelector = function (subscribe, getSnapshot, getServerSnapshot, selector, isEqual) {
var instRef = useRef(null);
if (null === instRef.current) {
var inst = { hasValue: false, value: null };
instRef.current = inst;
} else inst = instRef.current;
instRef = useMemo(
function () {
function memoizedSelector(nextSnapshot) {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
nextSnapshot = selector(nextSnapshot);
if (void 0 !== isEqual && inst.hasValue) {
var currentSelection = inst.value;
if (isEqual(currentSelection, nextSnapshot)) return (memoizedSelection = currentSelection);
}
return (memoizedSelection = nextSnapshot);
}
currentSelection = memoizedSelection;
if (objectIs(memoizedSnapshot, nextSnapshot)) return currentSelection;
var nextSelection = selector(nextSnapshot);
if (void 0 !== isEqual && isEqual(currentSelection, nextSelection)) return ((memoizedSnapshot = nextSnapshot), currentSelection);
memoizedSnapshot = nextSnapshot;
return (memoizedSelection = nextSelection);
}
var hasMemo = false,
memoizedSnapshot,
memoizedSelection,
maybeGetServerSnapshot = void 0 === getServerSnapshot ? null : getServerSnapshot;
return [
function () {
return memoizedSelector(getSnapshot());
},
null === maybeGetServerSnapshot ? void 0 : (
function () {
return memoizedSelector(maybeGetServerSnapshot());
}
),
];
},
[getSnapshot, getServerSnapshot, selector, isEqual],
);
var value = useSyncExternalStore(subscribe, instRef[0], instRef[1]);
useEffect(
function () {
inst.hasValue = true;
inst.value = value;
},
[value],
);
useDebugValue(value);
return value;
};
return withSelector_production;
}
var hasRequiredWithSelector;
function requireWithSelector() {
if (hasRequiredWithSelector) return withSelector.exports;
hasRequiredWithSelector = 1;
{
withSelector.exports = requireWithSelector_production();
}
return withSelector.exports;
}
var withSelectorExports = requireWithSelector();
export { withSelectorExports as w };

10
web/dist/index.html vendored Normal file
View File

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<title>CalcomBlock Plugin</title>
<link rel="stylesheet" crossorigin href="/assets/style-Bs2zy9jQ.css" />
</head>
<body>
<!-- This is a placeholder for Module Federation remote entry -->
</body>
</html>

428
web/editor.tsx Normal file
View File

@ -0,0 +1,428 @@
import { useCallback, useEffect, useState } from "react";
import {
Alert,
AlertDescription,
type BlockEditorProps,
Button,
type ReactNode,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Settings,
Edit3,
CheckCircle2,
XCircle,
Loader2,
Key,
AlertCircle,
ExternalLink,
RefreshCw,
t,
} from "@block-ninja/ui";
type EventTypeSummary = {
id: number;
slug: string;
title: string;
lengthInMinutes: number;
};
function renderConnectedAccount(apiKeyConfigured: boolean | null, calcomUsername: string) {
if (apiKeyConfigured === false) {
return <p className="text-xs text-muted-foreground">{t("calcom.editor.connectedAs.noKey", "Configure the API key in plugin settings to connect an account.")}</p>;
}
if (calcomUsername) {
return <p className="text-sm font-mono">{calcomUsername}</p>;
}
return <p className="text-xs text-muted-foreground">{t("calcom.editor.connectedAs.resolving", "Resolving account from API key…")}</p>;
}
function CalcomBlockEditor({ content, onChange }: BlockEditorProps) {
const [activeTab, setActiveTab] = useState("config");
const [apiKeyConfigured, setApiKeyConfigured] = useState<boolean | null>(null);
const [calcomUsername, setCalcomUsername] = useState<string>("");
const [eventTypes, setEventTypes] = useState<EventTypeSummary[]>([]);
const [loadingEventTypes, setLoadingEventTypes] = useState(false);
const [fetchFailed, setFetchFailed] = useState(false);
const [retryCounter, setRetryCounter] = useState(0);
const handleFieldChange = useCallback(
(field: string, value: unknown) => {
onChange({ ...content, [field]: value });
},
[content, onChange],
);
const getString = (field: string, defaultValue = "") => {
return (content[field] as string) || defaultValue;
};
const getNumber = (field: string, defaultValue = 0) => {
const val = content[field];
if (typeof val === "number") return val;
if (typeof val === "string") return parseInt(val, 10) || defaultValue;
return defaultValue;
};
const getBool = (field: string, defaultValue = false) => {
const val = content[field];
if (typeof val === "boolean") return val;
return defaultValue;
};
const eventTypeSlug = getString("eventTypeSlug");
useEffect(() => {
fetch(`/api/plugins/calcomblock/settings`)
.then((r) => r.json())
.then((d: { api_key_configured?: boolean; username?: string }) => {
setApiKeyConfigured(!!d.api_key_configured);
setCalcomUsername(d.username ?? "");
})
.catch(() => setApiKeyConfigured(false));
}, []);
// Stamp the resolved Cal.com username into block content so the public
// render path (block.go BookingConfig.Username) keeps working without any
// backend rewiring. Existing blocks with a stale username get refreshed
// automatically the next time the editor opens.
useEffect(() => {
if (calcomUsername && content.username !== calcomUsername) {
onChange({ ...content, username: calcomUsername });
}
}, [calcomUsername, content, onChange]);
useEffect(() => {
if (!apiKeyConfigured) {
setEventTypes([]);
setFetchFailed(false);
return;
}
const timer = setTimeout(() => {
setLoadingEventTypes(true);
setFetchFailed(false);
fetch(`/api/plugins/calcomblock/event-types?username=${encodeURIComponent(calcomUsername)}`)
.then((r) => r.json())
.then((d: { success?: boolean; event_types?: EventTypeSummary[] }) => {
if (d.success) {
setEventTypes(d.event_types ?? []);
} else {
setEventTypes([]);
setFetchFailed(true);
}
})
.catch(() => {
setEventTypes([]);
setFetchFailed(true);
})
.finally(() => setLoadingEventTypes(false));
}, 300);
return () => clearTimeout(timer);
}, [apiKeyConfigured, calcomUsername, retryCounter]);
const handleRetry = useCallback(() => {
setRetryCounter((n) => n + 1);
}, []);
const selectPlaceholder = (() => {
if (apiKeyConfigured === false) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Key className="h-3 w-3" />
{t("calcom.editor.eventType.placeholder.noApiKey", "Configure API key first")}
</span>
);
}
if (loadingEventTypes) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("calcom.editor.eventType.placeholder.loading", "Loading…")}
</span>
);
}
if (!calcomUsername) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("calcom.editor.eventType.placeholder.resolvingUser", "Resolving account…")}
</span>
);
}
if (eventTypes.length === 0) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<XCircle className="h-3 w-3" />
{t("calcom.editor.eventType.placeholder.noEventTypes", "No event types found")}
</span>
);
}
return t("calcom.editor.eventType.placeholder.select", "Select event type");
})();
const apiKeyReady = apiKeyConfigured === true;
const usernameReady = calcomUsername.length > 0;
const eventTypeReady = eventTypeSlug.length > 0;
const canOpenInCalcom = usernameReady && eventTypeReady;
const renderStatusRow = (ok: boolean, label: string, action?: ReactNode) => (
<div className="flex items-center gap-2 text-sm">
{ok ?
<CheckCircle2 className="h-4 w-4 text-primary" aria-hidden="true" />
: <XCircle className="h-4 w-4 text-destructive" aria-hidden="true" />}
<span className={ok ? "text-foreground" : "text-muted-foreground"}>{label}</span>
{action ?
<span className="ml-auto">{action}</span>
: null}
</div>
);
return (
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="config" className="gap-2">
<Settings className="h-4 w-4" />
<span>{t("calcom.editor.tab.configuration", "Configuration")}</span>
</TabsTrigger>
<TabsTrigger value="content" className="gap-2">
<Edit3 className="h-4 w-4" />
<span>{t("calcom.editor.tab.content", "Content")}</span>
</TabsTrigger>
</TabsList>
{/* Configuration Tab */}
<TabsContent value="config" className="space-y-4 mt-4">
{apiKeyConfigured === false && (
<Alert>
<AlertDescription>
{t("calcom.editor.alert.apiKeyMissing.prefix", "Configure your Cal.com API key in")}{" "}
<a href="/admin/plugins?plugin=calcomblock" className="text-primary hover:underline">
{t("calcom.editor.alert.apiKeyMissing.linkLabel", "plugin settings")}
</a>{" "}
{t("calcom.editor.alert.apiKeyMissing.suffix", "to enable the event-type dropdown.")}
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.section.config.title", "Cal.com Settings")}</CardTitle>
<CardDescription>{t("calcom.editor.section.config.description", "Connect to your Cal.com account and select an event type")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("calcom.editor.connectedAs.label", "Connected Account")}</Label>
{renderConnectedAccount(apiKeyConfigured, calcomUsername)}
</div>
<div className="space-y-2">
<Label htmlFor="eventTypeSlug">
{t("calcom.editor.eventType.label", "Event Type")} <span className="text-destructive">*</span>
</Label>
{fetchFailed ?
<>
<div className="flex items-center gap-2">
<Input
id="eventTypeSlug"
value={eventTypeSlug}
onChange={(e) => handleFieldChange("eventTypeSlug", e.target.value)}
placeholder={t("calcom.editor.eventType.fallbackPlaceholder", "30min")}
className="flex-1"
/>
<Button
type="button"
size="sm"
variant="outline"
action="retry"
entity="event-types"
onClick={handleRetry}
disabled={loadingEventTypes}
aria-label={t("calcom.editor.eventType.retryAria", "Retry loading event types")}>
<RefreshCw className={`h-4 w-4 ${loadingEventTypes ? "animate-spin" : ""}`} aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.eventType.retryLabel", "Retry")}</span>
</Button>
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
<AlertDescription>{t("calcom.editor.eventType.fetchFailed", "Couldn't load event types from Cal.com. Enter the slug manually or retry.")}</AlertDescription>
</Alert>
</>
: <>
<Select
value={eventTypeSlug}
onValueChange={(v) => handleFieldChange("eventTypeSlug", v)}
disabled={loadingEventTypes || !apiKeyConfigured || eventTypes.length === 0}>
<SelectTrigger aria-label={t("calcom.editor.eventType.label", "Event Type")}>
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>
<SelectContent>
{eventTypes.map((et) => (
<SelectItem key={et.id} value={et.slug}>
{et.title}
<span className="text-muted-foreground">
{" "}
({et.slug} · {et.lengthInMinutes}m)
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("calcom.editor.eventType.help", "Fetched from your Cal.com account")}</p>
</>
}
</div>
<div className="space-y-2">
<Label htmlFor="weeksToShow">{t("calcom.editor.weeksToShow.label", "Weeks to Display")}</Label>
<Select value={String(getNumber("weeksToShow", 2))} onValueChange={(v) => handleFieldChange("weeksToShow", parseInt(v, 10))}>
<SelectTrigger aria-label={t("calcom.editor.weeksToShow.label", "Weeks to Display")}>
<SelectValue placeholder={t("calcom.editor.weeksToShow.placeholder", "Select weeks")} />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
<SelectItem key={n} value={String(n)}>
{n} {n > 1 ? t("calcom.editor.weeksToShow.unitPlural", "weeks") : t("calcom.editor.weeksToShow.unitSingular", "week")}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("calcom.editor.weeksToShow.help", "How many weeks of dates to show in the calendar")}</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>{t("calcom.editor.showTimezone.label", "Show Timezone Selector")}</Label>
<p className="text-xs text-muted-foreground">
{t("calcom.editor.showTimezone.help", "Show a label above the date grid indicating which timezone times are displayed in.")}
</p>
</div>
<Switch
checked={getBool("showTimezone", true)}
onCheckedChange={(v) => handleFieldChange("showTimezone", v)}
aria-label={t("calcom.editor.showTimezone.label", "Show Timezone Selector")}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.status.title", "Configuration status")}</CardTitle>
<CardDescription>{t("calcom.editor.status.description", "Quick check of the values needed before this block renders bookings.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{renderStatusRow(
apiKeyReady,
t("calcom.editor.status.apiKey", "API key configured"),
!apiKeyReady ?
<a href="/admin/plugins?plugin=calcomblock" className="text-xs text-primary hover:underline">
{t("calcom.editor.status.apiKey.configureLink", "Configure")}
</a>
: null,
)}
{renderStatusRow(usernameReady, t("calcom.editor.status.username", "Account resolved"))}
{renderStatusRow(eventTypeReady, t("calcom.editor.status.eventType", "Event type selected"))}
<div className="flex items-center gap-2 text-sm">
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">·</span>
<span className="text-muted-foreground">
{t("calcom.editor.status.eventTypesAvailable", "{count} event types available").replace("{count}", String(eventTypes.length))}
</span>
</div>
<div className="pt-2">
<Button
type="button"
size="sm"
variant="outline"
action="open"
entity="calcom-page"
asChild={canOpenInCalcom}
disabled={!canOpenInCalcom}
aria-label={t("calcom.editor.status.openExternalAria", "Open Cal.com booking page in a new tab")}>
{canOpenInCalcom ?
<a href={`https://cal.com/${calcomUsername}/${eventTypeSlug}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.status.openExternal", "Open in Cal.com")}</span>
</a>
: <>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="ml-1">{t("calcom.editor.status.openExternal", "Open in Cal.com")}</span>
</>
}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Content Tab */}
<TabsContent value="content" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.editor.section.content.title", "Widget Content")}</CardTitle>
<CardDescription>{t("calcom.editor.section.content.description", "Customize the text shown in the booking widget")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("calcom.editor.title.label", "Title")}</Label>
<Input
id="title"
value={getString("title")}
onChange={(e) => handleFieldChange("title", e.target.value)}
placeholder={t("calcom.editor.title.placeholder", "Book a Meeting")}
/>
<p className="text-xs text-muted-foreground">{t("calcom.editor.title.help", "Main heading shown above the calendar")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("calcom.editor.description.label", "Description")}</Label>
<Textarea
id="description"
value={getString("description")}
onChange={(e) => handleFieldChange("description", e.target.value)}
placeholder={t("calcom.editor.description.placeholder", "Choose a convenient time for your consultation...")}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t("calcom.editor.description.help", "Optional text shown below the title")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="unavailableMessage">{t("calcom.editor.unavailableMessage.label", "Booking unavailable message")}</Label>
<Textarea
id="unavailableMessage"
value={getString("unavailableMessage")}
onChange={(e) => handleFieldChange("unavailableMessage", e.target.value)}
placeholder={t(
"calcom.editor.unavailableMessage.placeholder",
"Sorry — we couldn't complete your booking online. Please use our contact form and we'll get you booked in.",
)}
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t("calcom.editor.unavailableMessage.help", "Shown if an online booking can't be completed. Leave blank to use the default.")}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
export default CalcomBlockEditor;

3
web/entry.ts Normal file
View File

@ -0,0 +1,3 @@
// Placeholder entry for Module Federation remote
// The actual editor is exposed via "./editor" in the federation config
export {};

3
web/eslint.config.js Normal file
View File

@ -0,0 +1,3 @@
import config from "../../../cms/web/eslint.config.js";
export default config;

10
web/index.html Normal file
View File

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<title>CalcomBlock Plugin</title>
</head>
<body>
<!-- This is a placeholder for Module Federation remote entry -->
<script type="module" src="./entry.ts"></script>
</body>
</html>

22
web/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "calcomblock-editor",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"dependencies": {
"@block-ninja/ui": "^0.1.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",
"@types/react": "^18.3.28",
"@vitejs/plugin-react": "^4.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.9.3",
"vite": "^6.4.1"
}
}

13
web/prettier.config.cjs Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
arrowParens: "always",
bracketSameLine: true,
bracketSpacing: true,
experimentalTernaries: true,
printWidth: 200,
quoteProps: "consistent",
semi: true,
singleQuote: false,
tabWidth: 4,
trailingComma: "all",
useTabs: true,
};

515
web/settings.tsx Normal file
View File

@ -0,0 +1,515 @@
import { useState, useCallback, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Button,
Alert,
AlertDescription,
Badge,
ScrollArea,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
Eye,
EyeOff,
Key,
Zap,
Settings,
Send,
Copy,
RefreshCw,
toast,
t,
useAlertDialog,
} from "@block-ninja/ui";
type PluginSettingsProps = {
pluginName: string;
};
type SettingsData = {
api_key_configured: boolean;
api_key_masked?: string;
webhook_url?: string;
webhook_secret_configured?: boolean;
webhook_secret_masked?: string;
last_event_at?: string;
last_event_type?: string;
};
type TestResult = {
success: boolean;
error?: string;
event_types?: { id: number; title: string; slug: string }[];
};
const copyToClipboard = async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
toast.success(t("calcom.settings.copied", "Copied to clipboard"));
} catch (_error) {
// Clipboard API may be unavailable (non-secure context).
toast.error(t("calcom.settings.copyFailed", "Could not copy to clipboard"));
}
};
const formatRelativeTime = (iso: string): string => {
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) return iso;
const diffMs = Date.now() - parsed;
const minutes = Math.round(diffMs / 60000);
if (minutes < 1) return t("calcom.settings.relative.justNow", "just now");
if (minutes < 60) {
return minutes === 1 ? t("calcom.settings.relative.minute", "1 minute ago") : t("calcom.settings.relative.minutes", "{count} minutes ago").replace("{count}", String(minutes));
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return hours === 1 ? t("calcom.settings.relative.hour", "1 hour ago") : t("calcom.settings.relative.hours", "{count} hours ago").replace("{count}", String(hours));
}
const days = Math.round(hours / 24);
return days === 1 ? t("calcom.settings.relative.day", "1 day ago") : t("calcom.settings.relative.days", "{count} days ago").replace("{count}", String(days));
};
function CalcomSettings({ pluginName }: PluginSettingsProps) {
const { confirm } = useAlertDialog();
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [rotating, setRotating] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [settings, setSettings] = useState<SettingsData | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const baseUrl = `/api/plugins/${pluginName}`;
// Follow-up: when @block-ninja/ui re-exports useQuery from connect-query
// AND this plugin's settings endpoints move to proto-backed RPCs, swap this
// useEffect + fetch pair for useQuery with a staleTime so re-opening the
// settings panel does not refetch. Today neither precondition holds (no
// .proto for this plugin, no tanstack-query primitives re-exported), so
// the raw fetch stays.
const loadSettings = useCallback(async () => {
try {
const res = await fetch(`${baseUrl}/settings`);
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch (_error) {
console.error("Failed to load settings");
} finally {
setLoading(false);
}
}, [baseUrl]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
// Save API key
const handleSave = async () => {
if (!apiKey.trim()) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.errorEmpty", "Please enter an API key") });
return;
}
setSaving(true);
setSaveMessage(null);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
if (res.ok) {
// Reload settings to pick up the auto-generated webhook secret.
await loadSettings();
setApiKey("");
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.saved", "API key saved successfully") });
} else {
const text = await res.text();
setSaveMessage({ type: "error", text: text || t("calcom.settings.apiKey.saveFailed", "Failed to save API key") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setSaving(false);
}
};
// Test connection
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data);
} catch (_error) {
setTestResult({ success: false, error: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setTesting(false);
}
};
// Clear API key
const handleClear = async () => {
setSaving(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: "" }),
});
if (res.ok) {
await loadSettings();
setTestResult(null);
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.removed", "API key removed") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.clearFailed", "Failed to clear API key") });
} finally {
setSaving(false);
}
};
// Rotate webhook secret
const handleRotate = async () => {
const confirmed = await confirm({
title: t("calcom.settings.webhook.rotateConfirmTitle", "Rotate webhook secret?"),
message: t("calcom.settings.webhook.rotateConfirmMessage", "This invalidates the existing secret immediately. You'll need to update Cal.com to keep webhooks flowing."),
confirmLabel: t("calcom.settings.webhook.rotateConfirmLabel", "Rotate"),
variant: "destructive",
});
if (!confirmed) return;
setRotating(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings/rotate-webhook-secret`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setSettings((prev) => (prev ? { ...prev, webhook_secret_masked: data.webhook_secret_masked, webhook_secret_configured: true } : prev));
const successMsg = t("calcom.settings.webhook.rotated", "Webhook secret rotated. Update it in Cal.com.");
setSaveMessage({ type: "success", text: successMsg });
toast.success(successMsg);
} else {
setSaveMessage({ type: "error", text: t("calcom.settings.webhook.rotateFailed", "Failed to rotate webhook secret") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setRotating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const apiKeyVisibilityLabel = showApiKey ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
const secretVisibilityLabel = showSecret ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" />
{t("calcom.settings.heading", "Cal.com Integration")}
</h2>
<p className="text-sm text-muted-foreground">{t("calcom.settings.headingDescription", "Configure your Cal.com API key to enable booking functionality")}</p>
</div>
{/* Current Status */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-4 w-4" />
{t("calcom.settings.apiKey.title", "API Key Status")}
</CardTitle>
<CardDescription>{t("calcom.settings.apiKey.description", "Your Cal.com API key is required for the booking widget to function")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{settings?.api_key_configured ?
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-3">
<Badge variant="default" className="gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("calcom.settings.apiKey.configuredLabel", "Configured")}
</Badge>
<p className="text-xs text-muted-foreground font-mono">{settings.api_key_masked || t("calcom.settings.apiKey.maskedFallback", "***configured***")}</p>
</div>
<Button action="delete" entity="plugin-setting" variant="ghost" size="sm" onClick={handleClear} disabled={saving}>
{t("calcom.settings.apiKey.remove", "Remove")}
</Button>
</div>
: <div className="flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
<Badge variant="outline" className="gap-1.5">
<XCircle className="h-3.5 w-3.5" />
{t("calcom.settings.apiKey.notConfiguredLabel", "Not configured")}
</Badge>
<p className="text-xs text-muted-foreground">{t("calcom.settings.apiKey.notConfiguredHelp", "Enter your Cal.com API key below to enable booking")}</p>
</div>
}
{/* API Key Input */}
<div className="space-y-2">
<Label htmlFor="apiKey">{settings?.api_key_configured ? t("calcom.settings.apiKey.updateLabel", "Update API Key") : t("calcom.settings.apiKey.label", "API Key")}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="apiKey"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t("calcom.settings.apiKey.placeholder", "cal_live_...")}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
action="toggle"
entity="api-key-visibility"
onClick={() => setShowApiKey(!showApiKey)}
aria-label={apiKeyVisibilityLabel}
className="absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground">
{showApiKey ?
<EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
</div>
<Button action="save" entity="plugin-setting" onClick={handleSave} disabled={saving || !apiKey.trim()}>
{saving ?
<Loader2 className="h-4 w-4 animate-spin" />
: t("calcom.settings.apiKey.save", "Save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("calcom.settings.apiKey.helpPrefix", "Get your API key from")}{" "}
<a href="https://app.cal.com/settings/developer/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{t("calcom.settings.apiKey.helpLink", "Cal.com API Settings")}
</a>
</p>
</div>
{/* Save Message */}
{saveMessage && (
<Alert variant={saveMessage.type === "error" ? "destructive" : "default"}>
<AlertDescription>{saveMessage.text}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Test Connection */}
{settings?.api_key_configured && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Zap className="h-4 w-4" />
{t("calcom.settings.test.title", "Test Connection")}
</CardTitle>
<CardDescription>{t("calcom.settings.test.description", "Verify your API key works and see available event types")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button action="settings" entity="plugin-connection" onClick={handleTest} disabled={testing}>
{testing ?
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("calcom.settings.test.testing", "Testing...")}
</>
: t("calcom.settings.test.button", "Test Connection")}
</Button>
{testResult && (
<div className="space-y-3">
{testResult.success ?
<>
<Alert>
<CheckCircle2 className="h-4 w-4 text-success" />
<AlertDescription className="ml-2">
{t("calcom.settings.test.successPrefix", "Connection successful! Found")} {testResult.event_types?.length || 0}{" "}
{t("calcom.settings.test.successSuffix", "event types.")}
</AlertDescription>
</Alert>
{testResult.event_types && testResult.event_types.length > 0 && (
<ScrollArea className="max-h-96 rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-3 py-2 font-medium">{t("calcom.settings.test.tableEventType", "Event Type")}</th>
<th className="text-left px-3 py-2 font-medium">{t("calcom.settings.test.tableSlug", "Slug")}</th>
</tr>
</thead>
<tbody>
{testResult.event_types.map((et) => (
<tr key={et.id} className="border-t">
<td className="px-3 py-2">{et.title}</td>
<td className="px-3 py-2 font-mono text-xs">{et.slug}</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
)}
</>
: <Alert variant="destructive">
<XCircle className="h-4 w-4" />
<AlertDescription className="ml-2">{testResult.error || t("calcom.settings.test.failure", "Connection failed")}</AlertDescription>
</Alert>
}
</div>
)}
</CardContent>
</Card>
)}
{/* Webhook */}
{settings?.api_key_configured && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Send className="h-4 w-4" />
{t("calcom.settings.webhook.title", "Webhook")}
</CardTitle>
<CardDescription>{t("calcom.settings.webhook.description", "Configure this URL in Cal.com → Settings → Developer → Webhooks to receive booking events.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhookUrl">{t("calcom.settings.webhook.urlLabel", "Webhook URL")}</Label>
<div className="flex gap-2">
<Input id="webhookUrl" readOnly value={settings?.webhook_url || ""} className="flex-1 font-mono text-xs" />
<Button
action="copy"
entity="webhook-url"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.copyUrl", "Copy webhook URL")}
onClick={() => copyToClipboard(settings?.webhook_url || "")}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="webhookSecret">{t("calcom.settings.webhook.secretLabel", "Webhook Secret")}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="webhookSecret"
type={showSecret ? "text" : "password"}
readOnly
value={settings?.webhook_secret_masked || t("calcom.settings.webhook.secretNotConfigured", "Not configured")}
className="pr-10 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
action="toggle"
entity="webhook-secret-visibility"
onClick={() => setShowSecret(!showSecret)}
aria-label={secretVisibilityLabel}
className="absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground">
{showSecret ?
<EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
</div>
<Button
action="copy"
entity="webhook-secret"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.copySecret", "Copy webhook secret")}
onClick={() => copyToClipboard(settings?.webhook_secret_masked || "")}>
<Copy className="h-4 w-4" />
</Button>
<Button
action="rotate"
entity="webhook-secret"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.rotate", "Rotate webhook secret")}
onClick={handleRotate}
disabled={rotating}>
{rotating ?
<Loader2 className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"calcom.settings.webhook.secretHelp",
"Cal.com displays the secret only once. Regenerating invalidates the previous value immediately — update it in Cal.com after rotating.",
)}
</p>
</div>
<div className="text-sm">
{settings?.last_event_at ?
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-4 w-4 text-success" />
<span>
{t("calcom.settings.webhook.lastEventPrefix", "Last event")} {formatRelativeTime(settings.last_event_at)}
{settings.last_event_type && (
<>
{" — "}
<code className="font-mono text-xs">{settings.last_event_type}</code>
</>
)}
</span>
</div>
: <div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>{t("calcom.settings.webhook.waitingForEvent", "Status: Waiting for first event")}</span>
</div>
}
</div>
</CardContent>
</Card>
)}
{/* Documentation */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.settings.docs.title", "Documentation")}</CardTitle>
<CardDescription>{t("calcom.settings.docs.description", "Reference material for working with the Cal.com plugin.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<a href="https://cal.com/docs/api-reference/v2" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline block">
{t("calcom.settings.docs.calcomApi", "Cal.com API documentation")}
</a>
{/* Follow-up: confirm /admin/docs/calcom-plugin route exists; fallback to the FEATURES/docs entry once published. */}
<a href="/admin/docs/calcom-plugin" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline block">
{t("calcom.settings.docs.pluginGuide", "BlockNinja plugin guide")}
</a>
</CardContent>
</Card>
</div>
);
}
export default CalcomSettings;

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["*.tsx", "*.ts"]
}

25
web/vite.config.ts Normal file
View File

@ -0,0 +1,25 @@
import federation from "@originjs/vite-plugin-federation";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
react(),
federation({
name: "calcomblock",
filename: "remoteEntry.js",
exposes: {
"./editor": "./editor.tsx",
"./settings": "./settings.tsx",
},
shared: ["react", "react-dom", "@block-ninja/ui"],
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
outDir: "dist",
},
});

160
webhook_handler.go Normal file
View File

@ -0,0 +1,160 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
)
// Notifier matches the existing CMS notifier callback signature
// (handlers/webhook_ingestion.go:34). The wiring lives in
// internal/server/server.go where the notification service is available.
type Notifier func(ctx context.Context, severity, title, message, uniqueKey string)
// notifier is a package-level callback set once at server startup via
// SetNotifyFn. nil-safe: dispatch() checks before invoking.
var notifier Notifier
// SetNotifyFn wires the CMS notification service into the cal.com webhook
// dispatcher. Called exactly once at server startup from server.go.
func SetNotifyFn(fn Notifier) {
notifier = fn
}
// WebhookHandler accepts Cal.com webhook events, verifies their HMAC signature
// against the configured secret, and fans them out via the package-level
// notifier. Mirrors the Stripe pattern of returning 200 on app-level errors so
// the sender does not flood retries with junk.
type WebhookHandler struct {
settings *SettingsManager
logger *slog.Logger
}
// NewWebhookHandler builds a webhook handler bound to the given settings
// store. The logger is used for security-relevant events (invalid signatures,
// missing configuration); body content is never logged.
func NewWebhookHandler(settings *SettingsManager, logger *slog.Logger) *WebhookHandler {
if logger == nil {
logger = slog.Default()
}
return &WebhookHandler{settings: settings, logger: logger}
}
// Handle is the HTTP handler for POST /webhook.
func (h *WebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sourceIP := helpers.GetRealIP(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024))
if err != nil {
h.logger.Warn("calcom webhook: read body", "error", err, "ip", sourceIP)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
secret, err := h.settings.GetWebhookSecret(ctx)
if err != nil {
h.logger.Warn("calcom webhook: load secret", "error", err, "ip", sourceIP)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if secret == "" {
h.logger.Warn("calcom webhook: not configured", "ip", sourceIP)
http.Error(w, "webhook not configured", http.StatusServiceUnavailable)
return
}
sig := r.Header.Get("X-Cal-Signature-256")
if sig == "" || !verifyCalcomSignature(body, sig, secret) {
h.logger.Warn("calcom webhook: invalid signature", "ip", sourceIP)
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var evt WebhookEvent
if err := json.Unmarshal(body, &evt); err != nil {
// Match the stripe pattern: ack invalid JSON so Cal.com doesn't keep retrying.
h.logger.Warn("calcom webhook: bad json", "error", err)
w.WriteHeader(http.StatusOK)
return
}
// Run dispatch + last-event recording inside a recover so a panicking
// notifier or settings store can't bubble a 500 to Cal.com and trigger a
// retry storm. Cal.com retries 5xx responses; the Stripe pattern in the
// rest of the CMS is to always ack 200 once the signature has verified.
func() {
defer func() {
if rec := recover(); rec != nil {
h.logger.Error("calcom webhook: panic in dispatch", "panic", rec)
}
}()
h.dispatch(ctx, evt)
if err := h.settings.UpdateLastEvent(ctx, evt.TriggerEvent, evt.CreatedAt); err != nil {
h.logger.Warn("calcom webhook: update last event", "error", err)
}
}()
w.WriteHeader(http.StatusOK)
}
// dispatch fans the parsed event out to the package notifier. Unknown trigger
// events are silently ignored — Cal.com adds new event types over time and we
// don't want noisy notifications for events we haven't taught the plugin yet.
func (h *WebhookHandler) dispatch(ctx context.Context, evt WebhookEvent) {
if notifier == nil {
return
}
attendeeName := ""
if len(evt.Payload.Attendees) > 0 {
attendeeName = evt.Payload.Attendees[0].Name
}
eventTitle := evt.Payload.EventType.Title
start := evt.Payload.StartTime
uniqueKey := fmt.Sprintf("calcom:%s:%s", evt.TriggerEvent, evt.Payload.UID)
var title, message string
switch evt.TriggerEvent {
case "BOOKING_CREATED":
title = "New booking"
message = fmt.Sprintf("%s booked %s at %s", attendeeName, eventTitle, start)
case "BOOKING_RESCHEDULED":
title = "Booking rescheduled"
message = fmt.Sprintf("%s moved %s to %s", attendeeName, eventTitle, start)
case "BOOKING_CANCELLED":
title = "Booking cancelled"
message = fmt.Sprintf("%s cancelled %s at %s", attendeeName, eventTitle, start)
default:
return
}
notifier(ctx, "info", title, message, uniqueKey)
}
// verifyCalcomSignature checks a Cal.com webhook signature. Accepts both the
// canonical "sha256=<hex>" and bare "<hex>" formats to match the established
// in-house pattern from handlers/webhook_ingestion.go:592. All comparisons use
// constant-time hmac.Equal to avoid timing-leak side channels.
func verifyCalcomSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expectedHex := hex.EncodeToString(mac.Sum(nil))
expectedSig := "sha256=" + expectedHex
sig := strings.TrimSpace(signature)
if hmac.Equal([]byte(expectedSig), []byte(sig)) {
return true
}
if hmac.Equal([]byte(expectedHex), []byte(sig)) {
return true
}
return false
}

542
webhook_test.go Normal file
View File

@ -0,0 +1,542 @@
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newTestWebhookHandler builds a WebhookHandler backed by an isolated
// in-memory SettingsManager. It is the preferred factory for all webhook tests.
func newTestWebhookHandler(t *testing.T) (*WebhookHandler, *SettingsManager) {
t.Helper()
settings, _ := newTestSettings()
h := NewWebhookHandler(settings, slog.Default())
return h, settings
}
// computeSig returns the HMAC-SHA256 hex digest for the given body and secret.
func computeSig(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// bookingEventBody builds a minimal, valid Cal.com webhook JSON payload.
func bookingEventBody(trigger, uid, attendeeName, eventTitle, startTime string) []byte {
return fmt.Appendf(nil,
`{"triggerEvent":%q,"createdAt":"2026-05-27T10:00:00Z","payload":{"uid":%q,"startTime":%q,"eventType":{"title":%q},"attendees":[{"name":%q}]}}`,
trigger, uid, startTime, eventTitle, attendeeName,
)
}
// sendSigned posts body to h with a correct "sha256=<hex>" signature header
// and returns the response recorder.
func sendSigned(t *testing.T, h *WebhookHandler, body []byte, secret string) *httptest.ResponseRecorder {
t.Helper()
sig := "sha256=" + computeSig(body, secret)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
return rec
}
// -------------------------------------------------------------------------
// WO-047 — signature validation
// -------------------------------------------------------------------------
// TestWebhook_ValidSignatureSha256Hex verifies that a request with the
// canonical "sha256=<hex>" signature format is accepted with 200 OK.
func TestWebhook_ValidSignatureSha256Hex(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
var called int
SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ })
t.Cleanup(func() { SetNotifyFn(nil) })
body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z")
sig := "sha256=" + computeSig(body, "supersecret")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if called != 1 {
t.Errorf("expected notifier called once, got %d", called)
}
}
// TestWebhook_ValidSignatureBareHex verifies that the bare "<hex>" format
// (without the "sha256=" prefix) is also accepted.
func TestWebhook_ValidSignatureBareHex(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
var called int
SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ })
t.Cleanup(func() { SetNotifyFn(nil) })
body := bookingEventBody("BOOKING_CREATED", "u2", "Alice", "Demo", "2026-05-27T11:00:00Z")
// Bare hex — no "sha256=" prefix.
sig := computeSig(body, "supersecret")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if called != 1 {
t.Errorf("expected notifier called once, got %d", called)
}
}
// TestWebhook_TamperedBody401 verifies that modifying the body after
// computing the signature triggers a 401 Unauthorized.
func TestWebhook_TamperedBody401(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
original := bookingEventBody("BOOKING_CREATED", "u3", "Eve", "Workshop", "2026-05-27T12:00:00Z")
sig := "sha256=" + computeSig(original, "supersecret")
// Tamper: change "Eve" to "Eve2" in the body after signing.
tampered := bytes.Replace(original, []byte("Eve"), []byte("Eve2"), 1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(tampered))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
// TestWebhook_WrongSecret401 verifies that signing with a different secret
// than the one configured in settings returns 401 Unauthorized.
func TestWebhook_WrongSecret401(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "right-secret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
body := bookingEventBody("BOOKING_CREATED", "u4", "Mallory", "Hack", "2026-05-27T13:00:00Z")
// Signed with the wrong secret.
sig := "sha256=" + computeSig(body, "wrong-secret")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
// TestWebhook_MissingHeader401 verifies that omitting X-Cal-Signature-256
// entirely returns 401 Unauthorized.
func TestWebhook_MissingHeader401(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
body := bookingEventBody("BOOKING_CREATED", "u5", "Anon", "Meeting", "2026-05-27T14:00:00Z")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
// Intentionally no X-Cal-Signature-256 header.
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
// TestWebhook_MissingSecret503 verifies that when no webhook secret has been
// configured, the handler returns 503 Service Unavailable.
func TestWebhook_MissingSecret503(t *testing.T) {
h, _ := newTestWebhookHandler(t)
// Deliberately do NOT call SetWebhookSecret — secret is absent.
body := bookingEventBody("BOOKING_CREATED", "u6", "Nobody", "Call", "2026-05-27T15:00:00Z")
sig := "sha256=" + computeSig(body, "anything")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", rec.Code)
}
}
// -------------------------------------------------------------------------
// WO-048 — dispatch + unique key + last-event
// -------------------------------------------------------------------------
// notifyCall captures a single invocation of the notifier.
type notifyCall struct {
severity, title, message, uniqueKey string
}
// captureNotifier installs a capturing notifier and returns a pointer to the
// slice of calls. The caller MUST register t.Cleanup(func() { SetNotifyFn(nil) }).
func captureNotifier(t *testing.T) *[]notifyCall {
t.Helper()
calls := &[]notifyCall{}
SetNotifyFn(func(_ context.Context, sev, title, msg, key string) {
*calls = append(*calls, notifyCall{sev, title, msg, key})
})
t.Cleanup(func() { SetNotifyFn(nil) })
return calls
}
// TestWebhook_BookingCreatedNotification verifies that a BOOKING_CREATED event
// fires a notification with the correct title, attendee name, event title, and
// unique key.
func TestWebhook_BookingCreatedNotification(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if len(*calls) != 1 {
t.Fatalf("expected 1 notifier call, got %d", len(*calls))
}
c := (*calls)[0]
if c.severity != "info" {
t.Errorf("expected severity 'info', got %q", c.severity)
}
if c.title != "New booking" {
t.Errorf("expected title 'New booking', got %q", c.title)
}
if !strings.Contains(c.message, "Bob") {
t.Errorf("expected message to contain 'Bob', got %q", c.message)
}
if !strings.Contains(c.message, "Quick chat") {
t.Errorf("expected message to contain 'Quick chat', got %q", c.message)
}
if c.uniqueKey != "calcom:BOOKING_CREATED:u1" {
t.Errorf("expected uniqueKey 'calcom:BOOKING_CREATED:u1', got %q", c.uniqueKey)
}
}
// TestWebhook_BookingRescheduledNotification verifies that BOOKING_RESCHEDULED
// produces the correct notification title and unique key.
func TestWebhook_BookingRescheduledNotification(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
body := bookingEventBody("BOOKING_RESCHEDULED", "u2", "Carol", "Check-in", "2026-05-28T09:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if len(*calls) != 1 {
t.Fatalf("expected 1 notifier call, got %d", len(*calls))
}
c := (*calls)[0]
if c.severity != "info" {
t.Errorf("expected severity 'info', got %q", c.severity)
}
if c.title != "Booking rescheduled" {
t.Errorf("expected title 'Booking rescheduled', got %q", c.title)
}
if c.uniqueKey != "calcom:BOOKING_RESCHEDULED:u2" {
t.Errorf("expected uniqueKey 'calcom:BOOKING_RESCHEDULED:u2', got %q", c.uniqueKey)
}
}
// TestWebhook_BookingCancelledNotification verifies that BOOKING_CANCELLED
// produces the correct notification title and unique key.
func TestWebhook_BookingCancelledNotification(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
body := bookingEventBody("BOOKING_CANCELLED", "u3", "Dave", "Strategy", "2026-05-29T14:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if len(*calls) != 1 {
t.Fatalf("expected 1 notifier call, got %d", len(*calls))
}
c := (*calls)[0]
if c.severity != "info" {
t.Errorf("expected severity 'info', got %q", c.severity)
}
if c.title != "Booking cancelled" {
t.Errorf("expected title 'Booking cancelled', got %q", c.title)
}
if c.uniqueKey != "calcom:BOOKING_CANCELLED:u3" {
t.Errorf("expected uniqueKey 'calcom:BOOKING_CANCELLED:u3', got %q", c.uniqueKey)
}
}
// TestWebhook_UnknownEventIgnored verifies that an unrecognised triggerEvent
// returns 200 but does NOT invoke the notifier — unknown events are silently
// dropped so future Cal.com additions don't cause noisy notifications.
func TestWebhook_UnknownEventIgnored(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
body := bookingEventBody("SOMETHING_ELSE", "u4", "Nobody", "Nothing", "2026-05-27T10:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
if len(*calls) != 0 {
t.Errorf("expected notifier NOT called for unknown event, got %d calls", len(*calls))
}
}
// TestWebhook_NilNotifierSafe verifies that a nil notifier does not panic —
// the handler must guard against an unregistered notifier gracefully.
func TestWebhook_NilNotifierSafe(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
SetNotifyFn(nil)
t.Cleanup(func() { SetNotifyFn(nil) })
body := bookingEventBody("BOOKING_CREATED", "u5", "Safe", "Call", "2026-05-27T10:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
// TestWebhook_MalformedJson200 verifies that a correctly signed payload that
// is not valid JSON returns 200 (Stripe pattern: ack the delivery so Cal.com
// does not flood retries with junk). The notifier must NOT be called.
func TestWebhook_MalformedJson200(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
body := []byte(`{this is not valid JSON`)
sig := "sha256=" + computeSig(body, "s")
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("X-Cal-Signature-256", sig)
rec := httptest.NewRecorder()
h.Handle(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 for malformed JSON, got %d", rec.Code)
}
if len(*calls) != 0 {
t.Errorf("expected notifier NOT called for malformed JSON, got %d calls", len(*calls))
}
}
// TestWebhook_UniqueKeyFormat verifies that the uniqueKey passed to the
// notifier follows the "calcom:{triggerEvent}:{uid}" format. This is the
// key that the downstream NotificationService uses for deduplication, so
// the format must be exact. (Replay dedup itself occurs inside
// NotificationService.Emit via the database — not within this handler.)
func TestWebhook_UniqueKeyFormat(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
// Send the same payload twice; both should carry the same uniqueKey.
body := bookingEventBody("BOOKING_CREATED", "replay-uid", "Tester", "Dedup Test", "2026-05-27T10:00:00Z")
sendSigned(t, h, body, "s")
sendSigned(t, h, body, "s")
if len(*calls) != 2 {
t.Fatalf("expected 2 notifier calls (handler does not dedup), got %d", len(*calls))
}
expected := "calcom:BOOKING_CREATED:replay-uid"
for i, c := range *calls {
if c.uniqueKey != expected {
t.Errorf("call[%d]: expected uniqueKey %q, got %q", i, expected, c.uniqueKey)
}
}
}
// TestWebhookHandler_RecoversFromDispatchPanic asserts the handler swallows
// notifier panics and still returns 200 to Cal.com — Stripe-pattern: a panic
// IS an app-level error and must not surface as a 5xx that triggers Cal.com's
// retry storm.
func TestWebhookHandler_RecoversFromDispatchPanic(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
SetNotifyFn(func(_ context.Context, _, _, _, _ string) {
panic("boom")
})
t.Cleanup(func() { SetNotifyFn(nil) })
body := bookingEventBody("BOOKING_CREATED", "u-panic", "Boom", "Crash Test", "2026-05-27T10:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Errorf("expected 200 even when notifier panics, got %d", rec.Code)
}
}
// TestWebhookHandler_ParsesRealCalcomPayloadShape locks down the JSON shape the
// implementer's WebhookPayload struct assumes Cal.com sends. The payload below
// is the implementer's CURRENT assumption — embedded inline rather than read
// from a fixture file because WO-051 (capture a real Cal.com webhook in
// staging) has not been run yet.
//
// After WO-051 runs, replace the assumedPayload literal with the real captured
// JSON (load it from testdata/calcom_webhook_real.json) to confirm the shape
// has not drifted.
func TestWebhookHandler_ParsesRealCalcomPayloadShape(t *testing.T) {
// IMPORTANT: this payload is the implementer's assumption. Replace with a
// real Cal.com capture after WO-051.
assumedPayload := []byte(`{
"triggerEvent": "BOOKING_CREATED",
"createdAt": "2026-05-27T10:00:00Z",
"payload": {
"uid": "real-uid-abc123",
"startTime": "2026-06-01T14:00:00Z",
"endTime": "2026-06-01T14:30:00Z",
"eventType": {
"title": "Strategy Call",
"slug": "strategy-call"
},
"attendees": [
{
"name": "Real Attendee",
"email": "attendee@example.com",
"timeZone": "America/Toronto"
}
]
}
}`)
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "real-secret"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
calls := captureNotifier(t)
rec := sendSigned(t, h, assumedPayload, "real-secret")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if len(*calls) != 1 {
t.Fatalf("expected 1 notifier call, got %d", len(*calls))
}
c := (*calls)[0]
// uid → uniqueKey
if c.uniqueKey != "calcom:BOOKING_CREATED:real-uid-abc123" {
t.Errorf("uniqueKey: got %q, want calcom:BOOKING_CREATED:real-uid-abc123", c.uniqueKey)
}
// eventType.title → message
if !strings.Contains(c.message, "Strategy Call") {
t.Errorf("expected message to contain eventType.title 'Strategy Call', got %q", c.message)
}
// attendees[0].name → message
if !strings.Contains(c.message, "Real Attendee") {
t.Errorf("expected message to contain attendees[0].name 'Real Attendee', got %q", c.message)
}
// startTime → message (the implementer formats this verbatim)
if !strings.Contains(c.message, "2026-06-01T14:00:00Z") {
t.Errorf("expected message to contain startTime '2026-06-01T14:00:00Z', got %q", c.message)
}
// title (the notification title, not eventType.title) is "New booking"
if c.title != "New booking" {
t.Errorf("notification title: got %q, want 'New booking'", c.title)
}
// Also confirm last_event_at picked up the createdAt timestamp.
s, err := settings.GetSettings(context.Background())
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if got, _ := s["last_event_at"].(string); got != "2026-05-27T10:00:00Z" {
t.Errorf("last_event_at: got %q, want '2026-05-27T10:00:00Z'", got)
}
}
// TestWebhook_UpdatesLastEventInSettings verifies that after a successful
// BOOKING_CREATED event, the settings store records last_event_type and
// last_event_at so the admin panel can surface the last webhook activity.
func TestWebhook_UpdatesLastEventInSettings(t *testing.T) {
h, settings := newTestWebhookHandler(t)
if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil {
t.Fatalf("SetWebhookSecret: %v", err)
}
// Notifier must be set so dispatch() proceeds; discard the call.
SetNotifyFn(func(_ context.Context, _, _, _, _ string) {})
t.Cleanup(func() { SetNotifyFn(nil) })
body := bookingEventBody("BOOKING_CREATED", "u7", "Last", "Event Test", "2026-06-01T08:00:00Z")
rec := sendSigned(t, h, body, "s")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
s, err := settings.GetSettings(context.Background())
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
lastType, _ := s["last_event_type"].(string)
if lastType != "BOOKING_CREATED" {
t.Errorf("expected last_event_type 'BOOKING_CREATED', got %q", lastType)
}
lastAt, _ := s["last_event_at"].(string)
if lastAt == "" {
t.Errorf("expected last_event_at to be populated, got empty string")
}
}