650 lines
24 KiB
TypeScript
650 lines
24 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";
|
||
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" },
|
||
],
|
||
};
|