/** * 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(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; }); // 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(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(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(); }); // 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(); 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(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(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), }); }); // 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(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(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(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(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" }, ], };