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:
commit
18b7825592
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*.bnp
|
||||
*.wasm
|
||||
/web/node_modules/
|
||||
/web/pnpm-workspace.yaml
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal 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
230
block.go
Normal 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
922
booking.templ
Normal 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
115
booking_errors.go
Normal 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
120
booking_errors_test.go
Normal 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
2371
booking_templ.go
Normal file
File diff suppressed because one or more lines are too long
36
captcha_widget.templ
Normal file
36
captcha_widget.templ
Normal 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
49
captcha_widget_templ.go
Normal 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
604
client.go
Normal 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
651
client_test.go
Normal 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
257
countries.go
Normal 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
208
dategrid_alignment_test.go
Normal 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
|
||||
// Sun–Sat 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
|
||||
// Sun–Sat 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
25
errors.go
Normal 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
541
field_routing_test.go
Normal 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
22
go.mod
Normal 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
46
go.sum
Normal 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
1150
handler.go
Normal file
File diff suppressed because it is too large
Load Diff
340
handler_captcha_test.go
Normal file
340
handler_captcha_test.go
Normal 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
1335
handler_integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
929
handler_test.go
Normal file
929
handler_test.go
Normal 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
24
internal/db/db.go
Normal 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
197
internal/helpers/helpers.go
Normal 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
13
main.go
Normal 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
123
phone_region.go
Normal 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
9
plugin.mod
Normal 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
61
ratelimit.go
Normal 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
85
ratelimit_test.go
Normal 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
64
register.go
Normal 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
23
registration.go
Normal 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() },
|
||||
}
|
||||
56
schemas/calcom-booking.schema.json
Normal file
56
schemas/calcom-booking.schema.json
Normal 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
247
settings_store.go
Normal 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
400
timezone_test.go
Normal 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
140
types.go
Normal 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
7
tzdata.go
Normal 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"
|
||||
550
web/dist/assets/__federation_expose_Editor-BDCHVRx7.js
vendored
Normal file
550
web/dist/assets/__federation_expose_Editor-BDCHVRx7.js
vendored
Normal 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 };
|
||||
619
web/dist/assets/__federation_expose_Settings-C7jFM1o4.js
vendored
Normal file
619
web/dist/assets/__federation_expose_Settings-C7jFM1o4.js
vendored
Normal 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 };
|
||||
392
web/dist/assets/__federation_fn_import-hlt2XzeI.js
vendored
Normal file
392
web/dist/assets/__federation_fn_import-hlt2XzeI.js
vendored
Normal 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 };
|
||||
36447
web/dist/assets/__federation_shared_@block-ninja/ui-C3CAr7wz.js
vendored
Normal file
36447
web/dist/assets/__federation_shared_@block-ninja/ui-C3CAr7wz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
web/dist/assets/__federation_shared_react-DoKb58Ht.js
vendored
Normal file
7
web/dist/assets/__federation_shared_react-DoKb58Ht.js
vendored
Normal 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 };
|
||||
7
web/dist/assets/__federation_shared_react-dom-DU2-P0kt.js
vendored
Normal file
7
web/dist/assets/__federation_shared_react-dom-DU2-P0kt.js
vendored
Normal 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 };
|
||||
5
web/dist/assets/_commonjsHelpers-B85MJLTf.js
vendored
Normal file
5
web/dist/assets/_commonjsHelpers-B85MJLTf.js
vendored
Normal 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 };
|
||||
14322
web/dist/assets/blocknote-editor-inner-TDURYXE3-M26_XwGL.js
vendored
Normal file
14322
web/dist/assets/blocknote-editor-inner-TDURYXE3-M26_XwGL.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
77744
web/dist/assets/index-BSkdT-DR.js
vendored
Normal file
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
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
105
web/dist/assets/index-Coq9nAfE.js
vendored
Normal 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
959
web/dist/assets/index-D75C7LcR.js
vendored
Normal 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
347
web/dist/assets/index-DQGM2Mpm.js
vendored
Normal 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
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
59
web/dist/assets/jsx-runtime-CvJTHeKY.js
vendored
Normal 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 };
|
||||
17102
web/dist/assets/markdown-editor-inner-UDKJCI6H-CeENUUs8.js
vendored
Normal file
17102
web/dist/assets/markdown-editor-inner-UDKJCI6H-CeENUUs8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2890
web/dist/assets/module-CfopjtOo.js
vendored
Normal file
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
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
88
web/dist/assets/remoteEntry.js
vendored
Normal 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
1028
web/dist/assets/style-Bs2zy9jQ.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
104
web/dist/assets/with-selector-B2V6shWK.js
vendored
Normal file
104
web/dist/assets/with-selector-B2V6shWK.js
vendored
Normal 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
10
web/dist/index.html
vendored
Normal 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
428
web/editor.tsx
Normal 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
3
web/entry.ts
Normal 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
3
web/eslint.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
import config from "../../../cms/web/eslint.config.js";
|
||||
|
||||
export default config;
|
||||
10
web/index.html
Normal file
10
web/index.html
Normal 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
22
web/package.json
Normal 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
13
web/prettier.config.cjs
Normal 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
515
web/settings.tsx
Normal 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
20
web/tsconfig.json
Normal 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
25
web/vite.config.ts
Normal 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
160
webhook_handler.go
Normal 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
542
webhook_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user