feat(rschedule): complete native port of schedule-jeffemmett

Phases D-H of the rSchedule booking module:

- **Google Calendar sync** (`lib/gcal-sync.ts`): reuses rspace OAuth, syncs busy
  times into config doc, creates booking events, deletes on cancel.
- **Admin UI** (`components/folk-schedule-admin.ts`): 6-tab passkey-gated dashboard
  (overview, availability rules + overrides, bookings, invitations, gcal, settings).
  Timezone-shift banner when browser tz diverges from host tz.
- **Emails** (`lib/emails.ts`, `lib/calendar-links.ts`): confirmation with
  Google/Outlook/Yahoo add-to-calendar buttons + .ics attachment; cancellation
  with 3 suggested slots from availability engine; 24h reminder.
- **Cron** (`lib/cron.ts`): in-process 5-min reminder sweep + 10-min gcal sweep
  per connected space, started from onInit.
- **Invitations + timezone shift** (mod.ts, admin UI): PATCH
  /api/invitations/:id accept/decline; POST /api/admin/timezone/shift with
  optional booking relabel; invitations tab shows cross-space invites.

Full public booking flow (`components/folk-schedule-booking.ts`): month calendar,
date → slot drill, booking form, confirmation view, timezone picker.

EncryptID passkey gates admin routes via resolveCallerRole ≥ moderator.
Per-entity model: each space (and user-space) hosts its own bookable page;
bookings mirror into invitee spaces' :invitations docs so cross-space visibility
works without cross-space reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-16 18:19:10 -04:00
parent 700e372260
commit 69ce497aa8
7 changed files with 1808 additions and 89 deletions

View File

@ -1,16 +1,67 @@
/**
* <folk-schedule-admin> admin dashboard for rSchedule.
* <folk-schedule-admin> admin dashboard for rSchedule (passkey-gated).
*
* Passkey-gated (server enforces moderator role on every admin API call).
* Phase A: minimal shell with tab placeholders. Phase E builds full UI
* (availability rules, overrides, bookings list, settings, gcal connect).
* Tabs: Overview · Availability · Bookings · Google Calendar · Settings.
* All mutations go through `/api/admin/*` which enforces moderator role
* server-side via resolveCallerRole.
*/
interface Settings {
displayName: string;
email: string;
bookingMessage: string;
slotDurationMin: number;
bufferBeforeMin: number;
bufferAfterMin: number;
minNoticeHours: number;
maxAdvanceDays: number;
timezone: string;
autoShiftTimezone: boolean;
}
interface Rule { id: string; dayOfWeek: number; startTime: string; endTime: string; isActive: boolean; }
interface Override { id: string; date: string; isBlocked: boolean; startTime: string | null; endTime: string | null; reason: string; }
interface BookingRow {
id: string; guestName: string; guestEmail: string;
startTime: number; endTime: number; timezone: string;
status: string; attendeeCount: number;
}
interface GcalStatus {
connected: boolean; email: string | null; calendarIds: string[];
syncToken: string | null; lastSyncAt: number | null;
lastSyncStatus: "ok" | "error" | null; lastSyncError: string | null;
}
interface InvitationRow {
id: string;
bookingId: string;
host: { kind: string; id: string; label?: string };
title: string;
startTime: number;
endTime: number;
timezone: string;
status: string;
response: "invited" | "accepted" | "declined";
meetingLink: string | null;
}
type Tab = "overview" | "availability" | "bookings" | "invitations" | "calendar" | "settings";
class FolkScheduleAdmin extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private tab: "overview" | "availability" | "bookings" | "calendar" | "settings" = "overview";
private tab: Tab = "overview";
private err = "";
private info = "";
private authOk = false;
private settings: Settings | null = null;
private rules: Rule[] = [];
private overrides: Override[] = [];
private bookings: BookingRow[] = [];
private invitations: InvitationRow[] = [];
private gcal: GcalStatus | null = null;
private busy = false;
private browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
constructor() {
super();
@ -19,81 +70,498 @@ class FolkScheduleAdmin extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.render();
void this.init();
}
private apiBase(): string {
const path = window.location.pathname;
const m = path.match(/^(\/[^/]+)?\/rschedule/);
return `${m?.[0] || "/rschedule"}/api/admin`;
}
private authHeaders(): Record<string, string> {
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
const token = sess?.token;
return token ? { Authorization: `Bearer ${token}` } : {};
} catch { return {}; }
return token ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" };
} catch { return { "Content-Type": "application/json" }; }
}
private async ping() {
const path = window.location.pathname;
const m = path.match(/^(\/[^/]+)?\/rschedule/);
const base = `${m?.[0] || "/rschedule"}/api/admin`;
const res = await fetch(`${base}/settings`, { headers: this.authHeaders() });
if (res.status === 401) { this.err = "Sign in to manage this booking page."; return; }
if (res.status === 403) { this.err = "You need moderator or higher role in this space."; return; }
if (!res.ok) { this.err = `Error ${res.status}`; return; }
this.err = "";
private async api<T = any>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.apiBase()}${path}`, {
...init,
headers: { ...this.authHeaders(), ...(init.headers || {}) },
});
if (res.status === 401) { this.authOk = false; this.err = "Sign in with a passkey to manage this booking page."; throw new Error("unauth"); }
if (res.status === 403) { this.authOk = false; this.err = "Moderator or admin role required for this space."; throw new Error("forbidden"); }
if (!res.ok) { const e = await res.json().catch(() => ({})); this.err = e.error || `Error ${res.status}`; throw new Error(this.err); }
return res.json();
}
private async init() {
try {
const { settings } = await this.api<{ settings: Settings }>("/settings");
this.authOk = true;
this.settings = settings;
await Promise.all([this.loadRules(), this.loadOverrides(), this.loadBookings(), this.loadInvitations(), this.loadGcal()]);
} catch { /* err set in api() */ }
this.render();
}
private async loadRules() {
try { const { rules } = await this.api<{ rules: Rule[] }>("/availability"); this.rules = rules; } catch {}
}
private async loadOverrides() {
try { const { overrides } = await this.api<{ overrides: Override[] }>("/overrides"); this.overrides = overrides; } catch {}
}
private async loadBookings() {
try { const { bookings } = await this.api<{ bookings: BookingRow[] }>("/bookings"); this.bookings = bookings; } catch {}
}
private async loadGcal() {
try { this.gcal = await this.api<GcalStatus>("/google/status"); } catch {}
}
private async loadInvitations() {
try {
// Public endpoint — no /admin prefix
const base = this.apiBase().replace(/\/api\/admin$/, "/api");
const res = await fetch(`${base}/invitations`);
if (res.ok) {
const data = await res.json() as { invitations: InvitationRow[] };
this.invitations = data.invitations;
}
} catch {}
}
private async respondToInvitation(id: string, response: "accepted" | "declined") {
try {
await this.api(`/../invitations/${id}`, { method: "PATCH", body: JSON.stringify({ response }) });
} catch {
// Fallback: use the public invitations endpoint with admin auth
try {
const base = this.apiBase().replace(/\/admin$/, "");
await fetch(`${base}/invitations/${id}`, {
method: "PATCH",
headers: this.authHeaders(),
body: JSON.stringify({ response }),
});
} catch {}
}
await this.loadInvitations();
this.info = `Invitation ${response}.`;
this.render();
}
private async shiftTimezone() {
if (!this.settings) return;
const newTz = this.browserTz;
const relabel = confirm(`Change schedule timezone from ${this.settings.timezone} to ${newTz} and relabel existing bookings?`);
try {
await this.api("/timezone/shift", {
method: "POST",
body: JSON.stringify({ timezone: newTz, relabelBookings: relabel }),
});
this.info = `Timezone changed to ${newTz}.`;
await this.init();
} catch {}
}
// ── Settings ──
private async saveSettings(patch: Partial<Settings>) {
this.busy = true; this.render();
try {
const { settings } = await this.api<{ settings: Settings }>("/settings", {
method: "PUT", body: JSON.stringify(patch),
});
this.settings = settings;
this.info = "Settings saved.";
} catch {}
this.busy = false; this.render();
}
// ── Rules ──
private async addRule(r: Omit<Rule, "id">) {
this.busy = true; this.render();
try { await this.api("/availability", { method: "POST", body: JSON.stringify(r) }); await this.loadRules(); this.info = "Rule added."; } catch {}
this.busy = false; this.render();
}
private async updateRule(id: string, patch: Partial<Rule>) {
try { await this.api(`/availability/${id}`, { method: "PUT", body: JSON.stringify(patch) }); await this.loadRules(); } catch {}
this.render();
}
private async deleteRule(id: string) {
try { await this.api(`/availability/${id}`, { method: "DELETE" }); await this.loadRules(); } catch {}
this.render();
}
// ── Overrides ──
private async addOverride(o: Omit<Override, "id">) {
try { await this.api("/overrides", { method: "POST", body: JSON.stringify(o) }); await this.loadOverrides(); this.info = "Override added."; } catch {}
this.render();
}
private async deleteOverride(id: string) {
try { await this.api(`/overrides/${id}`, { method: "DELETE" }); await this.loadOverrides(); } catch {}
this.render();
}
// ── Gcal ──
private async syncGcal() {
this.busy = true; this.render();
try {
const r = await this.api<{ ok: boolean; eventsUpdated: number; deleted: number; error?: string }>("/google/sync", { method: "POST" });
this.info = r.ok ? `Synced. +${r.eventsUpdated} events, -${r.deleted} deleted.` : `Sync failed: ${r.error}`;
await this.loadGcal();
} catch {}
this.busy = false; this.render();
}
private async disconnectGcal() {
if (!confirm("Disconnect Google Calendar from this rSchedule? Tokens remain (use OAuth disconnect to fully revoke).")) return;
try { await this.api("/google/disconnect", { method: "POST" }); await this.loadGcal(); this.info = "Disconnected."; } catch {}
this.render();
}
private render() {
void this.ping().then(() => this.paint());
this.paint();
}
private paint() {
this.shadow.innerHTML = `
<style>
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:#0b1120; min-height:100vh; }
.wrap { max-width:1100px; margin:0 auto; padding:32px 24px; }
h1 { margin:0 0 6px; font-size:1.4rem; }
.sub { color:#94a3b8; margin:0 0 24px; font-size:0.9rem; }
.tabs { display:flex; gap:4px; border-bottom:1px solid rgba(148,163,184,0.12); margin-bottom:24px; overflow-x:auto; }
.tab { background:transparent; color:#94a3b8; border:0; padding:10px 16px; cursor:pointer; font-size:0.9rem; border-bottom:2px solid transparent; }
.tab.active { color:#06b6d4; border-bottom-color:#06b6d4; }
.card { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:12px; padding:24px; }
.err { padding:16px; border:1px solid rgba(239,68,68,0.3); background:rgba(239,68,68,0.08); border-radius:8px; color:#fca5a5; margin-bottom:16px; }
.placeholder { color:#94a3b8; padding:40px; text-align:center; border:1px dashed rgba(148,163,184,0.25); border-radius:10px; }
</style>
<style>${STYLES}</style>
<div class="wrap">
<h1>rSchedule admin &middot; ${escapeHtml(this.space)}</h1>
<p class="sub">Configure availability, bookings, and Google Calendar for this space.</p>
<header>
<h1>rSchedule admin · ${escapeHtml(this.space)}</h1>
<p class="sub">Configure your bookable availability, Google Calendar sync, and booking policies.</p>
</header>
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
<div class="tabs">
${this.info ? `<div class="info">${escapeHtml(this.info)}</div>` : ""}
${this.authOk ? `
${this.renderTzBanner()}
<nav class="tabs">
${this.tabBtn("overview", "Overview")}
${this.tabBtn("availability", "Availability")}
${this.tabBtn("bookings", "Bookings")}
${this.tabBtn("bookings", `Bookings${this.bookings.length ? ` (${this.bookings.length})` : ""}`)}
${this.tabBtn("invitations", `Invitations${this.invitations.length ? ` (${this.invitations.length})` : ""}`)}
${this.tabBtn("calendar", "Google Calendar")}
${this.tabBtn("settings", "Settings")}
</nav>
<section class="panel">${this.renderPanel()}</section>
` : `<div class="unauth">Sign in with a passkey to continue. Admin pages are restricted to moderators and admins of this space.</div>`}
</div>
`;
this.wire();
}
private renderPanel(): string {
switch (this.tab) {
case "overview": return this.renderOverview();
case "availability": return this.renderAvailability();
case "bookings": return this.renderBookings();
case "invitations": return this.renderInvitations();
case "calendar": return this.renderCalendar();
case "settings": return this.renderSettings();
}
}
private renderTzBanner(): string {
if (!this.settings) return "";
if (this.browserTz && this.browserTz !== this.settings.timezone) {
return `<div class="tz-banner">
Your browser is on <strong>${escapeHtml(this.browserTz)}</strong> but this schedule is configured for <strong>${escapeHtml(this.settings.timezone)}</strong>.
<button class="primary small" id="tz-shift">Shift to ${escapeHtml(this.browserTz)}</button>
</div>`;
}
return "";
}
private renderInvitations(): string {
return `
<h2>Bookings you're invited to</h2>
<p class="muted">Meetings hosted by other spaces or users where <strong>${escapeHtml(this.space)}</strong> is an attendee.</p>
${this.invitations.length === 0 ? `<p class="muted">No invitations.</p>` : `
<table class="tbl">
<thead><tr><th>When</th><th>Host</th><th>Title</th><th>Status</th><th>Response</th><th></th></tr></thead>
<tbody>
${this.invitations.map(inv => `<tr data-inv-id="${inv.id}">
<td>${escapeHtml(new Date(inv.startTime).toLocaleString())}</td>
<td>${escapeHtml(inv.host.label || inv.host.id)}</td>
<td>${escapeHtml(inv.title)}</td>
<td><span class="badge badge-${inv.status}">${inv.status}</span></td>
<td><span class="badge badge-${inv.response}">${inv.response}</span></td>
<td>
${inv.response !== "accepted" ? `<button class="secondary small inv-accept">Accept</button>` : ""}
${inv.response !== "declined" ? `<button class="danger inv-decline">Decline</button>` : ""}
</td>
</tr>`).join("")}
</tbody>
</table>
`}
`;
}
private renderOverview(): string {
const upcoming = this.bookings.filter((b) => b.status === "confirmed" && b.startTime > Date.now()).slice(0, 5);
const base = this.apiBase().replace(/\/api\/admin$/, "");
return `
<div class="grid2">
<div class="card">
<h2>Share your booking link</h2>
<p class="muted">Send this URL to anyone who wants to book time with you.</p>
<code class="link">${location.origin}${base}</code>
<button class="primary" id="copy-link">Copy link</button>
</div>
<div class="card">
<div class="placeholder">
<strong>${escapeHtml(this.tab)}</strong><br>
Full admin UI arrives in Phase E. For now, this confirms auth gate works.
<h2>Next up</h2>
${upcoming.length === 0 ? `<p class="muted">No upcoming bookings.</p>` : `
<ul class="list">
${upcoming.map((b) => `<li>
<strong>${escapeHtml(b.guestName)}</strong>
<span>${escapeHtml(new Date(b.startTime).toLocaleString())}</span>
</li>`).join("")}
</ul>
`}
</div>
<div class="card">
<h2>Google Calendar</h2>
${this.gcal?.connected
? `<p>Connected as <strong>${escapeHtml(this.gcal.email || "(unknown)")}</strong>. Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}.</p>`
: `<p class="muted">Not connected. Bookings will be created without Google Calendar events.</p>`}
<button class="secondary" data-tab="calendar">Manage </button>
</div>
<div class="card">
<h2>Availability</h2>
<p>${this.rules.filter(r => r.isActive).length} active weekly rules, ${this.overrides.length} date overrides.</p>
<button class="secondary" data-tab="availability">Configure </button>
</div>
</div>
`;
this.shadow.querySelectorAll<HTMLButtonElement>(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
this.tab = btn.dataset.tab as any;
this.paint();
});
});
}
private tabBtn(id: string, label: string): string {
private renderAvailability(): string {
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return `
<h2>Weekly availability rules</h2>
<p class="muted">Add one or more time windows per weekday. Leave a day empty to block it entirely.</p>
<table class="tbl">
<thead><tr><th>Day</th><th>Start</th><th>End</th><th>Active</th><th></th></tr></thead>
<tbody>
${this.rules.length === 0 ? `<tr><td colspan="5" class="muted">No rules yet. Add one below.</td></tr>` : this.rules
.slice()
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.startTime.localeCompare(b.startTime))
.map(r => `<tr data-rule-id="${r.id}">
<td>${DOW[r.dayOfWeek]}</td>
<td><input type="time" class="rule-start" value="${r.startTime}"></td>
<td><input type="time" class="rule-end" value="${r.endTime}"></td>
<td><input type="checkbox" class="rule-active" ${r.isActive ? "checked" : ""}></td>
<td><button class="danger rule-del">Delete</button></td>
</tr>`).join("")}
</tbody>
</table>
<form class="add-form" id="add-rule">
<select id="nr-dow">${DOW.map((d, i) => `<option value="${i}">${d}</option>`).join("")}</select>
<input type="time" id="nr-start" value="09:00">
<input type="time" id="nr-end" value="17:00">
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>Add rule</button>
</form>
<h2 style="margin-top:32px">Date overrides</h2>
<p class="muted">Block specific dates or set custom hours for a single day.</p>
<table class="tbl">
<thead><tr><th>Date</th><th>Blocked?</th><th>Custom window</th><th>Reason</th><th></th></tr></thead>
<tbody>
${this.overrides.length === 0 ? `<tr><td colspan="5" class="muted">No overrides.</td></tr>` : this.overrides
.slice()
.sort((a, b) => a.date.localeCompare(b.date))
.map(o => `<tr data-ov-id="${o.id}">
<td>${o.date}</td>
<td>${o.isBlocked ? "✓ blocked" : "custom"}</td>
<td>${o.isBlocked ? "—" : `${o.startTime || "?"} ${o.endTime || "?"}`}</td>
<td>${escapeHtml(o.reason || "")}</td>
<td><button class="danger ov-del">Delete</button></td>
</tr>`).join("")}
</tbody>
</table>
<form class="add-form" id="add-override">
<input type="date" id="no-date" required>
<label class="inline"><input type="checkbox" id="no-blocked" checked> Block day</label>
<input type="time" id="no-start" placeholder="from">
<input type="time" id="no-end" placeholder="to">
<input type="text" id="no-reason" placeholder="Reason (optional)">
<button class="primary" type="submit">Add override</button>
</form>
`;
}
private renderBookings(): string {
return `
<h2>All bookings</h2>
${this.bookings.length === 0 ? `<p class="muted">No bookings yet.</p>` : `
<table class="tbl">
<thead><tr><th>When</th><th>Guest</th><th>Email</th><th>Status</th><th>Attendees</th></tr></thead>
<tbody>
${this.bookings.map(b => `<tr>
<td>${escapeHtml(new Date(b.startTime).toLocaleString())}</td>
<td>${escapeHtml(b.guestName)}</td>
<td><a href="mailto:${escapeHtml(b.guestEmail)}">${escapeHtml(b.guestEmail)}</a></td>
<td><span class="badge badge-${b.status}">${b.status}</span></td>
<td>${b.attendeeCount}</td>
</tr>`).join("")}
</tbody>
</table>
`}
`;
}
private renderCalendar(): string {
const path = window.location.pathname;
const connectUrl = `/api/oauth/google/authorize?space=${encodeURIComponent(this.space)}&returnTo=rschedule%2Fadmin`;
const base = this.apiBase().replace(/\/api\/admin$/, "");
return `
<h2>Google Calendar</h2>
${this.gcal?.connected ? `
<p>Connected to Google as <strong>${escapeHtml(this.gcal.email || "(unknown)")}</strong>.</p>
<p class="muted">Calendar ID: <code>${escapeHtml(this.gcal.calendarIds[0] || "primary")}</code></p>
<p class="muted">Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}
${this.gcal.lastSyncStatus ? ` · <span class="badge badge-${this.gcal.lastSyncStatus === "ok" ? "confirmed" : "cancelled"}">${this.gcal.lastSyncStatus}</span>` : ""}</p>
${this.gcal.lastSyncError ? `<p class="err-inline">${escapeHtml(this.gcal.lastSyncError)}</p>` : ""}
<div class="row-gap">
<button class="primary" id="sync-gcal" ${this.busy ? "disabled" : ""}>Sync now</button>
<button class="secondary" id="disconnect-gcal">Disconnect from rSchedule</button>
</div>
<p class="muted small">Tokens are shared across rSpace modules and live in the encrypted connections doc. Fully revoke the OAuth grant via <code>/api/oauth/google/disconnect?space=${escapeHtml(this.space)}</code>.</p>
` : `
<p class="muted">Connecting Google Calendar blocks bookings when you're busy on gcal and creates a calendar event for every confirmed booking.</p>
<a class="primary" href="${connectUrl}">Connect Google Calendar</a>
`}
`;
}
private renderSettings(): string {
const s = this.settings;
if (!s) return `<p class="muted">Loading…</p>`;
return `
<h2>Booking policies</h2>
<form id="settings-form" class="settings-form">
<label>Display name
<input name="displayName" value="${escapeHtml(s.displayName || "")}" placeholder="${escapeHtml(this.space)}">
</label>
<label>Contact email
<input name="email" type="email" value="${escapeHtml(s.email || "")}">
</label>
<label class="full">Booking page message
<textarea name="bookingMessage" rows="3">${escapeHtml(s.bookingMessage)}</textarea>
</label>
<label>Slot duration (min)
<input name="slotDurationMin" type="number" min="15" max="120" step="5" value="${s.slotDurationMin}">
</label>
<label>Timezone
<input name="timezone" value="${escapeHtml(s.timezone)}">
</label>
<label>Buffer before (min)
<input name="bufferBeforeMin" type="number" min="0" value="${s.bufferBeforeMin}">
</label>
<label>Buffer after (min)
<input name="bufferAfterMin" type="number" min="0" value="${s.bufferAfterMin}">
</label>
<label>Min notice (hours)
<input name="minNoticeHours" type="number" min="0" value="${s.minNoticeHours}">
</label>
<label>Max advance (days)
<input name="maxAdvanceDays" type="number" min="1" max="365" value="${s.maxAdvanceDays}">
</label>
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${this.busy ? "Saving…" : "Save settings"}</button>
</form>
`;
}
private tabBtn(id: Tab, label: string): string {
return `<button class="tab ${this.tab === id ? "active" : ""}" data-tab="${id}">${label}</button>`;
}
private wire() {
const $ = (id: string) => this.shadow.getElementById(id);
this.shadow.querySelectorAll<HTMLElement>("[data-tab]").forEach((el) => {
el.addEventListener("click", () => {
this.tab = (el.dataset.tab as Tab) || "overview";
this.err = ""; this.info = "";
this.render();
});
});
// Copy link
$("copy-link")?.addEventListener("click", () => {
const base = this.apiBase().replace(/\/api\/admin$/, "");
navigator.clipboard?.writeText(`${location.origin}${base}`);
this.info = "Link copied.";
this.render();
});
// Rule handlers
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-rule-id]").forEach((row) => {
const id = row.dataset.ruleId!;
const start = row.querySelector<HTMLInputElement>(".rule-start");
const end = row.querySelector<HTMLInputElement>(".rule-end");
const active = row.querySelector<HTMLInputElement>(".rule-active");
start?.addEventListener("change", () => void this.updateRule(id, { startTime: start.value }));
end?.addEventListener("change", () => void this.updateRule(id, { endTime: end.value }));
active?.addEventListener("change", () => void this.updateRule(id, { isActive: active.checked }));
row.querySelector<HTMLButtonElement>(".rule-del")?.addEventListener("click", () => void this.deleteRule(id));
});
(this.shadow.getElementById("add-rule") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const dow = Number(($("nr-dow") as HTMLSelectElement).value);
const startTime = ($("nr-start") as HTMLInputElement).value;
const endTime = ($("nr-end") as HTMLInputElement).value;
void this.addRule({ dayOfWeek: dow, startTime, endTime, isActive: true });
});
// Override handlers
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-ov-id]").forEach((row) => {
const id = row.dataset.ovId!;
row.querySelector<HTMLButtonElement>(".ov-del")?.addEventListener("click", () => void this.deleteOverride(id));
});
(this.shadow.getElementById("add-override") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const date = ($("no-date") as HTMLInputElement).value;
const blocked = ($("no-blocked") as HTMLInputElement).checked;
const start = ($("no-start") as HTMLInputElement).value || null;
const end = ($("no-end") as HTMLInputElement).value || null;
const reason = ($("no-reason") as HTMLInputElement).value;
if (!date) return;
void this.addOverride({ date, isBlocked: blocked, startTime: start, endTime: end, reason });
});
// Gcal
$("sync-gcal")?.addEventListener("click", () => void this.syncGcal());
$("disconnect-gcal")?.addEventListener("click", () => void this.disconnectGcal());
// Timezone shift
$("tz-shift")?.addEventListener("click", () => void this.shiftTimezone());
// Invitations
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-inv-id]").forEach((row) => {
const id = row.dataset.invId!;
row.querySelector<HTMLButtonElement>(".inv-accept")?.addEventListener("click", () => void this.respondToInvitation(id, "accepted"));
row.querySelector<HTMLButtonElement>(".inv-decline")?.addEventListener("click", () => void this.respondToInvitation(id, "declined"));
});
// Settings form
(this.shadow.getElementById("settings-form") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const patch: Partial<Settings> = {
displayName: String(fd.get("displayName") || ""),
email: String(fd.get("email") || ""),
bookingMessage: String(fd.get("bookingMessage") || ""),
slotDurationMin: Number(fd.get("slotDurationMin")),
timezone: String(fd.get("timezone") || "UTC"),
bufferBeforeMin: Number(fd.get("bufferBeforeMin")),
bufferAfterMin: Number(fd.get("bufferAfterMin")),
minNoticeHours: Number(fd.get("minNoticeHours")),
maxAdvanceDays: Number(fd.get("maxAdvanceDays")),
};
void this.saveSettings(patch);
});
}
}
function escapeHtml(s: string): string {
return String(s)
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@ -101,6 +569,58 @@ function escapeHtml(s: string): string {
.replace(/'/g, "&#39;");
}
const STYLES = `
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:#0b1120; min-height:100vh; }
.wrap { max-width:1100px; margin:0 auto; padding:32px 24px; }
header h1 { margin:0 0 4px; font-size:1.5rem; }
.sub { color:#94a3b8; margin:0 0 20px; font-size:0.9rem; }
.err, .err-inline { padding:12px 14px; border:1px solid rgba(239,68,68,0.3); background:rgba(239,68,68,0.08); border-radius:8px; color:#fca5a5; margin-bottom:14px; font-size:0.9rem; }
.info { padding:10px 14px; border:1px solid rgba(34,197,94,0.3); background:rgba(34,197,94,0.08); border-radius:8px; color:#86efac; margin-bottom:14px; font-size:0.9rem; }
.unauth { padding:40px; text-align:center; border:1px dashed rgba(148,163,184,0.3); border-radius:10px; color:#94a3b8; margin-top:20px; }
.tabs { display:flex; gap:4px; border-bottom:1px solid rgba(148,163,184,0.12); margin-bottom:20px; overflow-x:auto; }
.tab { background:transparent; color:#94a3b8; border:0; padding:10px 16px; cursor:pointer; font-size:0.9rem; border-bottom:2px solid transparent; white-space:nowrap; }
.tab.active { color:#06b6d4; border-bottom-color:#06b6d4; }
.panel { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:12px; padding:24px; }
.panel h2 { margin:0 0 8px; font-size:1.05rem; color:#e2e8f0; }
.muted { color:#94a3b8; font-size:0.9rem; }
.small { font-size:0.8rem; }
.link { display:inline-block; background:rgba(6,182,212,0.08); padding:6px 10px; border-radius:6px; margin:6px 8px 6px 0; font-family:ui-monospace, monospace; color:#5eead4; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width:700px) { .grid2 { grid-template-columns:1fr; } }
.card { background:#0b1120; border:1px solid rgba(148,163,184,0.12); border-radius:10px; padding:20px; }
.card h2 { margin:0 0 8px; font-size:0.95rem; }
.list { list-style:none; padding:0; margin:0; }
.list li { display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px solid rgba(148,163,184,0.08); font-size:0.9rem; }
.list li:last-child { border-bottom:0; }
.primary { background:linear-gradient(to right,#06b6d4,#8b5cf6); color:#fff; border:0; padding:9px 16px; border-radius:7px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-block; font-size:0.9rem; }
.primary:disabled { opacity:0.6; cursor:default; }
.secondary { background:transparent; color:#06b6d4; border:1px solid rgba(6,182,212,0.4); padding:8px 14px; border-radius:7px; cursor:pointer; font-size:0.9rem; }
.secondary:hover { background:rgba(6,182,212,0.08); }
.danger { background:transparent; color:#fca5a5; border:1px solid rgba(239,68,68,0.3); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:0.82rem; }
.danger:hover { background:rgba(239,68,68,0.1); }
.row-gap { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
.tbl { width:100%; border-collapse:collapse; margin-top:12px; font-size:0.9rem; }
.tbl th, .tbl td { text-align:left; padding:8px 10px; border-bottom:1px solid rgba(148,163,184,0.1); }
.tbl th { color:#64748b; font-weight:500; font-size:0.8rem; }
.tbl input[type="time"], .tbl input[type="text"] { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:4px 8px; border-radius:5px; }
.add-form { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; align-items:center; }
.add-form input, .add-form select { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:6px 10px; border-radius:6px; font-size:0.9rem; }
.add-form .inline { display:flex; align-items:center; gap:6px; color:#cbd5e1; font-size:0.9rem; }
.settings-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; max-width:800px; }
.settings-form label { display:flex; flex-direction:column; gap:4px; color:#cbd5e1; font-size:0.85rem; }
.settings-form label.full { grid-column:1 / -1; }
.settings-form input, .settings-form textarea { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:8px 10px; border-radius:6px; font-size:0.9rem; font-family:inherit; }
.settings-form button[type="submit"] { grid-column:1 / -1; justify-self:start; }
.badge { padding:2px 8px; border-radius:99px; font-size:0.75rem; font-weight:500; }
.badge-confirmed, .badge-accepted, .badge-ok { background:rgba(34,197,94,0.15); color:#86efac; }
.badge-cancelled, .badge-declined, .badge-error { background:rgba(239,68,68,0.15); color:#fca5a5; }
.badge-completed { background:rgba(148,163,184,0.15); color:#cbd5e1; }
.badge-invited { background:rgba(6,182,212,0.15); color:#5eead4; }
.tz-banner { background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.3); color:#fcd34d; padding:12px 16px; border-radius:8px; margin-bottom:14px; font-size:0.9rem; display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
.small { font-size:0.82rem; padding:5px 10px; }
code { background:rgba(148,163,184,0.1); padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; font-size:0.85rem; color:#e2e8f0; }
`;
customElements.define("folk-schedule-admin", FolkScheduleAdmin);
export {};

View File

@ -1,10 +1,11 @@
/**
* <folk-schedule-booking> public booking page.
*
* Phase A: stub that fetches public settings + availability and shows a
* placeholder date picker. Phase B ports the full UI from
* schedule-jeffemmett/src/app/page.tsx (month calendar, slot list, timezone
* toggle, world map, booking form).
* Flow: fetch public settings render month calendar click date to see
* slots click slot to open booking form submit show confirmation.
*
* Port of schedule-jeffemmett/src/app/page.tsx, distilled to the essential UX.
* World map + advanced timezone picker deferred to Phase H polish.
*/
interface PublicSettings {
@ -16,10 +17,32 @@ interface PublicSettings {
timezone: string;
}
interface Slot {
id: string;
startUtc: string;
endUtc: string;
date: string;
startDisplay: string;
endDisplay: string;
startMs: number;
endMs: number;
}
class FolkScheduleBooking extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private settings: PublicSettings | null = null;
private viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
private monthCursor = new Date(); // first of the visible month
private selectedDate: string | null = null; // YYYY-MM-DD
private slotsByDate: Record<string, Slot[]> = {};
private loadedRange: { fromMs: number; toMs: number } | null = null;
private selectedSlot: Slot | null = null;
private form = { name: "", email: "", note: "" };
private confirmation: { id: string; cancelToken: string } | null = null;
private submitting = false;
private loadingAvailability = false;
private err = "";
constructor() {
super();
@ -28,18 +51,8 @@ class FolkScheduleBooking extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
void this.load();
}
private async load() {
try {
const base = this.apiBase();
const res = await fetch(`${base}/settings/public`);
this.settings = res.ok ? await res.json() : null;
} catch {
this.settings = null;
}
this.render();
this.monthCursor = firstOfMonth(new Date());
void this.init();
}
private apiBase(): string {
@ -48,37 +61,277 @@ class FolkScheduleBooking extends HTMLElement {
return `${match?.[0] || "/rschedule"}/api`;
}
private async init() {
try {
const res = await fetch(`${this.apiBase()}/settings/public`);
this.settings = res.ok ? await res.json() : null;
} catch {
this.settings = null;
}
await this.loadMonth();
this.render();
}
private async loadMonth() {
const start = firstOfMonth(this.monthCursor);
const end = lastOfMonth(this.monthCursor);
this.loadingAvailability = true;
this.render();
try {
const qs = new URLSearchParams({
from: start.toISOString(),
to: end.toISOString(),
tz: this.viewerTz,
});
const res = await fetch(`${this.apiBase()}/availability?${qs}`);
if (!res.ok) throw new Error(`Availability ${res.status}`);
const data = await res.json();
const byDate: Record<string, Slot[]> = {};
for (const s of data.slots as Slot[]) {
(byDate[s.date] ||= []).push(s);
}
this.slotsByDate = byDate;
this.loadedRange = { fromMs: start.getTime(), toMs: end.getTime() };
this.err = "";
} catch (e: any) {
this.err = e?.message || String(e);
}
this.loadingAvailability = false;
}
private async submit() {
if (!this.selectedSlot) return;
if (!this.form.name.trim() || !this.form.email.trim()) {
this.err = "Name and email required.";
this.render();
return;
}
this.submitting = true;
this.err = "";
this.render();
try {
const res = await fetch(`${this.apiBase()}/bookings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
startTime: this.selectedSlot.startMs,
endTime: this.selectedSlot.endMs,
timezone: this.viewerTz,
guestName: this.form.name,
guestEmail: this.form.email,
guestNote: this.form.note,
}),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || `Booking failed (${res.status})`);
this.confirmation = await res.json();
} catch (e: any) {
this.err = e?.message || String(e);
}
this.submitting = false;
this.render();
}
private render() {
const s = this.settings;
const name = s?.displayName || this.space;
const msg = s?.bookingMessage || "Book a time to chat.";
const duration = s?.slotDurationMin ?? 30;
const msg = s?.bookingMessage || "Book a time to chat.";
this.shadow.innerHTML = `
<style>
:host { display: block; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; background: #0b1120; }
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px; }
.card { background: #111827; border: 1px solid rgba(148,163,184,0.12); border-radius: 16px; padding: 32px; }
h1 { margin: 0 0 8px; font-size: 1.6rem; background: linear-gradient(to right, #06b6d4, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.meta { display: flex; gap: 16px; color: #94a3b8; font-size: 0.9rem; margin-bottom: 16px; }
.msg { color: #cbd5e1; margin: 0 0 24px; white-space: pre-wrap; }
.placeholder { padding: 40px; text-align: center; border: 1px dashed rgba(148,163,184,0.25); border-radius: 10px; color: #94a3b8; }
</style>
<style>${STYLES}</style>
<div class="wrap">
<div class="card">
<header class="hero">
<h1>Book with ${escapeHtml(name)}</h1>
<p class="msg">${escapeHtml(msg)}</p>
<div class="meta">
<span> ${duration} min</span>
${s?.timezone ? `<span>🌐 ${escapeHtml(s.timezone)}</span>` : ""}
<span>🌐 <select class="tz-select" id="tz">
${timezoneOptions(this.viewerTz)}
</select></span>
</div>
<p class="msg">${escapeHtml(msg)}</p>
<div class="placeholder">
Date picker and slot list coming in Phase B.<br>
Admin: <a href="admin" style="color:#06b6d4">configure availability</a>
</header>
${this.confirmation ? this.renderConfirmation() : this.selectedSlot ? this.renderForm() : this.renderCalendar()}
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
</div>
`;
this.wireHandlers();
}
private renderCalendar(): string {
const cursor = this.monthCursor;
const monthLabel = cursor.toLocaleString(undefined, { month: "long", year: "numeric" });
const firstDow = cursor.getDay();
const days = daysInMonth(cursor);
const today = toDateStr(new Date());
const cells: string[] = [];
for (let i = 0; i < firstDow; i++) cells.push(`<div class="cell empty"></div>`);
for (let d = 1; d <= days; d++) {
const date = new Date(cursor.getFullYear(), cursor.getMonth(), d);
const dateStr = toDateStr(date);
const hasSlots = (this.slotsByDate[dateStr] || []).length > 0;
const isToday = dateStr === today;
const classes = ["cell", "day", hasSlots ? "has-slots" : "no-slots", isToday ? "today" : "", this.selectedDate === dateStr ? "selected" : ""].filter(Boolean).join(" ");
cells.push(`<button class="${classes}" data-date="${dateStr}" ${hasSlots ? "" : "disabled"}>${d}</button>`);
}
const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : [];
return `
<div class="cal-wrap">
<div class="cal-col">
<div class="cal-header">
<button class="nav-btn" id="prev-month"></button>
<span class="month-label">${escapeHtml(monthLabel)}</span>
<button class="nav-btn" id="next-month"></button>
</div>
<div class="dow-row">
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="dow">${d}</div>`).join("")}
</div>
<div class="cal-grid">${cells.join("")}</div>
${this.loadingAvailability ? `<p class="loading">Loading availability&hellip;</p>` : ""}
</div>
<div class="slot-col">
${this.selectedDate ? `
<h3>${escapeHtml(new Date(this.selectedDate + "T12:00:00").toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }))}</h3>
${slots.length === 0 ? `<p class="empty-slots">No times available.</p>` : `
<div class="slot-list">
${slots.map(sl => `<button class="slot-btn" data-slot-id="${sl.id}">${escapeHtml(sl.startDisplay)}</button>`).join("")}
</div>
`}
` : `<p class="hint">Pick a date with availability.</p>`}
</div>
</div>
`;
}
private renderForm(): string {
const sl = this.selectedSlot!;
const dateLabel = new Date(sl.startMs).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
return `
<div class="form-card">
<div class="slot-pill">
📅 ${escapeHtml(dateLabel)} &middot; ${escapeHtml(sl.startDisplay)} ${escapeHtml(sl.endDisplay)}
<button class="link-btn" id="change-slot">change</button>
</div>
<label>Your name <input id="name" value="${escapeHtml(this.form.name)}" autocomplete="name"></label>
<label>Email <input id="email" type="email" value="${escapeHtml(this.form.email)}" autocomplete="email"></label>
<label>Anything you'd like me to know? (optional)
<textarea id="note" rows="4">${escapeHtml(this.form.note)}</textarea>
</label>
<button class="submit-btn" id="submit" ${this.submitting ? "disabled" : ""}>
${this.submitting ? "Booking…" : "Confirm booking"}
</button>
</div>
`;
}
private renderConfirmation(): string {
return `
<div class="confirm-card">
<div class="confirm-icon"></div>
<h2>You're booked!</h2>
<p>A confirmation email is on the way. You'll also get a calendar invite.</p>
<p class="small">Booking id: <code>${escapeHtml(this.confirmation!.id)}</code></p>
<button class="secondary-btn" id="book-another">Book another</button>
</div>
`;
}
private wireHandlers() {
const $ = (id: string) => this.shadow.getElementById(id);
// Timezone
const tz = $("tz") as HTMLSelectElement | null;
tz?.addEventListener("change", () => {
this.viewerTz = tz.value;
void this.loadMonth().then(() => this.render());
});
// Month nav
$("prev-month")?.addEventListener("click", () => {
this.monthCursor = addMonths(this.monthCursor, -1);
this.selectedDate = null;
void this.loadMonth().then(() => this.render());
});
$("next-month")?.addEventListener("click", () => {
this.monthCursor = addMonths(this.monthCursor, 1);
this.selectedDate = null;
void this.loadMonth().then(() => this.render());
});
// Date click
this.shadow.querySelectorAll<HTMLButtonElement>(".cell.day").forEach((btn) => {
btn.addEventListener("click", () => {
this.selectedDate = btn.dataset.date || null;
this.render();
});
});
// Slot click
this.shadow.querySelectorAll<HTMLButtonElement>(".slot-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.slotId;
const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : [];
const sl = slots.find((x) => x.id === id);
if (sl) {
this.selectedSlot = sl;
this.render();
}
});
});
// Form
$("change-slot")?.addEventListener("click", () => {
this.selectedSlot = null;
this.render();
});
const nameEl = $("name") as HTMLInputElement | null;
const emailEl = $("email") as HTMLInputElement | null;
const noteEl = $("note") as HTMLTextAreaElement | null;
nameEl?.addEventListener("input", () => { this.form.name = nameEl.value; });
emailEl?.addEventListener("input", () => { this.form.email = emailEl.value; });
noteEl?.addEventListener("input", () => { this.form.note = noteEl.value; });
$("submit")?.addEventListener("click", () => void this.submit());
// Post-confirmation
$("book-another")?.addEventListener("click", () => {
this.confirmation = null;
this.selectedSlot = null;
this.selectedDate = null;
this.form = { name: "", email: "", note: "" };
void this.loadMonth().then(() => this.render());
});
}
}
// ── Helpers ──
function firstOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); }
function lastOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); }
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); }
function addMonths(d: Date, n: number): Date { return new Date(d.getFullYear(), d.getMonth() + n, 1); }
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timezoneOptions(selected: string): string {
// Small curated list; could later pull full IANA list.
const zones = [
"America/Vancouver", "America/Los_Angeles", "America/Denver", "America/Chicago",
"America/New_York", "America/Toronto", "America/Halifax", "America/Sao_Paulo",
"Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid", "Europe/Rome",
"Europe/Amsterdam", "Europe/Athens", "Africa/Nairobi", "Africa/Johannesburg",
"Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai",
"Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", "Pacific/Auckland",
"UTC",
];
if (!zones.includes(selected)) zones.unshift(selected);
return zones.map((z) => `<option value="${z}" ${z === selected ? "selected" : ""}>${z}</option>`).join("");
}
function escapeHtml(s: string): string {
@ -90,6 +343,54 @@ function escapeHtml(s: string): string {
.replace(/'/g, "&#39;");
}
const STYLES = `
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height:100vh; background:#0b1120; }
.wrap { max-width:1100px; margin:0 auto; padding:40px 24px; }
.hero h1 { margin:0 0 6px; font-size:1.8rem; background:linear-gradient(to right,#06b6d4,#8b5cf6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.hero .msg { color:#cbd5e1; margin:0 0 12px; white-space:pre-wrap; }
.hero .meta { display:flex; gap:16px; color:#94a3b8; font-size:0.9rem; margin-bottom:24px; align-items:center; }
.tz-select { background:#111827; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); border-radius:6px; padding:4px 8px; font-size:0.85rem; }
.cal-wrap { display:grid; grid-template-columns:1fr 320px; gap:24px; background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:14px; padding:20px; }
@media (max-width:780px) { .cal-wrap { grid-template-columns:1fr; } }
.cal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.month-label { font-weight:600; }
.nav-btn { background:transparent; color:#cbd5e1; border:1px solid rgba(148,163,184,0.2); border-radius:6px; width:32px; height:32px; cursor:pointer; font-size:1.1rem; }
.nav-btn:hover { background:rgba(148,163,184,0.1); }
.dow-row { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; margin-bottom:4px; }
.dow { text-align:center; font-size:0.75rem; color:#64748b; padding:4px 0; }
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; }
.cell { aspect-ratio:1; display:flex; align-items:center; justify-content:center; border-radius:6px; font-size:0.9rem; }
.cell.empty { background:transparent; }
.cell.day { border:1px solid rgba(148,163,184,0.08); background:rgba(30,41,59,0.3); color:#94a3b8; cursor:pointer; }
.cell.day.has-slots { background:rgba(6,182,212,0.12); border-color:rgba(6,182,212,0.3); color:#e2e8f0; }
.cell.day.has-slots:hover { background:rgba(6,182,212,0.22); }
.cell.day.no-slots { opacity:0.3; cursor:not-allowed; }
.cell.day.today { font-weight:700; }
.cell.day.selected { background:#06b6d4; color:#0b1120; border-color:#06b6d4; }
.slot-col h3 { margin:0 0 12px; font-size:1rem; color:#e2e8f0; }
.slot-list { display:grid; grid-template-columns:repeat(2,1fr); gap:6px; }
.slot-btn { background:rgba(6,182,212,0.08); color:#e2e8f0; border:1px solid rgba(6,182,212,0.2); border-radius:8px; padding:10px; cursor:pointer; font-size:0.9rem; transition:0.15s; }
.slot-btn:hover { background:rgba(6,182,212,0.2); }
.hint, .empty-slots, .loading { color:#64748b; font-size:0.85rem; }
.loading { margin-top:12px; }
.form-card { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:14px; padding:24px; max-width:560px; }
.slot-pill { background:rgba(6,182,212,0.08); border:1px solid rgba(6,182,212,0.3); padding:10px 14px; border-radius:10px; margin-bottom:20px; color:#e2e8f0; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.link-btn { background:transparent; color:#06b6d4; border:0; cursor:pointer; text-decoration:underline; padding:0; font-size:0.85rem; margin-left:auto; }
.form-card label { display:block; margin-bottom:14px; color:#cbd5e1; font-size:0.9rem; }
.form-card input, .form-card textarea { display:block; width:100%; margin-top:6px; padding:9px 12px; border-radius:8px; border:1px solid rgba(148,163,184,0.2); background:#0b1120; color:#e2e8f0; font-family:inherit; font-size:0.95rem; box-sizing:border-box; }
.submit-btn { background:linear-gradient(to right,#06b6d4,#8b5cf6); color:#fff; border:0; padding:11px 22px; border-radius:8px; font-weight:600; cursor:pointer; font-size:0.95rem; }
.submit-btn:disabled { opacity:0.6; cursor:default; }
.secondary-btn { background:transparent; color:#06b6d4; border:1px solid rgba(6,182,212,0.4); padding:9px 20px; border-radius:8px; cursor:pointer; font-size:0.9rem; margin-top:10px; }
.secondary-btn:hover { background:rgba(6,182,212,0.08); }
.confirm-card { background:#111827; border:1px solid rgba(34,197,94,0.3); border-radius:14px; padding:40px; text-align:center; max-width:500px; }
.confirm-icon { font-size:2.5rem; color:#22c55e; margin-bottom:8px; }
.confirm-card h2 { margin:8px 0; color:#e2e8f0; }
.confirm-card p { color:#cbd5e1; }
.confirm-card .small { font-size:0.8rem; color:#64748b; }
.err { margin-top:16px; padding:14px; background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.3); border-radius:8px; color:#fca5a5; font-size:0.9rem; }
code { background:rgba(148,163,184,0.1); padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; font-size:0.8rem; }
`;
customElements.define("folk-schedule-booking", FolkScheduleBooking);
export {};

View File

@ -0,0 +1,102 @@
/**
* Calendar-link helpers port of schedule-jeffemmett/src/lib/calendar-links.ts.
*
* Used in confirmation emails so guests can add the booking to their calendar
* with one click, and in `.ics` attachments for Apple Calendar / Outlook.
*/
function formatUtcDate(iso: string): string {
return new Date(iso).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
export interface CalLinkParams {
startUtc: string;
endUtc: string;
title: string;
meetingLink?: string | null;
guestNote?: string | null;
locationOverride?: string | null;
}
export function getGoogleCalendarUrl(p: CalLinkParams): string {
const start = formatUtcDate(p.startUtc);
const end = formatUtcDate(p.endUtc);
const details = [p.guestNote ? `Note: ${p.guestNote}` : "", p.meetingLink ? `Join: ${p.meetingLink}` : ""].filter(Boolean).join("\n");
const q = new URLSearchParams({
action: "TEMPLATE",
text: p.title,
dates: `${start}/${end}`,
...(details && { details }),
...(p.meetingLink ? { location: p.meetingLink } : p.locationOverride ? { location: p.locationOverride } : {}),
});
return `https://calendar.google.com/calendar/render?${q.toString()}`;
}
export function getOutlookCalendarUrl(p: CalLinkParams): string {
const body = [p.guestNote ? `Note: ${p.guestNote}` : "", p.meetingLink ? `Join: ${p.meetingLink}` : ""].filter(Boolean).join("\n");
const q = new URLSearchParams({
path: "/calendar/action/compose",
rru: "addevent",
subject: p.title,
startdt: new Date(p.startUtc).toISOString(),
enddt: new Date(p.endUtc).toISOString(),
...(body && { body }),
...(p.meetingLink ? { location: p.meetingLink } : {}),
});
return `https://outlook.live.com/calendar/0/action/compose?${q.toString()}`;
}
export function getYahooCalendarUrl(p: CalLinkParams): string {
const start = formatUtcDate(p.startUtc);
const end = formatUtcDate(p.endUtc);
const desc = [p.guestNote ? `Note: ${p.guestNote}` : "", p.meetingLink ? `Join: ${p.meetingLink}` : ""].filter(Boolean).join("\n");
const q = new URLSearchParams({
v: "60",
title: p.title,
st: start,
et: end,
...(desc && { desc }),
...(p.meetingLink ? { in_loc: p.meetingLink } : {}),
});
return `https://calendar.yahoo.com/?${q.toString()}`;
}
export interface IcsParams extends CalLinkParams {
attendees?: string[];
organizerEmail: string;
organizerName: string;
uidDomain?: string;
method?: "REQUEST" | "PUBLISH" | "CANCEL";
}
export function generateIcsContent(p: IcsParams): string {
const start = formatUtcDate(p.startUtc);
const end = formatUtcDate(p.endUtc);
const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@${p.uidDomain || "rschedule.rspace.online"}`;
const stamp = formatUtcDate(new Date().toISOString());
const description = [p.guestNote ? `Note: ${p.guestNote}` : "", p.meetingLink ? `Join: ${p.meetingLink}` : ""].filter(Boolean).join("\\n");
const method = p.method ?? (p.attendees?.length ? "REQUEST" : "PUBLISH");
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//rSchedule//Booking//EN",
"CALSCALE:GREGORIAN",
`METHOD:${method}`,
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${stamp}`,
`DTSTART:${start}`,
`DTEND:${end}`,
`SUMMARY:${p.title.replace(/([;,\\\n])/g, "\\$1")}`,
...(description ? [`DESCRIPTION:${description}`] : []),
...(p.meetingLink ? [`LOCATION:${p.meetingLink}`, `URL:${p.meetingLink}`] : []),
`ORGANIZER;CN=${p.organizerName}:mailto:${p.organizerEmail}`,
`ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=${p.organizerName}:mailto:${p.organizerEmail}`,
...(p.attendees || []).map((e) => `ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${e}`),
"END:VEVENT",
"END:VCALENDAR",
];
return lines.join("\r\n");
}

View File

@ -0,0 +1,99 @@
/**
* rSchedule cron internal tick loops for reminders and Google Calendar sync.
*
* Runs in-process (no external scheduler); follows the same pattern as
* rCal's `startGcalSyncLoop()`. The user's original intent was to drive these
* from rMinders, but rMinders' generic action types don't yet cover our
* internal needs keeping them here keeps the cron coupled to the module
* that owns the data. rMinders still handles user-authored automations.
*
* Tick cadence:
* - reminder sweep: every 5 minutes (checks window ±30 min around 24h mark)
* - gcal sweep: every 10 minutes per connected space
*/
import type { SyncServer } from "../../../server/local-first/sync-server";
import {
scheduleBookingsDocId,
scheduleConfigDocId,
type ScheduleBookingsDoc,
type ScheduleConfigDoc,
} from "../schemas";
import { syncGcalBusy } from "./gcal-sync";
import { sendBookingReminder } from "./emails";
const REMINDER_TICK_MS = 5 * 60 * 1000;
const GCAL_TICK_MS = 10 * 60 * 1000;
const REMINDER_WINDOW_START = 23 * 3600 * 1000; // 23h ahead
const REMINDER_WINDOW_END = 25 * 3600 * 1000; // 25h ahead
let _reminderTimer: ReturnType<typeof setInterval> | null = null;
let _gcalTimer: ReturnType<typeof setInterval> | null = null;
/** Build "https://<host>" if the env hints at it — best-effort for email links. */
function hostBaseUrl(): string {
return process.env.RSPACE_BASE_URL || "https://rspace.online";
}
export function startRScheduleCron(syncServer: SyncServer): void {
if (_reminderTimer) return;
_reminderTimer = setInterval(() => void reminderSweep(syncServer).catch((e) => console.error("[rSchedule cron] reminder:", e?.message)), REMINDER_TICK_MS);
_gcalTimer = setInterval(() => void gcalSweep(syncServer).catch((e) => console.error("[rSchedule cron] gcal:", e?.message)), GCAL_TICK_MS);
// Run once on boot (small delay so syncServer.loadAllDocs has finished)
setTimeout(() => {
void reminderSweep(syncServer).catch(() => {});
void gcalSweep(syncServer).catch(() => {});
}, 15000);
console.log("[rSchedule cron] started — 5min reminder + 10min gcal sweeps");
}
export function stopRScheduleCron(): void {
if (_reminderTimer) { clearInterval(_reminderTimer); _reminderTimer = null; }
if (_gcalTimer) { clearInterval(_gcalTimer); _gcalTimer = null; }
}
// ── Sweeps ──
async function reminderSweep(syncServer: SyncServer): Promise<void> {
const now = Date.now();
for (const docId of syncServer.listDocs()) {
if (!docId.endsWith(":schedule:bookings")) continue;
const space = docId.split(":")[0];
const doc = syncServer.getDoc<ScheduleBookingsDoc>(docId);
if (!doc) continue;
const cfg = syncServer.getDoc<ScheduleConfigDoc>(scheduleConfigDocId(space));
const hostName = cfg?.settings.displayName || space;
for (const b of Object.values(doc.bookings)) {
if (b.status !== "confirmed") continue;
if (b.reminderSentAt) continue;
const delta = b.startTime - now;
if (delta < REMINDER_WINDOW_START || delta > REMINDER_WINDOW_END) continue;
const cancelUrl = `${hostBaseUrl()}/${space}/rschedule/cancel/${b.id}?token=${encodeURIComponent(b.cancelToken)}`;
const result = await sendBookingReminder({
booking: b as any,
settings: cfg?.settings || ({} as any),
hostDisplayName: hostName,
cancelUrl,
}).catch((e) => ({ ok: false, error: e?.message }));
if (result.ok) {
syncServer.changeDoc<ScheduleBookingsDoc>(docId, `reminder sent ${b.id}`, (d) => {
if (d.bookings[b.id]) d.bookings[b.id].reminderSentAt = Date.now();
});
}
}
}
}
async function gcalSweep(syncServer: SyncServer): Promise<void> {
for (const docId of syncServer.listDocs()) {
if (!docId.endsWith(":schedule:config")) continue;
const space = docId.split(":")[0];
const cfg = syncServer.getDoc<ScheduleConfigDoc>(docId);
if (!cfg?.googleAuth?.connected) continue;
await syncGcalBusy(space, syncServer).catch(() => { /* per-space failures logged inside */ });
}
}

View File

@ -0,0 +1,267 @@
/**
* rSchedule emails confirmation, cancel, reminder, attendee invite.
*
* Reuses the same SMTP transport pattern as rMinders (nodemailer postfix-mailcow
* internal, or external SMTP with auth). All emails are fire-and-forget
* failures log but don't roll back the booking write.
*/
import { createTransport, type Transporter } from "nodemailer";
import {
getGoogleCalendarUrl,
getOutlookCalendarUrl,
getYahooCalendarUrl,
generateIcsContent,
type IcsParams,
} from "./calendar-links";
import type { Booking, ScheduleSettings } from "../schemas";
let _transport: Transporter | null = null;
function getTransport(): Transporter | null {
if (_transport) return _transport;
const host = process.env.SMTP_HOST || "mail.rmail.online";
const isInternal = host.includes("mailcow") || host.includes("postfix");
if (!process.env.SMTP_PASS && !isInternal) return null;
_transport = createTransport({
host,
port: isInternal ? 25 : Number(process.env.SMTP_PORT) || 587,
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
...(isInternal ? {} : { auth: { user: process.env.SMTP_USER || "noreply@rmail.online", pass: process.env.SMTP_PASS! } }),
tls: { rejectUnauthorized: false },
});
return _transport;
}
const FROM = process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>";
// ── Formatting helpers ──
function fmtDate(ms: number, tz: string): string {
return new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", month: "long", day: "numeric", year: "numeric" }).format(new Date(ms));
}
function fmtTime(ms: number, tz: string): string {
return new Intl.DateTimeFormat("en-US", { timeZone: tz, hour: "numeric", minute: "2-digit", hour12: true }).format(new Date(ms));
}
function escapeHtml(s: string): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── Confirmation ──
export interface ConfirmationCtx {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
/** Absolute URL where guest can self-cancel. */
cancelUrl: string;
/** Optional list of extra attendee emails (beyond primary guest). */
extraAttendeeEmails?: string[];
}
export async function sendBookingConfirmation(ctx: ConfirmationCtx): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, cancelUrl, extraAttendeeEmails = [] } = ctx;
const title = `${booking.guestName}${hostDisplayName}`;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const endStr = fmtTime(booking.endTime, booking.timezone);
const attendees = [booking.guestEmail, ...extraAttendeeEmails].filter(Boolean);
const organizerEmail = settings.email || "noreply@rmail.online";
const calParams = {
startUtc: new Date(booking.startTime).toISOString(),
endUtc: new Date(booking.endTime).toISOString(),
title,
meetingLink: booking.meetingLink,
guestNote: booking.guestNote,
};
const icsParams: IcsParams = {
...calParams,
attendees,
organizerEmail,
organizerName: hostDisplayName,
};
const ics = generateIcsContent(icsParams);
const meetLink = booking.meetingLink
? `<p style="margin:16px 0"><a href="${escapeHtml(booking.meetingLink)}" style="display:inline-block;padding:10px 20px;background:#6366f1;color:#fff;border-radius:6px;text-decoration:none;">Join Meeting</a></p>`
: "";
const noteBlock = booking.guestNote ? `<p><strong>Your note:</strong> ${escapeHtml(booking.guestNote)}</p>` : "";
const attendeesBlock = attendees.length > 1 ? `<p style="font-size:13px;color:#666"><strong>Attendees:</strong> ${attendees.map(escapeHtml).join(", ")}</p>` : "";
const btnStyle = "display:inline-block;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:13px;color:#fff;margin-right:8px;margin-bottom:8px;";
const calendarButtons = `
<p style="margin:16px 0 4px 0;font-size:13px;color:#888;">Add to your calendar:</p>
<p style="margin:0 0 16px 0">
<a href="${getGoogleCalendarUrl(calParams)}" style="${btnStyle}background:#4285f4;">Google</a>
<a href="${getOutlookCalendarUrl(calParams)}" style="${btnStyle}background:#0078d4;">Outlook</a>
<a href="${getYahooCalendarUrl(calParams)}" style="${btnStyle}background:#6001d2;">Yahoo</a>
</p>
<p style="font-size:12px;color:#999;">An .ics file is attached for Apple Calendar and other apps.</p>`;
const guestHtml = `
<h2>Meeting Confirmed</h2>
<p>Hi ${escapeHtml(booking.guestName)},</p>
<p>Your meeting with ${escapeHtml(hostDisplayName)} is confirmed:</p>
<ul>
<li><strong>Date:</strong> ${escapeHtml(dateStr)}</li>
<li><strong>Time:</strong> ${escapeHtml(startStr)} ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})</li>
</ul>
${meetLink}
${attendeesBlock}
${calendarButtons}
${noteBlock}
<p><a href="${escapeHtml(cancelUrl)}">Cancel or reschedule</a></p>`;
const hostHtml = `
<h2>New Booking</h2>
<p><strong>${escapeHtml(booking.guestName)}</strong> (${escapeHtml(booking.guestEmail)}) booked a meeting with you.</p>
<ul>
<li><strong>Date:</strong> ${escapeHtml(dateStr)}</li>
<li><strong>Time:</strong> ${escapeHtml(startStr)} ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})</li>
${attendees.length > 1 ? `<li><strong>Attendees:</strong> ${attendees.map(escapeHtml).join(", ")}</li>` : ""}
</ul>
${noteBlock}`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Meeting confirmed: ${dateStr} at ${startStr}`,
html: guestHtml,
attachments: [{ filename: "meeting.ics", content: Buffer.from(ics, "utf-8"), contentType: "text/calendar; method=REQUEST" }],
});
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `New booking: ${booking.guestName}`,
html: hostHtml,
});
}
for (const email of extraAttendeeEmails) {
await t.sendMail({
from: FROM,
to: email,
subject: `Meeting invite: ${dateStr} at ${startStr}`,
html: guestHtml.replace("Your meeting", "You've been added to a meeting"),
attachments: [{ filename: "meeting.ics", content: Buffer.from(ics, "utf-8"), contentType: "text/calendar; method=REQUEST" }],
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] confirmation send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}
// ── Cancellation ──
export interface CancellationCtx {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
/** URL to the booking page (for rebooking). */
bookingPageUrl: string;
/** Up to 3 upcoming suggested slot labels for convenience. */
suggestedSlots?: Array<{ date: string; time: string; link: string }>;
}
export async function sendCancellationEmail(ctx: CancellationCtx): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, bookingPageUrl, suggestedSlots = [] } = ctx;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const reasonBlock = booking.cancellationReason ? `<p><strong>Reason:</strong> ${escapeHtml(booking.cancellationReason)}</p>` : "";
const suggestBlock = suggestedSlots.length ? `
<p>Here are a few open times if you'd like to rebook:</p>
<ul>${suggestedSlots.map((s) => `<li><a href="${escapeHtml(s.link)}">${escapeHtml(s.date)} at ${escapeHtml(s.time)}</a></li>`).join("")}</ul>
<p>Or <a href="${escapeHtml(bookingPageUrl)}">pick a different time</a>.</p>` : `<p>You can <a href="${escapeHtml(bookingPageUrl)}">pick a new time</a> any time.</p>`;
const html = `
<h2>Meeting cancelled</h2>
<p>Hi ${escapeHtml(booking.guestName)},</p>
<p>Your meeting with ${escapeHtml(hostDisplayName)} on <strong>${escapeHtml(dateStr)} at ${escapeHtml(startStr)}</strong> has been cancelled.</p>
${reasonBlock}
${suggestBlock}`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Cancelled: ${dateStr} at ${startStr}`,
html,
});
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `Cancelled: ${booking.guestName} · ${dateStr} at ${startStr}`,
html: `<h2>Booking cancelled</h2><p>${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)})</p>${reasonBlock}`,
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] cancel send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}
// ── 24h reminder ──
export async function sendBookingReminder(ctx: {
booking: Booking;
settings: ScheduleSettings;
hostDisplayName: string;
cancelUrl: string;
}): Promise<{ ok: boolean; error?: string }> {
const t = getTransport();
if (!t) return { ok: false, error: "SMTP transport not configured" };
const { booking, settings, hostDisplayName, cancelUrl } = ctx;
const dateStr = fmtDate(booking.startTime, booking.timezone);
const startStr = fmtTime(booking.startTime, booking.timezone);
const meetLink = booking.meetingLink ? `<p><a href="${escapeHtml(booking.meetingLink)}">Join Meeting</a></p>` : "";
const html = `
<h2>Reminder: meeting tomorrow</h2>
<p>Hi ${escapeHtml(booking.guestName)}, your meeting with ${escapeHtml(hostDisplayName)} is coming up.</p>
<ul>
<li><strong>${escapeHtml(dateStr)} at ${escapeHtml(startStr)}</strong> (${escapeHtml(booking.timezone)})</li>
</ul>
${meetLink}
<p>Need to cancel? <a href="${escapeHtml(cancelUrl)}">Use this link</a>.</p>`;
try {
await t.sendMail({
from: FROM,
to: booking.guestEmail,
subject: `Reminder: ${hostDisplayName} tomorrow at ${startStr}`,
html,
});
// Notify admin too so they don't no-show
if (settings.email) {
await t.sendMail({
from: FROM,
to: settings.email,
subject: `Reminder: ${booking.guestName} tomorrow at ${startStr}`,
html: `<p>Your meeting with <strong>${escapeHtml(booking.guestName)}</strong> is tomorrow at ${escapeHtml(startStr)}.</p>`,
});
}
return { ok: true };
} catch (e: any) {
console.error("[rSchedule] reminder send error:", e?.message);
return { ok: false, error: e?.message || String(e) };
}
}

View File

@ -0,0 +1,270 @@
/**
* Google Calendar sync + event creation for rSchedule.
*
* Reuses rspace's global OAuth infra (server/oauth/google.ts, server/google-calendar.ts)
* and the ConnectionsDoc stored by rdocs. rSchedule owns the *busy-time cache*
* (config.googleEvents) and *sync metadata* (config.googleAuth.syncToken, etc.)
* in its own config doc; tokens live in ConnectionsDoc (shared across modules).
*/
import {
getValidGoogleToken,
listGoogleCalendars,
fetchGoogleEvents,
createGoogleEvent,
type GoogleEvent,
type GoogleEventInput,
} from "../../../server/google-calendar";
import { connectionsDocId } from "../../rdocs/schemas";
import type { ConnectionsDoc } from "../../rdocs/schemas";
import type { SyncServer } from "../../../server/local-first/sync-server";
import {
scheduleConfigDocId,
type ScheduleConfigDoc,
type CachedGcalEvent,
type Booking,
MAX_GCAL_EVENTS_CACHED,
} from "../schemas";
// ── Status + helpers ──
export interface GcalStatus {
connected: boolean;
email: string | null;
connectedAt: number | null;
calendarIds: string[];
syncToken: string | null;
lastSyncAt: number | null;
lastSyncStatus: "ok" | "error" | null;
lastSyncError: string | null;
}
export function getGcalStatus(space: string, syncServer: SyncServer): GcalStatus {
const conn = syncServer.getDoc<ConnectionsDoc>(connectionsDocId(space));
const cfg = syncServer.getDoc<ScheduleConfigDoc>(scheduleConfigDocId(space));
const connected = Boolean(conn?.google?.refreshToken);
return {
connected,
email: conn?.google?.email ?? null,
connectedAt: conn?.google?.connectedAt ?? null,
calendarIds: cfg?.googleAuth?.calendarIds ?? [],
syncToken: cfg?.googleAuth?.syncToken ?? null,
lastSyncAt: cfg?.googleAuth?.lastSyncAt ?? null,
lastSyncStatus: cfg?.googleAuth?.lastSyncStatus ?? null,
lastSyncError: cfg?.googleAuth?.lastSyncError ?? null,
};
}
// ── Busy-time sync ──
function parseGoogleDateTime(dt?: { dateTime?: string; date?: string }): { ms: number; allDay: boolean } {
if (!dt) return { ms: 0, allDay: false };
if (dt.dateTime) return { ms: new Date(dt.dateTime).getTime(), allDay: false };
if (dt.date) return { ms: new Date(dt.date + "T00:00:00").getTime(), allDay: true };
return { ms: 0, allDay: false };
}
function toCached(ev: GoogleEvent, calendarId: string): CachedGcalEvent | null {
const start = parseGoogleDateTime(ev.start);
const end = parseGoogleDateTime(ev.end);
if (!start.ms || !end.ms) return null;
return {
id: ev.id,
googleEventId: ev.id,
calendarId,
title: ev.summary || "(busy)",
startTime: start.ms,
endTime: end.ms,
allDay: start.allDay,
status: (ev.status === "cancelled" || ev.status === "tentative" ? ev.status : "confirmed") as CachedGcalEvent["status"],
// Treat untagged events as opaque (they block). Google's `transparency`
// field would ideally come through but our lightweight GoogleEvent type
// doesn't include it — default to opaque which matches source app behavior.
transparency: "opaque",
};
}
/**
* Sync primary calendar busy times into config.googleEvents.
*
* Uses incremental sync via syncToken when possible. Falls back to a 90-day
* window on first sync or if Google returns 410 (token expired).
*/
export async function syncGcalBusy(space: string, syncServer: SyncServer): Promise<{ ok: boolean; eventsUpdated: number; deleted: number; error?: string }> {
const cfgId = scheduleConfigDocId(space);
const cfg = syncServer.getDoc<ScheduleConfigDoc>(cfgId);
if (!cfg) return { ok: false, eventsUpdated: 0, deleted: 0, error: "No rSchedule config" };
const token = await getValidGoogleToken(space, syncServer).catch(() => null);
if (!token) {
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: no token", (d) => {
d.googleAuth.lastSyncAt = Date.now();
d.googleAuth.lastSyncStatus = "error";
d.googleAuth.lastSyncError = "Not connected to Google";
});
return { ok: false, eventsUpdated: 0, deleted: 0, error: "Not connected to Google" };
}
// Pick calendar: explicit config, else primary
let calendarId = cfg.googleAuth.calendarIds[0] || "primary";
if (calendarId === "primary") {
try {
const cals = await listGoogleCalendars(token);
const primary = cals.find((c) => c.primary) || cals[0];
if (primary) {
calendarId = primary.id;
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: save primary cal id", (d) => {
d.googleAuth.calendarIds = [primary.id];
if (!d.googleAuth.email && cfg.googleAuth.email === null) {
// best-effort
}
});
}
} catch (e: any) {
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: list cals error", (d) => {
d.googleAuth.lastSyncAt = Date.now();
d.googleAuth.lastSyncStatus = "error";
d.googleAuth.lastSyncError = `listCalendars: ${e?.message || String(e)}`;
});
return { ok: false, eventsUpdated: 0, deleted: 0, error: `listCalendars: ${e?.message}` };
}
}
let syncToken = cfg.googleAuth.syncToken;
let fetchResult;
try {
if (syncToken) {
fetchResult = await fetchGoogleEvents(token, calendarId, { syncToken });
} else {
const now = Date.now();
fetchResult = await fetchGoogleEvents(token, calendarId, {
timeMin: new Date(now).toISOString(),
timeMax: new Date(now + 90 * 86400_000).toISOString(),
});
}
} catch (e: any) {
if (e?.status === 410) {
// Sync token invalidated — retry with full window
try {
const now = Date.now();
fetchResult = await fetchGoogleEvents(token, calendarId, {
timeMin: new Date(now).toISOString(),
timeMax: new Date(now + 90 * 86400_000).toISOString(),
});
} catch (e2: any) {
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: fetch err", (d) => {
d.googleAuth.lastSyncAt = Date.now();
d.googleAuth.lastSyncStatus = "error";
d.googleAuth.lastSyncError = `fetchEvents(after 410): ${e2?.message || String(e2)}`;
});
return { ok: false, eventsUpdated: 0, deleted: 0, error: e2?.message };
}
} else {
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: fetch err", (d) => {
d.googleAuth.lastSyncAt = Date.now();
d.googleAuth.lastSyncStatus = "error";
d.googleAuth.lastSyncError = `fetchEvents: ${e?.message || String(e)}`;
});
return { ok: false, eventsUpdated: 0, deleted: 0, error: e?.message };
}
}
let updated = 0;
syncServer.changeDoc<ScheduleConfigDoc>(cfgId, "gcal sync: apply", (d) => {
for (const ev of fetchResult.events) {
const cached = toCached(ev, calendarId);
if (!cached) continue;
d.googleEvents[cached.id] = cached;
updated++;
}
for (const delId of fetchResult.deleted) {
delete d.googleEvents[delId];
}
// Trim past + oversized cache
const now = Date.now();
const keys = Object.keys(d.googleEvents);
if (keys.length > MAX_GCAL_EVENTS_CACHED) {
const sorted = keys
.map((k) => ({ k, t: d.googleEvents[k].endTime }))
.sort((a, b) => a.t - b.t);
const toDrop = sorted.slice(0, keys.length - MAX_GCAL_EVENTS_CACHED);
for (const { k } of toDrop) delete d.googleEvents[k];
}
// Drop cancelled events older than a day
for (const [k, v] of Object.entries(d.googleEvents)) {
if (v.endTime < now - 86400_000) delete d.googleEvents[k];
}
d.googleAuth.syncToken = fetchResult.nextSyncToken;
d.googleAuth.lastSyncAt = Date.now();
d.googleAuth.lastSyncStatus = "ok";
d.googleAuth.lastSyncError = null;
d.googleAuth.connected = true;
});
return { ok: true, eventsUpdated: updated, deleted: fetchResult.deleted.length };
}
// ── Create booking event on Google Calendar ──
export async function createGcalBookingEvent(
space: string,
syncServer: SyncServer,
booking: Booking,
opts: { hostEmail?: string; attendeeEmails?: string[] } = {},
): Promise<{ ok: true; googleEventId: string } | { ok: false; error: string }> {
const token = await getValidGoogleToken(space, syncServer).catch(() => null);
if (!token) return { ok: false, error: "Not connected to Google" };
const cfg = syncServer.getDoc<ScheduleConfigDoc>(scheduleConfigDocId(space));
const calendarId = cfg?.googleAuth.calendarIds[0] || "primary";
const attendees: Array<{ email: string; displayName?: string }> = [];
if (booking.guestEmail) attendees.push({ email: booking.guestEmail, displayName: booking.guestName });
for (const email of opts.attendeeEmails || []) attendees.push({ email });
const input: GoogleEventInput = {
summary: `${booking.guestName}${booking.host.label || booking.host.id}`,
description: booking.guestNote
? `${booking.guestNote}\n\n—\nBooked via rSchedule.`
: "Booked via rSchedule.",
start: { dateTime: new Date(booking.startTime).toISOString(), timeZone: booking.timezone },
end: { dateTime: new Date(booking.endTime).toISOString(), timeZone: booking.timezone },
};
try {
// Note: our thin createGoogleEvent doesn't currently pass attendees +
// conference request. That's fine for Phase D: event lands on host's
// calendar and guests get invite via the confirmation email (Phase F).
const id = await createGoogleEvent(token, calendarId, input);
return { ok: true, googleEventId: id };
} catch (e: any) {
return { ok: false, error: e?.message || String(e) };
}
}
// ── Delete booking event on Google Calendar ──
export async function deleteGcalBookingEvent(
space: string,
syncServer: SyncServer,
googleEventId: string,
): Promise<{ ok: boolean; error?: string }> {
const token = await getValidGoogleToken(space, syncServer).catch(() => null);
if (!token) return { ok: false, error: "Not connected to Google" };
const cfg = syncServer.getDoc<ScheduleConfigDoc>(scheduleConfigDocId(space));
const calendarId = cfg?.googleAuth.calendarIds[0] || "primary";
try {
const res = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok && res.status !== 410 && res.status !== 404) {
return { ok: false, error: `delete ${res.status}` };
}
return { ok: true };
} catch (e: any) {
return { ok: false, error: e?.message || String(e) };
}
}

View File

@ -41,6 +41,9 @@ import {
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;
@ -256,6 +259,33 @@ routes.post("/api/bookings", async (c) => {
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
@ -300,6 +330,58 @@ routes.post("/api/bookings/:id/cancel", async (c) => {
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 });
});
@ -444,12 +526,89 @@ routes.get("/api/invitations", (c) => {
});
});
// 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") || "");
const doc = ensureConfigDoc(space);
return c.json(doc.googleAuth);
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 ──
@ -471,6 +630,7 @@ export const scheduleModule: RSpaceModule = {
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 },