Compare commits

..

2 Commits

Author SHA1 Message Date
Jeff Emmett 3842ad9688 Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m13s Details
2026-04-16 17:35:08 -04:00
Jeff Emmett 700e372260 fix(rschedule): add missing lib/availability + component updates
lib/availability.ts (getAvailableSlots) was imported by mod.ts but never
committed — caused module load to fail on server, leaving renderShell
callers with undefined 'modules' and producing 500s on every rschedule
route. Plus small updates to booking/admin/cancel components and mod.ts
wiring.
2026-04-16 17:35:05 -04:00
5 changed files with 305 additions and 39 deletions

View File

@ -102,3 +102,5 @@ function escapeHtml(s: string): string {
} }
customElements.define("folk-schedule-admin", FolkScheduleAdmin); customElements.define("folk-schedule-admin", FolkScheduleAdmin);
export {};

View File

@ -91,3 +91,5 @@ function escapeHtml(s: string): string {
} }
customElements.define("folk-schedule-booking", FolkScheduleBooking); customElements.define("folk-schedule-booking", FolkScheduleBooking);
export {};

View File

@ -123,3 +123,5 @@ function escapeHtml(s: string): string {
} }
customElements.define("folk-schedule-cancel", FolkScheduleCancel); customElements.define("folk-schedule-cancel", FolkScheduleCancel);
export {};

View File

@ -0,0 +1,236 @@
/**
* Availability engine port of schedule-jeffemmett/src/lib/availability.ts.
*
* Pure computation: takes config doc + bookings doc + a range, returns free
* slots. No side effects. Two callers:
* - GET /api/availability (public booking page)
* - Admin dashboard preview
*
* Deliberately avoids date-fns / date-fns-tz to keep the module tree light
* uses Intl.DateTimeFormat for timezone math instead.
*/
import type {
ScheduleConfigDoc,
ScheduleBookingsDoc,
AvailabilityRule,
AvailabilityOverride,
CachedGcalEvent,
} from "../schemas";
export interface TimeSlot {
/** UTC ISO string — acts as stable slot id. */
id: string;
startUtc: string;
endUtc: string;
/** YYYY-MM-DD in viewer timezone. */
date: string;
/** "9:00 AM" in viewer timezone. */
startDisplay: string;
endDisplay: string;
startMs: number;
endMs: number;
}
export interface GetSlotsOpts {
rangeStartMs: number;
rangeEndMs: number;
viewerTimezone: string;
/** Client-selected duration override (clamped 15120). */
durationOverride?: number;
}
interface Interval { startMs: number; endMs: number; }
// ── Timezone helpers (no external deps) ──
/**
* Return the UTC ms offset to apply to a wall-clock instant in `tz` to get
* the real UTC instant. Uses Intl.DateTimeFormat with `timeZoneName: 'longOffset'`.
*/
function tzOffsetMinutes(dateMs: number, tz: string): number {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
});
const parts = dtf.formatToParts(new Date(dateMs));
const map: Record<string, string> = {};
for (const p of parts) if (p.type !== "literal") map[p.type] = p.value;
const asUTC = Date.UTC(
Number(map.year),
Number(map.month) - 1,
Number(map.day),
Number(map.hour === "24" ? "0" : map.hour),
Number(map.minute),
Number(map.second),
);
return Math.round((asUTC - dateMs) / 60000);
}
/** Convert a wall-clock time in `tz` (y/m/d/h/m) to UTC ms. */
function wallClockToUtcMs(y: number, m: number, d: number, h: number, min: number, tz: string): number {
// First pass: treat as UTC, then correct by the tz offset at that moment.
const guess = Date.UTC(y, m - 1, d, h, min, 0);
const off = tzOffsetMinutes(guess, tz);
return guess - off * 60000;
}
/** Format a UTC ms in `tz` as YYYY-MM-DD. */
function fmtDate(ms: number, tz: string): string {
const dtf = new Intl.DateTimeFormat("en-CA", {
timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit",
});
return dtf.format(new Date(ms));
}
/** Format a UTC ms in `tz` as "h:mm AM/PM". */
function fmtTime(ms: number, tz: string): string {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: tz, hour: "numeric", minute: "2-digit", hour12: true,
});
return dtf.format(new Date(ms));
}
/** Day-of-week in `tz` (0=Sun..6=Sat). */
function dayOfWeekInTz(ms: number, tz: string): number {
const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" });
const short = dtf.format(new Date(ms));
return ({ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record<string, number>)[short] ?? 0;
}
function parseHHMM(s: string): [number, number] {
const [h, m] = s.split(":").map((n) => Number(n));
return [h || 0, m || 0];
}
// ── Main engine ──
export function getAvailableSlots(
config: ScheduleConfigDoc,
bookings: ScheduleBookingsDoc,
opts: GetSlotsOpts,
): TimeSlot[] {
const { settings, rules: rulesMap, overrides: overridesMap, googleEvents } = config;
const hostTz = settings.timezone || "UTC";
const slotMin = opts.durationOverride
? Math.max(15, Math.min(120, opts.durationOverride))
: settings.slotDurationMin;
const now = Date.now();
const earliest = now + settings.minNoticeHours * 3600_000;
const latest = now + settings.maxAdvanceDays * 86400_000;
const startMs = Math.max(opts.rangeStartMs, earliest);
const endMs = Math.min(opts.rangeEndMs, latest);
if (startMs >= endMs) return [];
const rules = Object.values(rulesMap).filter((r) => r.isActive);
const overrides = Object.values(overridesMap);
const gcalBusy: CachedGcalEvent[] = Object.values(googleEvents).filter(
(e) => e.transparency === "opaque" && e.status !== "cancelled",
);
// Build busy intervals from confirmed bookings + gcal cache
const busy: Interval[] = [];
for (const b of Object.values(bookings.bookings)) {
if (b.status !== "confirmed") continue;
if (b.endTime < startMs || b.startTime > endMs) continue;
busy.push({
startMs: b.startTime - settings.bufferBeforeMin * 60000,
endMs: b.endTime + settings.bufferAfterMin * 60000,
});
}
for (const g of gcalBusy) {
if (g.endTime < startMs || g.startTime > endMs) continue;
busy.push({ startMs: g.startTime, endMs: g.endTime });
}
const slots: TimeSlot[] = [];
// Walk day-by-day in host timezone
const msPerDay = 86400_000;
// Start at midnight in host tz of the effective range start
const startDate = fmtDate(startMs, hostTz);
const endDate = fmtDate(endMs, hostTz);
let cursor = parseDate(startDate);
const end = parseDate(endDate);
while (cursor.getTime() <= end.getTime()) {
const dateStr = toDateStr(cursor);
const override = overrides.find((o) => o.date === dateStr);
if (override?.isBlocked) {
cursor = addDays(cursor, 1);
continue;
}
// Windows for this day
type Window = { startHHMM: string; endHHMM: string };
let windows: Window[] = [];
if (override && override.startTime && override.endTime) {
windows = [{ startHHMM: override.startTime, endHHMM: override.endTime }];
} else if (!override) {
const dow = dayOfWeekInTz(
wallClockToUtcMs(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), 12, 0, hostTz),
hostTz,
);
windows = rules
.filter((r: AvailabilityRule) => r.dayOfWeek === dow)
.map((r) => ({ startHHMM: r.startTime, endHHMM: r.endTime }));
}
for (const w of windows) {
const [sh, sm] = parseHHMM(w.startHHMM);
const [eh, em] = parseHHMM(w.endHHMM);
const wStart = wallClockToUtcMs(
cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), sh, sm, hostTz,
);
const wEnd = wallClockToUtcMs(
cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), eh, em, hostTz,
);
let t = wStart;
while (t + slotMin * 60000 <= wEnd) {
const slotEnd = t + slotMin * 60000;
if (t < earliest) { t += slotMin * 60000; continue; }
if (t > latest) break;
const conflict = busy.some((b) => t < b.endMs && slotEnd > b.startMs);
if (!conflict) {
slots.push({
id: new Date(t).toISOString(),
startUtc: new Date(t).toISOString(),
endUtc: new Date(slotEnd).toISOString(),
date: fmtDate(t, opts.viewerTimezone),
startDisplay: fmtTime(t, opts.viewerTimezone),
endDisplay: fmtTime(slotEnd, opts.viewerTimezone),
startMs: t,
endMs: slotEnd,
});
}
t += slotMin * 60000;
}
}
cursor = addDays(cursor, 1);
}
return slots;
// ── local helpers ──
function parseDate(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
function toDateStr(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function addDays(d: Date, n: number): Date {
return new Date(d.getTime() + n * msPerDay);
}
}

View File

@ -40,6 +40,7 @@ import {
type Invitation, type Invitation,
type EntityRef, type EntityRef,
} from "./schemas"; } from "./schemas";
import { getAvailableSlots } from "./lib/availability";
let _syncServer: SyncServer | null = null; let _syncServer: SyncServer | null = null;
@ -104,22 +105,25 @@ async function requireAdmin(c: any): Promise<{ ok: true; did: string } | { ok: f
let claims; let claims;
try { claims = await verifyToken(token); } try { claims = await verifyToken(token); }
catch { return { ok: false, status: 401, error: "Invalid token" }; } catch { return { ok: false, status: 401, error: "Invalid token" }; }
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const role = await resolveCallerRole(space, claims); const resolved = await resolveCallerRole(space, claims);
if (!roleAtLeast(role, "moderator")) return { ok: false, status: 403, error: "Moderator role required" }; if (!resolved || !roleAtLeast(resolved.role, "moderator")) {
return { ok: true, did: claims.did }; return { ok: false, status: 403, error: "Moderator role required" };
}
return { ok: true, did: String(claims.did || claims.sub || "") };
} }
// ── Public routes ── // ── Public routes ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "demo");
return c.html( return c.html(
renderShell({ renderShell({
title: `${space} — Book a time | rSpace`, title: `${space} — Book a time | rSpace`,
moduleId: "rschedule", moduleId: "rschedule",
space, spaceSlug: space,
enabledModules: getModuleInfoList().map((m) => m.id), modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-booking.js?v=1"></script>`, scripts: `<script type="module" src="/modules/rschedule/folk-schedule-booking.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/booking.css">`, styles: `<link rel="stylesheet" href="/modules/rschedule/booking.css">`,
body: `<folk-schedule-booking space="${space}"></folk-schedule-booking>`, body: `<folk-schedule-booking space="${space}"></folk-schedule-booking>`,
@ -128,14 +132,15 @@ routes.get("/", (c) => {
}); });
routes.get("/cancel/:id", (c) => { routes.get("/cancel/:id", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "demo");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
return c.html( return c.html(
renderShell({ renderShell({
title: `Cancel booking | rSpace`, title: `Cancel booking | rSpace`,
moduleId: "rschedule", moduleId: "rschedule",
space, spaceSlug: space,
enabledModules: getModuleInfoList().map((m) => m.id), modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-cancel.js?v=1"></script>`, 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>`, body: `<folk-schedule-cancel space="${space}" booking-id="${id}"></folk-schedule-cancel>`,
}), }),
@ -143,13 +148,14 @@ routes.get("/cancel/:id", (c) => {
}); });
routes.get("/admin", (c) => { routes.get("/admin", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "demo");
return c.html( return c.html(
renderShell({ renderShell({
title: `${space} — Schedule admin | rSpace`, title: `${space} — Schedule admin | rSpace`,
moduleId: "rschedule", moduleId: "rschedule",
space, spaceSlug: space,
enabledModules: getModuleInfoList().map((m) => m.id), modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-admin.js?v=1"></script>`, scripts: `<script type="module" src="/modules/rschedule/folk-schedule-admin.js?v=1"></script>`,
body: `<folk-schedule-admin space="${space}"></folk-schedule-admin>`, body: `<folk-schedule-admin space="${space}"></folk-schedule-admin>`,
}), }),
@ -159,7 +165,7 @@ routes.get("/admin", (c) => {
// ── Public API ── // ── Public API ──
routes.get("/api/settings/public", (c) => { routes.get("/api/settings/public", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space); const doc = ensureConfigDoc(space);
const s = doc.settings; const s = doc.settings;
return c.json({ return c.json({
@ -172,15 +178,33 @@ routes.get("/api/settings/public", (c) => {
}); });
}); });
routes.get("/api/availability", (_c) => { routes.get("/api/availability", (c) => {
// Phase C wires the real engine. For now return empty so UI renders. const space = String(c.req.param("space") || "");
return _c.json({ slots: [] }); 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) => { routes.get("/api/bookings/:id", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
const token = c.req.query("token"); const token = String(c.req.query("token") || "");
const doc = ensureBookingsDoc(space); const doc = ensureBookingsDoc(space);
const booking = doc.bookings[id]; const booking = doc.bookings[id];
if (!booking) return c.json({ error: "Not found" }, 404); if (!booking) return c.json({ error: "Not found" }, 404);
@ -198,7 +222,7 @@ routes.get("/api/bookings/:id", (c) => {
}); });
routes.post("/api/bookings", async (c) => { routes.post("/api/bookings", async (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
if (!body?.startTime || !body?.endTime || !body?.guestName || !body?.guestEmail) { if (!body?.startTime || !body?.endTime || !body?.guestName || !body?.guestEmail) {
return c.json({ error: "Missing required fields" }, 400); return c.json({ error: "Missing required fields" }, 400);
@ -259,8 +283,8 @@ routes.post("/api/bookings", async (c) => {
}); });
routes.post("/api/bookings/:id/cancel", async (c) => { routes.post("/api/bookings/:id/cancel", async (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
const body = await c.req.json().catch(() => ({})); const body = await c.req.json().catch(() => ({}));
const token = body?.token || c.req.query("token"); const token = body?.token || c.req.query("token");
const docId = scheduleBookingsDocId(space); const docId = scheduleBookingsDocId(space);
@ -284,7 +308,7 @@ routes.post("/api/bookings/:id/cancel", async (c) => {
routes.get("/api/admin/settings", async (c) => { routes.get("/api/admin/settings", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space); const doc = ensureConfigDoc(space);
return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } }); return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } });
}); });
@ -292,7 +316,7 @@ routes.get("/api/admin/settings", async (c) => {
routes.put("/api/admin/settings", async (c) => { routes.put("/api/admin/settings", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const patch = await c.req.json().catch(() => ({})); const patch = await c.req.json().catch(() => ({}));
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), "update settings", (d) => { _syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), "update settings", (d) => {
Object.assign(d.settings, patch); Object.assign(d.settings, patch);
@ -304,7 +328,7 @@ routes.put("/api/admin/settings", async (c) => {
routes.get("/api/admin/availability", async (c) => { routes.get("/api/admin/availability", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space); const doc = ensureConfigDoc(space);
return c.json({ rules: Object.values(doc.rules) }); return c.json({ rules: Object.values(doc.rules) });
}); });
@ -312,7 +336,7 @@ routes.get("/api/admin/availability", async (c) => {
routes.post("/api/admin/availability", async (c) => { routes.post("/api/admin/availability", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => ({})); const body = await c.req.json().catch(() => ({}));
const id = `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const id = `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add rule ${id}`, (d) => { _syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add rule ${id}`, (d) => {
@ -331,8 +355,8 @@ routes.post("/api/admin/availability", async (c) => {
routes.put("/api/admin/availability/:id", async (c) => { routes.put("/api/admin/availability/:id", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
const patch = await c.req.json().catch(() => ({})); const patch = await c.req.json().catch(() => ({}));
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `update rule ${id}`, (d) => { _syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `update rule ${id}`, (d) => {
if (!d.rules[id]) return; if (!d.rules[id]) return;
@ -344,8 +368,8 @@ routes.put("/api/admin/availability/:id", async (c) => {
routes.delete("/api/admin/availability/:id", async (c) => { routes.delete("/api/admin/availability/:id", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete rule ${id}`, (d) => { _syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete rule ${id}`, (d) => {
delete d.rules[id]; delete d.rules[id];
}); });
@ -355,7 +379,7 @@ routes.delete("/api/admin/availability/:id", async (c) => {
routes.get("/api/admin/overrides", async (c) => { routes.get("/api/admin/overrides", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space); const doc = ensureConfigDoc(space);
return c.json({ overrides: Object.values(doc.overrides) }); return c.json({ overrides: Object.values(doc.overrides) });
}); });
@ -363,7 +387,7 @@ routes.get("/api/admin/overrides", async (c) => {
routes.post("/api/admin/overrides", async (c) => { routes.post("/api/admin/overrides", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const body = await c.req.json().catch(() => ({})); const body = await c.req.json().catch(() => ({}));
if (!body?.date) return c.json({ error: "date required (YYYY-MM-DD)" }, 400); 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)}`; const id = `ov-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
@ -384,8 +408,8 @@ routes.post("/api/admin/overrides", async (c) => {
routes.delete("/api/admin/overrides/:id", async (c) => { routes.delete("/api/admin/overrides/:id", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const id = c.req.param("id"); const id = String(c.req.param("id") || "");
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete override ${id}`, (d) => { _syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete override ${id}`, (d) => {
delete d.overrides[id]; delete d.overrides[id];
}); });
@ -395,7 +419,7 @@ routes.delete("/api/admin/overrides/:id", async (c) => {
routes.get("/api/admin/bookings", async (c) => { routes.get("/api/admin/bookings", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureBookingsDoc(space); const doc = ensureBookingsDoc(space);
const rows = Object.values(doc.bookings) const rows = Object.values(doc.bookings)
.sort((a, b) => b.startTime - a.startTime) .sort((a, b) => b.startTime - a.startTime)
@ -413,7 +437,7 @@ routes.get("/api/admin/bookings", async (c) => {
}); });
routes.get("/api/invitations", (c) => { routes.get("/api/invitations", (c) => {
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureInvitationsDoc(space); const doc = ensureInvitationsDoc(space);
return c.json({ return c.json({
invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime), invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime),
@ -423,7 +447,7 @@ routes.get("/api/invitations", (c) => {
routes.get("/api/admin/google/status", async (c) => { routes.get("/api/admin/google/status", async (c) => {
const auth = await requireAdmin(c); const auth = await requireAdmin(c);
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
const space = c.req.param("space"); const space = String(c.req.param("space") || "");
const doc = ensureConfigDoc(space); const doc = ensureConfigDoc(space);
return c.json(doc.googleAuth); return c.json(doc.googleAuth);
}); });