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:
parent
700e372260
commit
69ce497aa8
|
|
@ -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();
|
||||
this.shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="wrap">
|
||||
<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>` : ""}
|
||||
${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.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 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>
|
||||
<div class="wrap">
|
||||
<h1>rSchedule admin · ${escapeHtml(this.space)}</h1>
|
||||
<p class="sub">Configure availability, bookings, and Google Calendar for this space.</p>
|
||||
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
|
||||
<div class="tabs">
|
||||
${this.tabBtn("overview", "Overview")}
|
||||
${this.tabBtn("availability", "Availability")}
|
||||
${this.tabBtn("bookings", "Bookings")}
|
||||
${this.tabBtn("calendar", "Google Calendar")}
|
||||
${this.tabBtn("settings", "Settings")}
|
||||
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.
|
||||
</div>
|
||||
<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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
|
|
@ -101,6 +569,58 @@ function escapeHtml(s: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 {};
|
||||
|
|
|
|||
|
|
@ -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…</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)} · ⏰ ${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, "'");
|
||||
}
|
||||
|
||||
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 {};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 */ });
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── 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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue