rspace-online/modules/rschedule/mod.ts

650 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* rSchedule module — Calendly-style public booking pages.
*
* Each space (and each user's personal space) gets a bookable schedule page at
* `/:space/rschedule`. Guests pick a slot; hosts manage availability rules,
* overrides, and connected Google Calendar under `/:space/rschedule/admin`.
*
* All persistence via Automerge — no PostgreSQL. Three doc collections per
* space: `config` (settings + rules + overrides + gcal state), `bookings`
* (bookings this space hosts), `invitations` (bookings this space is attendee
* on — written by server on create so cross-space visibility works without
* cross-space reads).
*
* Phase A: scaffold routes, schemas registered, stub handlers return JSON.
* Public UI + availability engine + gcal sync + admin come in later phases.
*/
import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
import { renderLanding } from "./landing";
import type { SyncServer } from "../../server/local-first/sync-server";
import {
scheduleConfigSchema,
scheduleBookingsSchema,
scheduleInvitationsSchema,
scheduleConfigDocId,
scheduleBookingsDocId,
scheduleInvitationsDocId,
DEFAULT_SETTINGS,
DEFAULT_GOOGLE_AUTH,
type ScheduleConfigDoc,
type ScheduleBookingsDoc,
type ScheduleInvitationsDoc,
type Booking,
type Invitation,
type EntityRef,
} from "./schemas";
import { getAvailableSlots } from "./lib/availability";
import { syncGcalBusy, createGcalBookingEvent, deleteGcalBookingEvent, getGcalStatus } from "./lib/gcal-sync";
import { sendBookingConfirmation, sendCancellationEmail } from "./lib/emails";
import { startRScheduleCron } from "./lib/cron";
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── Doc helpers ──
function ensureConfigDoc(space: string): ScheduleConfigDoc {
const docId = scheduleConfigDocId(space);
let doc = _syncServer!.getDoc<ScheduleConfigDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ScheduleConfigDoc>(), "init rschedule config", (d) => {
const init = scheduleConfigSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.settings = { ...DEFAULT_SETTINGS };
d.rules = {};
d.overrides = {};
d.googleAuth = { ...DEFAULT_GOOGLE_AUTH };
d.googleEvents = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function ensureBookingsDoc(space: string): ScheduleBookingsDoc {
const docId = scheduleBookingsDocId(space);
let doc = _syncServer!.getDoc<ScheduleBookingsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ScheduleBookingsDoc>(), "init rschedule bookings", (d) => {
const init = scheduleBookingsSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.bookings = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function ensureInvitationsDoc(space: string): ScheduleInvitationsDoc {
const docId = scheduleInvitationsDocId(space);
let doc = _syncServer!.getDoc<ScheduleInvitationsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ScheduleInvitationsDoc>(), "init rschedule invitations", (d) => {
const init = scheduleInvitationsSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.invitations = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
// ── Auth helper (EncryptID passkey JWT) ──
async function requireAdmin(c: any): Promise<{ ok: true; did: string } | { ok: false; status: number; error: string }> {
const token = extractToken(c.req.raw.headers);
if (!token) return { ok: false, status: 401, error: "Auth required" };
let claims;
try { claims = await verifyToken(token); }
catch { return { ok: false, status: 401, error: "Invalid token" }; }
const space = String(c.req.param("space") || "");
const resolved = await resolveCallerRole(space, claims);
if (!resolved || !roleAtLeast(resolved.role, "moderator")) {
return { ok: false, status: 403, error: "Moderator role required" };
}
return { ok: true, did: String(claims.did || claims.sub || "") };
}
// ── Public routes ──
routes.get("/", (c) => {
const space = String(c.req.param("space") || "demo");
return c.html(
renderShell({
title: `${space} — Book a time | rSpace`,
moduleId: "rschedule",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-booking.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/booking.css">`,
body: `<folk-schedule-booking space="${space}"></folk-schedule-booking>`,
}),
);
});
routes.get("/cancel/:id", (c) => {
const space = String(c.req.param("space") || "demo");
const id = String(c.req.param("id") || "");
return c.html(
renderShell({
title: `Cancel booking | rSpace`,
moduleId: "rschedule",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-cancel.js?v=1"></script>`,
body: `<folk-schedule-cancel space="${space}" booking-id="${id}"></folk-schedule-cancel>`,
}),
);
});
routes.get("/admin", (c) => {
const space = String(c.req.param("space") || "demo");
return c.html(
renderShell({
title: `${space} — Schedule admin | rSpace`,
moduleId: "rschedule",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-admin.js?v=1"></script>`,
body: `<folk-schedule-admin space="${space}"></folk-schedule-admin>`,
}),
);
});
// ── Public API ──
routes.get("/api/settings/public", (c) => {
const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space);
const s = doc.settings;
return c.json({
displayName: s.displayName || space,
bookingMessage: s.bookingMessage,
slotDurationMin: s.slotDurationMin,
maxAdvanceDays: s.maxAdvanceDays,
minNoticeHours: s.minNoticeHours,
timezone: s.timezone,
});
});
routes.get("/api/availability", (c) => {
const space = String(c.req.param("space") || "");
const config = ensureConfigDoc(space);
const bookings = ensureBookingsDoc(space);
const fromStr = c.req.query("from");
const toStr = c.req.query("to");
const viewerTz = String(c.req.query("tz") || config.settings.timezone || "UTC");
const durationStr = c.req.query("duration");
const now = Date.now();
const rangeStartMs = fromStr ? Date.parse(fromStr) : now;
const rangeEndMs = toStr ? Date.parse(toStr) : now + 30 * 86400_000;
const slots = getAvailableSlots(config, bookings, {
rangeStartMs,
rangeEndMs,
viewerTimezone: viewerTz,
durationOverride: durationStr ? Number(durationStr) : undefined,
});
return c.json({ slots });
});
routes.get("/api/bookings/:id", (c) => {
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
const token = String(c.req.query("token") || "");
const doc = ensureBookingsDoc(space);
const booking = doc.bookings[id];
if (!booking) return c.json({ error: "Not found" }, 404);
if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403);
return c.json({
id: booking.id,
startTime: booking.startTime,
endTime: booking.endTime,
timezone: booking.timezone,
status: booking.status,
guestName: booking.guestName,
guestEmail: booking.guestEmail,
host: booking.host,
});
});
routes.post("/api/bookings", async (c) => {
const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => null);
if (!body?.startTime || !body?.endTime || !body?.guestName || !body?.guestEmail) {
return c.json({ error: "Missing required fields" }, 400);
}
const id = `bk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const cancelToken = `ct-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`;
const now = Date.now();
const host: EntityRef = { kind: "space", id: space };
const booking: Booking = {
id,
host,
guestName: String(body.guestName).slice(0, 200),
guestEmail: String(body.guestEmail).slice(0, 200),
guestNote: String(body.guestNote || "").slice(0, 2000),
attendees: {},
startTime: Number(body.startTime),
endTime: Number(body.endTime),
timezone: String(body.timezone || "UTC"),
status: "confirmed",
meetingLink: null,
googleEventId: null,
cancelToken,
cancellationReason: null,
reminderSentAt: null,
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<ScheduleBookingsDoc>(scheduleBookingsDocId(space), `create booking ${id}`, (d) => {
d.bookings[id] = booking as any;
});
// Fire-and-forget: create matching event on host's Google Calendar if connected.
// Writes googleEventId back on success; failure leaves booking unaffected.
void createGcalBookingEvent(space, _syncServer!, booking).then((r) => {
if (r.ok) {
_syncServer!.changeDoc<ScheduleBookingsDoc>(scheduleBookingsDocId(space), `attach gcal event to ${id}`, (d) => {
if (d.bookings[id]) d.bookings[id].googleEventId = r.googleEventId;
});
}
}).catch(() => { /* swallow — booking is still valid without gcal event */ });
// Fire-and-forget: send confirmation emails to guest + host + any extra attendees.
const configDoc = ensureConfigDoc(space);
const hostName = configDoc.settings.displayName || space;
const origin = (() => {
const proto = c.req.header("x-forwarded-proto") || "https";
const host = c.req.header("host") || "";
return host ? `${proto}://${host}` : "";
})();
const base = `${origin}/${space}/rschedule`;
void sendBookingConfirmation({
booking,
settings: configDoc.settings,
hostDisplayName: hostName,
cancelUrl: `${base}/cancel/${id}?token=${encodeURIComponent(cancelToken)}`,
extraAttendeeEmails: Array.isArray(body.attendeeEmails) ? body.attendeeEmails.filter((e: unknown) => typeof e === "string" && e.includes("@")) : [],
}).catch(() => { /* logged in emails.ts */ });
const inviteeRefs: EntityRef[] = Array.isArray(body.invitees) ? body.invitees : [];
for (const ref of inviteeRefs) {
if (ref.kind !== "space") continue; // user DIDs handled once user-spaces formalized
const invId = `inv-${id}-${ref.id}`;
const inv: Invitation = {
id: invId,
bookingId: id,
host,
title: `Booking with ${host.label || host.id}`,
startTime: booking.startTime,
endTime: booking.endTime,
timezone: booking.timezone,
status: booking.status,
response: "invited",
meetingLink: null,
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<ScheduleInvitationsDoc>(scheduleInvitationsDocId(ref.id), `receive invite ${invId}`, (d) => {
d.invitations[invId] = inv as any;
});
}
return c.json({ id, cancelToken, status: "confirmed" });
});
routes.post("/api/bookings/:id/cancel", async (c) => {
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
const body = await c.req.json().catch(() => ({}));
const token = body?.token || c.req.query("token");
const docId = scheduleBookingsDocId(space);
const doc = _syncServer!.getDoc<ScheduleBookingsDoc>(docId);
const booking = doc?.bookings[id];
if (!booking) return c.json({ error: "Not found" }, 404);
if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403);
if (booking.status === "cancelled") return c.json({ ok: true, alreadyCancelled: true });
_syncServer!.changeDoc<ScheduleBookingsDoc>(docId, `cancel booking ${id}`, (d) => {
d.bookings[id].status = "cancelled";
d.bookings[id].cancellationReason = String(body?.reason || "").slice(0, 500);
d.bookings[id].updatedAt = Date.now();
});
// Fire-and-forget: remove corresponding Google Calendar event if one was created
if (booking.googleEventId) {
void deleteGcalBookingEvent(space, _syncServer!, booking.googleEventId).catch(() => { /* best-effort */ });
}
// Fire-and-forget: email guest + host about the cancellation
{
const configDoc = ensureConfigDoc(space);
const hostName = configDoc.settings.displayName || space;
const origin = (() => {
const proto = c.req.header("x-forwarded-proto") || "https";
const host = c.req.header("host") || "";
return host ? `${proto}://${host}` : "";
})();
const bookingPageUrl = `${origin}/${space}/rschedule`;
// Suggest up to 3 upcoming slots from our own availability engine.
const suggestedSlots: Array<{ date: string; time: string; link: string }> = [];
try {
const now = Date.now();
const cfgForAvail = ensureConfigDoc(space);
const bookingsForAvail = ensureBookingsDoc(space);
const slots = getAvailableSlots(cfgForAvail, bookingsForAvail, {
rangeStartMs: now,
rangeEndMs: now + 14 * 86400_000,
viewerTimezone: booking.timezone,
});
const seen = new Set<string>();
for (const s of slots) {
if (seen.has(s.date)) continue;
seen.add(s.date);
suggestedSlots.push({
date: s.date,
time: `${s.startDisplay} ${s.endDisplay}`,
link: `${bookingPageUrl}?date=${s.date}`,
});
if (suggestedSlots.length >= 3) break;
}
} catch { /* no suggestions on engine failure */ }
const refreshed = _syncServer!.getDoc<ScheduleBookingsDoc>(docId)?.bookings[id];
if (refreshed) {
void sendCancellationEmail({
booking: refreshed as any,
settings: configDoc.settings,
hostDisplayName: hostName,
bookingPageUrl,
suggestedSlots,
}).catch(() => { /* logged */ });
}
}
return c.json({ ok: true });
});
// ── Admin API (passkey-gated) ──
routes.get("/api/admin/settings", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space);
return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } });
});
routes.put("/api/admin/settings", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const patch = await c.req.json().catch(() => ({}));
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), "update settings", (d) => {
Object.assign(d.settings, patch);
});
const doc = ensureConfigDoc(space);
return c.json({ settings: doc.settings });
});
routes.get("/api/admin/availability", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space);
return c.json({ rules: Object.values(doc.rules) });
});
routes.post("/api/admin/availability", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => ({}));
const id = `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add rule ${id}`, (d) => {
d.rules[id] = {
id,
dayOfWeek: Number(body.dayOfWeek ?? 1),
startTime: String(body.startTime || "09:00"),
endTime: String(body.endTime || "17:00"),
isActive: body.isActive !== false,
createdAt: Date.now(),
};
});
return c.json({ id });
});
routes.put("/api/admin/availability/:id", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
const patch = await c.req.json().catch(() => ({}));
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `update rule ${id}`, (d) => {
if (!d.rules[id]) return;
Object.assign(d.rules[id], patch);
});
return c.json({ ok: true });
});
routes.delete("/api/admin/availability/:id", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete rule ${id}`, (d) => {
delete d.rules[id];
});
return c.json({ ok: true });
});
routes.get("/api/admin/overrides", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space);
return c.json({ overrides: Object.values(doc.overrides) });
});
routes.post("/api/admin/overrides", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => ({}));
if (!body?.date) return c.json({ error: "date required (YYYY-MM-DD)" }, 400);
const id = `ov-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add override ${id}`, (d) => {
d.overrides[id] = {
id,
date: String(body.date),
isBlocked: body.isBlocked !== false,
startTime: body.startTime || null,
endTime: body.endTime || null,
reason: String(body.reason || ""),
createdAt: Date.now(),
};
});
return c.json({ id });
});
routes.delete("/api/admin/overrides/:id", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete override ${id}`, (d) => {
delete d.overrides[id];
});
return c.json({ ok: true });
});
routes.get("/api/admin/bookings", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const doc = ensureBookingsDoc(space);
const rows = Object.values(doc.bookings)
.sort((a, b) => b.startTime - a.startTime)
.map((b) => ({
id: b.id,
guestName: b.guestName,
guestEmail: b.guestEmail,
startTime: b.startTime,
endTime: b.endTime,
timezone: b.timezone,
status: b.status,
attendeeCount: Object.keys(b.attendees).length,
}));
return c.json({ bookings: rows });
});
routes.get("/api/invitations", (c) => {
const space = String(c.req.param("space") || "");
const doc = ensureInvitationsDoc(space);
return c.json({
invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime),
});
});
// PATCH /api/invitations/:id — invitee accepts/declines
routes.patch("/api/invitations/:id", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const id = String(c.req.param("id") || "");
const body = await c.req.json().catch(() => ({}));
const response = body?.response as "invited" | "accepted" | "declined" | undefined;
if (!response || !["invited", "accepted", "declined"].includes(response)) {
return c.json({ error: "response must be invited | accepted | declined" }, 400);
}
_syncServer!.changeDoc<ScheduleInvitationsDoc>(scheduleInvitationsDocId(space), `invitation ${id}${response}`, (d) => {
if (!d.invitations[id]) return;
d.invitations[id].response = response;
d.invitations[id].updatedAt = Date.now();
});
return c.json({ ok: true });
});
// POST /api/admin/timezone/shift — change host timezone, optionally shift existing bookings
routes.post("/api/admin/timezone/shift", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => ({}));
const newTz = String(body?.timezone || "");
if (!newTz) return c.json({ error: "timezone required" }, 400);
const cfg = ensureConfigDoc(space);
const oldTz = cfg.settings.timezone;
if (oldTz === newTz) return c.json({ ok: true, changed: 0, message: "already on this timezone" });
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `tz ${oldTz}${newTz}`, (d) => {
d.settings.timezone = newTz;
});
// Optionally rebase confirmed bookings' display timezone (wall-clock stays tied to
// UTC startTime/endTime — this only updates the label). Hosts who want the actual
// wall-clock to shift must cancel+rebook.
let changed = 0;
if (body?.relabelBookings) {
_syncServer!.changeDoc<ScheduleBookingsDoc>(scheduleBookingsDocId(space), `tz relabel`, (d) => {
for (const b of Object.values(d.bookings)) {
if (b.status === "confirmed") {
b.timezone = newTz;
changed++;
}
}
});
}
return c.json({ ok: true, changed, oldTz, newTz });
});
routes.get("/api/admin/google/status", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
ensureConfigDoc(space);
return c.json(getGcalStatus(space, _syncServer!));
});
// POST /api/admin/google/sync — manual gcal sync (also triggered by rMinders cron in Phase G)
routes.post("/api/admin/google/sync", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
ensureConfigDoc(space);
const result = await syncGcalBusy(space, _syncServer!);
return c.json(result);
});
// POST /api/admin/google/disconnect — clear calendar sync state (tokens are in the shared
// ConnectionsDoc and should be disconnected via /api/oauth/google/disconnect?space=X)
routes.post("/api/admin/google/disconnect", async (c) => {
const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = String(c.req.param("space") || "");
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), "disconnect gcal", (d) => {
d.googleAuth = { ...d.googleAuth, connected: false, calendarIds: [], syncToken: null, lastSyncAt: null, lastSyncStatus: null, lastSyncError: null };
d.googleEvents = {};
});
return c.json({ ok: true });
});
// ── Module export ──
export const scheduleModule: RSpaceModule = {
id: "rschedule",
name: "rSchedule",
icon: "📆",
description: "Calendly-style booking pages for spaces and users, backed by rCal availability",
scoping: { defaultScope: "global", userConfigurable: false },
docSchemas: [
{ pattern: "{space}:schedule:config", description: "Booking settings, weekly rules, overrides, gcal state", init: scheduleConfigSchema.init },
{ pattern: "{space}:schedule:bookings", description: "Bookings this space hosts", init: scheduleBookingsSchema.init },
{ pattern: "{space}:schedule:invitations", description: "Bookings this space is attendee on", init: scheduleInvitationsSchema.init },
],
routes,
standaloneDomain: "rschedule.online",
landingPage: renderLanding,
async onInit(ctx) {
_syncServer = ctx.syncServer;
ensureConfigDoc("demo");
startRScheduleCron(_syncServer);
},
feeds: [
{ id: "bookings", name: "Bookings", kind: "data", description: "Confirmed bookings with start/end times, attendees, and cancel tokens", filterable: true },
{ id: "invitations", name: "Invitations", kind: "data", description: "Bookings this space is invited to as an attendee" },
],
outputPaths: [
{ path: "admin", name: "Admin", icon: "⚙️", description: "Configure availability, bookings, and Google Calendar" },
{ path: "admin/availability", name: "Availability", icon: "📅", description: "Weekly rules and date overrides" },
{ path: "admin/bookings", name: "Bookings", icon: "📋", description: "All hosted bookings" },
],
onboardingActions: [
{ label: "Set Availability", icon: "📅", description: "Define your weekly bookable hours", type: "create", href: "/{space}/rschedule/admin/availability" },
{ label: "Connect Google Calendar", icon: "🔄", description: "Block bookings against your gcal busy times", type: "link", href: "/{space}/rschedule/admin" },
{ label: "Share Booking Link", icon: "🔗", description: "Send your public booking page to anyone", type: "link", href: "/{space}/rschedule" },
],
};