/** * 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"; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Doc helpers ── function ensureConfigDoc(space: string): ScheduleConfigDoc { const docId = scheduleConfigDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), "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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), "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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), "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: ``, styles: ``, body: ``, }), ); }); 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: ``, body: ``, }), ); }); 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: ``, body: ``, }), ); }); // ── 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(scheduleBookingsDocId(space), `create booking ${id}`, (d) => { d.bookings[id] = booking as any; }); 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(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(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(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(); }); 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(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(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(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(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(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(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), }); }); 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") || ""); const doc = ensureConfigDoc(space); return c.json(doc.googleAuth); }); // ── 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"); }, 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" }, ], };