From 69ce497aa8761b5e3f3343c73085b44583b2d426 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 18:19:10 -0400 Subject: [PATCH] feat(rschedule): complete native port of schedule-jeffemmett MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/folk-schedule-admin.ts | 630 ++++++++++++++++-- .../components/folk-schedule-booking.ts | 365 +++++++++- modules/rschedule/lib/calendar-links.ts | 102 +++ modules/rschedule/lib/cron.ts | 99 +++ modules/rschedule/lib/emails.ts | 267 ++++++++ modules/rschedule/lib/gcal-sync.ts | 270 ++++++++ modules/rschedule/mod.ts | 164 ++++- 7 files changed, 1808 insertions(+), 89 deletions(-) create mode 100644 modules/rschedule/lib/calendar-links.ts create mode 100644 modules/rschedule/lib/cron.ts create mode 100644 modules/rschedule/lib/emails.ts create mode 100644 modules/rschedule/lib/gcal-sync.ts diff --git a/modules/rschedule/components/folk-schedule-admin.ts b/modules/rschedule/components/folk-schedule-admin.ts index dade76af..33b0c7ca 100644 --- a/modules/rschedule/components/folk-schedule-admin.ts +++ b/modules/rschedule/components/folk-schedule-admin.ts @@ -1,16 +1,67 @@ /** - * — admin dashboard for rSchedule. + * — 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 { 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(path: string, init: RequestInit = {}): Promise { + 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("/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) { + 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) { + 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) { + 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) { + 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 = ` + +
+
+

rSchedule admin · ${escapeHtml(this.space)}

+

Configure your bookable availability, Google Calendar sync, and booking policies.

+
+ ${this.err ? `
${escapeHtml(this.err)}
` : ""} + ${this.info ? `
${escapeHtml(this.info)}
` : ""} + ${this.authOk ? ` + ${this.renderTzBanner()} + +
${this.renderPanel()}
+ ` : `
Sign in with a passkey to continue. Admin pages are restricted to moderators and admins of this space.
`} +
+ `; + this.wire(); } - private paint() { - this.shadow.innerHTML = ` - -
-

rSchedule admin · ${escapeHtml(this.space)}

-

Configure availability, bookings, and Google Calendar for this space.

- ${this.err ? `
${escapeHtml(this.err)}
` : ""} -
- ${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 `
+ Your browser is on ${escapeHtml(this.browserTz)} but this schedule is configured for ${escapeHtml(this.settings.timezone)}. + +
`; + } + return ""; + } + + private renderInvitations(): string { + return ` +

Bookings you're invited to

+

Meetings hosted by other spaces or users where ${escapeHtml(this.space)} is an attendee.

+ ${this.invitations.length === 0 ? `

No invitations.

` : ` + + + + ${this.invitations.map(inv => ` + + + + + + + `).join("")} + +
WhenHostTitleStatusResponse
${escapeHtml(new Date(inv.startTime).toLocaleString())}${escapeHtml(inv.host.label || inv.host.id)}${escapeHtml(inv.title)}${inv.status}${inv.response} + ${inv.response !== "accepted" ? `` : ""} + ${inv.response !== "declined" ? `` : ""} +
+ `} + `; + } + + 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 ` +
+
+

Share your booking link

+

Send this URL to anyone who wants to book time with you.

+ ${location.origin}${base} +
-
- ${escapeHtml(this.tab)}
- Full admin UI arrives in Phase E. For now, this confirms auth gate works. -
+

Next up

+ ${upcoming.length === 0 ? `

No upcoming bookings.

` : ` +
    + ${upcoming.map((b) => `
  • + ${escapeHtml(b.guestName)} + ${escapeHtml(new Date(b.startTime).toLocaleString())} +
  • `).join("")} +
+ `} +
+
+

Google Calendar

+ ${this.gcal?.connected + ? `

Connected as ${escapeHtml(this.gcal.email || "(unknown)")}. Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}.

` + : `

Not connected. Bookings will be created without Google Calendar events.

`} + +
+
+

Availability

+

${this.rules.filter(r => r.isActive).length} active weekly rules, ${this.overrides.length} date overrides.

+
`; - this.shadow.querySelectorAll(".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 ` +

Weekly availability rules

+

Add one or more time windows per weekday. Leave a day empty to block it entirely.

+ + + + ${this.rules.length === 0 ? `` : this.rules + .slice() + .sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.startTime.localeCompare(b.startTime)) + .map(r => ` + + + + + + `).join("")} + +
DayStartEndActive
No rules yet. Add one below.
${DOW[r.dayOfWeek]}
+
+ + + + +
+ +

Date overrides

+

Block specific dates or set custom hours for a single day.

+ + + + ${this.overrides.length === 0 ? `` : this.overrides + .slice() + .sort((a, b) => a.date.localeCompare(b.date)) + .map(o => ` + + + + + + `).join("")} + +
DateBlocked?Custom windowReason
No overrides.
${o.date}${o.isBlocked ? "✓ blocked" : "custom"}${o.isBlocked ? "—" : `${o.startTime || "?"} – ${o.endTime || "?"}`}${escapeHtml(o.reason || "")}
+
+ + + + + + +
+ `; + } + + private renderBookings(): string { + return ` +

All bookings

+ ${this.bookings.length === 0 ? `

No bookings yet.

` : ` + + + + ${this.bookings.map(b => ` + + + + + + `).join("")} + +
WhenGuestEmailStatusAttendees
${escapeHtml(new Date(b.startTime).toLocaleString())}${escapeHtml(b.guestName)}${escapeHtml(b.guestEmail)}${b.status}${b.attendeeCount}
+ `} + `; + } + + 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 ` +

Google Calendar

+ ${this.gcal?.connected ? ` +

Connected to Google as ${escapeHtml(this.gcal.email || "(unknown)")}.

+

Calendar ID: ${escapeHtml(this.gcal.calendarIds[0] || "primary")}

+

Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"} + ${this.gcal.lastSyncStatus ? ` · ${this.gcal.lastSyncStatus}` : ""}

+ ${this.gcal.lastSyncError ? `

${escapeHtml(this.gcal.lastSyncError)}

` : ""} +
+ + +
+

Tokens are shared across rSpace modules and live in the encrypted connections doc. Fully revoke the OAuth grant via /api/oauth/google/disconnect?space=${escapeHtml(this.space)}.

+ ` : ` +

Connecting Google Calendar blocks bookings when you're busy on gcal and creates a calendar event for every confirmed booking.

+ Connect Google Calendar + `} + `; + } + + private renderSettings(): string { + const s = this.settings; + if (!s) return `

Loading…

`; + return ` +

Booking policies

+
+ + + + + + + + + + +
+ `; + } + + private tabBtn(id: Tab, label: string): string { return ``; } + + private wire() { + const $ = (id: string) => this.shadow.getElementById(id); + this.shadow.querySelectorAll("[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("tr[data-rule-id]").forEach((row) => { + const id = row.dataset.ruleId!; + const start = row.querySelector(".rule-start"); + const end = row.querySelector(".rule-end"); + const active = row.querySelector(".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(".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("tr[data-ov-id]").forEach((row) => { + const id = row.dataset.ovId!; + row.querySelector(".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("tr[data-inv-id]").forEach((row) => { + const id = row.dataset.invId!; + row.querySelector(".inv-accept")?.addEventListener("click", () => void this.respondToInvitation(id, "accepted")); + row.querySelector(".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 = { + 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, ">") @@ -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 {}; diff --git a/modules/rschedule/components/folk-schedule-booking.ts b/modules/rschedule/components/folk-schedule-booking.ts index dfdb18c5..32a5d7b0 100644 --- a/modules/rschedule/components/folk-schedule-booking.ts +++ b/modules/rschedule/components/folk-schedule-booking.ts @@ -1,10 +1,11 @@ /** * — 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 = {}; + 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 = {}; + 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 = ` - +
-
+

Book with ${escapeHtml(name)}

+

${escapeHtml(msg)}

⏱ ${duration} min - ${s?.timezone ? `🌐 ${escapeHtml(s.timezone)}` : ""} + 🌐
-

${escapeHtml(msg)}

-
- Date picker and slot list coming in Phase B.
- Admin: configure availability +
+ + ${this.confirmation ? this.renderConfirmation() : this.selectedSlot ? this.renderForm() : this.renderCalendar()} + ${this.err ? `
${escapeHtml(this.err)}
` : ""} +
+ `; + 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(`
`); + 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(``); + } + + const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : []; + + return ` +
+
+
+ + ${escapeHtml(monthLabel)} +
+
+ ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `
${d}
`).join("")} +
+
${cells.join("")}
+ ${this.loadingAvailability ? `

Loading availability…

` : ""} +
+
+ ${this.selectedDate ? ` +

${escapeHtml(new Date(this.selectedDate + "T12:00:00").toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }))}

+ ${slots.length === 0 ? `

No times available.

` : ` +
+ ${slots.map(sl => ``).join("")} +
+ `} + ` : `

Pick a date with availability.

`}
`; } + + private renderForm(): string { + const sl = this.selectedSlot!; + const dateLabel = new Date(sl.startMs).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); + return ` +
+
+ 📅 ${escapeHtml(dateLabel)} · ⏰ ${escapeHtml(sl.startDisplay)} – ${escapeHtml(sl.endDisplay)} + +
+ + + + +
+ `; + } + + private renderConfirmation(): string { + return ` +
+
+

You're booked!

+

A confirmation email is on the way. You'll also get a calendar invite.

+

Booking id: ${escapeHtml(this.confirmation!.id)}

+ +
+ `; + } + + 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(".cell.day").forEach((btn) => { + btn.addEventListener("click", () => { + this.selectedDate = btn.dataset.date || null; + this.render(); + }); + }); + + // Slot click + this.shadow.querySelectorAll(".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) => ``).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 {}; diff --git a/modules/rschedule/lib/calendar-links.ts b/modules/rschedule/lib/calendar-links.ts new file mode 100644 index 00000000..d2e134b9 --- /dev/null +++ b/modules/rschedule/lib/calendar-links.ts @@ -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"); +} diff --git a/modules/rschedule/lib/cron.ts b/modules/rschedule/lib/cron.ts new file mode 100644 index 00000000..8effab03 --- /dev/null +++ b/modules/rschedule/lib/cron.ts @@ -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 | null = null; +let _gcalTimer: ReturnType | null = null; + +/** Build "https://" 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 { + 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(docId); + if (!doc) continue; + const cfg = syncServer.getDoc(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(docId, `reminder sent ${b.id}`, (d) => { + if (d.bookings[b.id]) d.bookings[b.id].reminderSentAt = Date.now(); + }); + } + } + } +} + +async function gcalSweep(syncServer: SyncServer): Promise { + for (const docId of syncServer.listDocs()) { + if (!docId.endsWith(":schedule:config")) continue; + const space = docId.split(":")[0]; + const cfg = syncServer.getDoc(docId); + if (!cfg?.googleAuth?.connected) continue; + await syncGcalBusy(space, syncServer).catch(() => { /* per-space failures logged inside */ }); + } +} diff --git a/modules/rschedule/lib/emails.ts b/modules/rschedule/lib/emails.ts new file mode 100644 index 00000000..c3f93a2f --- /dev/null +++ b/modules/rschedule/lib/emails.ts @@ -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 "; + +// ── 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, "'"); +} + +// ── 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 + ? `

Join Meeting

` + : ""; + const noteBlock = booking.guestNote ? `

Your note: ${escapeHtml(booking.guestNote)}

` : ""; + const attendeesBlock = attendees.length > 1 ? `

Attendees: ${attendees.map(escapeHtml).join(", ")}

` : ""; + + 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 = ` +

Add to your calendar:

+

+ Google + Outlook + Yahoo +

+

An .ics file is attached for Apple Calendar and other apps.

`; + + const guestHtml = ` +

Meeting Confirmed

+

Hi ${escapeHtml(booking.guestName)},

+

Your meeting with ${escapeHtml(hostDisplayName)} is confirmed:

+
    +
  • Date: ${escapeHtml(dateStr)}
  • +
  • Time: ${escapeHtml(startStr)} – ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})
  • +
+ ${meetLink} + ${attendeesBlock} + ${calendarButtons} + ${noteBlock} +

Cancel or reschedule

`; + + const hostHtml = ` +

New Booking

+

${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)}) booked a meeting with you.

+
    +
  • Date: ${escapeHtml(dateStr)}
  • +
  • Time: ${escapeHtml(startStr)} – ${escapeHtml(endStr)} (${escapeHtml(booking.timezone)})
  • + ${attendees.length > 1 ? `
  • Attendees: ${attendees.map(escapeHtml).join(", ")}
  • ` : ""} +
+ ${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 ? `

Reason: ${escapeHtml(booking.cancellationReason)}

` : ""; + const suggestBlock = suggestedSlots.length ? ` +

Here are a few open times if you'd like to rebook:

+ +

Or pick a different time.

` : `

You can pick a new time any time.

`; + + const html = ` +

Meeting cancelled

+

Hi ${escapeHtml(booking.guestName)},

+

Your meeting with ${escapeHtml(hostDisplayName)} on ${escapeHtml(dateStr)} at ${escapeHtml(startStr)} has been cancelled.

+ ${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: `

Booking cancelled

${escapeHtml(booking.guestName)} (${escapeHtml(booking.guestEmail)})

${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 ? `

Join Meeting

` : ""; + + const html = ` +

Reminder: meeting tomorrow

+

Hi ${escapeHtml(booking.guestName)}, your meeting with ${escapeHtml(hostDisplayName)} is coming up.

+
    +
  • ${escapeHtml(dateStr)} at ${escapeHtml(startStr)} (${escapeHtml(booking.timezone)})
  • +
+ ${meetLink} +

Need to cancel? Use this link.

`; + + 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: `

Your meeting with ${escapeHtml(booking.guestName)} is tomorrow at ${escapeHtml(startStr)}.

`, + }); + } + return { ok: true }; + } catch (e: any) { + console.error("[rSchedule] reminder send error:", e?.message); + return { ok: false, error: e?.message || String(e) }; + } +} diff --git a/modules/rschedule/lib/gcal-sync.ts b/modules/rschedule/lib/gcal-sync.ts new file mode 100644 index 00000000..fbc0de79 --- /dev/null +++ b/modules/rschedule/lib/gcal-sync.ts @@ -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(connectionsDocId(space)); + const cfg = syncServer.getDoc(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(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(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(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(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(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(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(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(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(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) }; + } +} diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 69a71bca..6bb0d913 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -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(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(); + 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(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(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(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(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(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 },