466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
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 = c.req.param("space");
|
|
const role = await resolveCallerRole(space, claims);
|
|
if (!roleAtLeast(role, "moderator")) return { ok: false, status: 403, error: "Moderator role required" };
|
|
return { ok: true, did: claims.did };
|
|
}
|
|
|
|
// ── Public routes ──
|
|
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space");
|
|
return c.html(
|
|
renderShell({
|
|
title: `${space} — Book a time | rSpace`,
|
|
moduleId: "rschedule",
|
|
space,
|
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
|
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 = c.req.param("space");
|
|
const id = c.req.param("id");
|
|
return c.html(
|
|
renderShell({
|
|
title: `Cancel booking | rSpace`,
|
|
moduleId: "rschedule",
|
|
space,
|
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
|
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 = c.req.param("space");
|
|
return c.html(
|
|
renderShell({
|
|
title: `${space} — Schedule admin | rSpace`,
|
|
moduleId: "rschedule",
|
|
space,
|
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
|
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 = 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) => {
|
|
// Phase C wires the real engine. For now return empty so UI renders.
|
|
return _c.json({ slots: [] });
|
|
});
|
|
|
|
routes.get("/api/bookings/:id", (c) => {
|
|
const space = c.req.param("space");
|
|
const id = c.req.param("id");
|
|
const token = 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 = 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;
|
|
});
|
|
|
|
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 = c.req.param("space");
|
|
const id = 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();
|
|
});
|
|
|
|
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 = 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 = 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 = 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 = 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 = c.req.param("space");
|
|
const id = 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 = c.req.param("space");
|
|
const id = 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 = 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 = 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 = c.req.param("space");
|
|
const id = 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 = 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 = 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 = 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" },
|
|
],
|
|
};
|