Compare commits
2 Commits
3e38c6cb1e
...
3842ad9688
| Author | SHA1 | Date |
|---|---|---|
|
|
3842ad9688 | |
|
|
700e372260 |
|
|
@ -102,3 +102,5 @@ function escapeHtml(s: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("folk-schedule-admin", FolkScheduleAdmin);
|
customElements.define("folk-schedule-admin", FolkScheduleAdmin);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,5 @@ function escapeHtml(s: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("folk-schedule-booking", FolkScheduleBooking);
|
customElements.define("folk-schedule-booking", FolkScheduleBooking);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,5 @@ function escapeHtml(s: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("folk-schedule-cancel", FolkScheduleCancel);
|
customElements.define("folk-schedule-cancel", FolkScheduleCancel);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -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 15–120). */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue