diff --git a/apps/api/src/__tests__/calendar.test.ts b/apps/api/src/__tests__/calendar.test.ts new file mode 100644 index 0000000..7287d88 --- /dev/null +++ b/apps/api/src/__tests__/calendar.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { generateIcalToken } from "../routes/calendar.js"; + +describe("generateIcalToken", () => { + it("generates a 64-character hex token", () => { + const token = generateIcalToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it("generates unique tokens", () => { + const token1 = generateIcalToken(); + const token2 = generateIcalToken(); + expect(token1).not.toBe(token2); + }); +}); \ No newline at end of file diff --git a/apps/api/src/__tests__/petPhotos.test.ts b/apps/api/src/__tests__/petPhotos.test.ts index 84a930c..b4d2d6b 100644 --- a/apps/api/src/__tests__/petPhotos.test.ts +++ b/apps/api/src/__tests__/petPhotos.test.ts @@ -11,6 +11,7 @@ const MANAGER: StaffRow = { name: "Manager McManager", email: "manager@example.com", active: true, + icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index c27db51..b052507 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -12,6 +12,7 @@ const MANAGER: StaffRow = { name: "Manager McManager", email: "manager@example.com", active: true, + icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/web/src/components/CalendarSync.tsx b/apps/web/src/components/CalendarSync.tsx new file mode 100644 index 0000000..887d05e --- /dev/null +++ b/apps/web/src/components/CalendarSync.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from "react"; +import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react"; + +interface Props { + staffId: string; + staffName: string; +} + +export function CalendarSyncSection({ staffId, staffName }: Props) { + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetchToken(); + }, [staffId]); + + async function fetchToken() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}`); + if (!res.ok) throw new Error("Failed to fetch staff data"); + const data = await res.json(); + setToken(data.icalToken || null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + } + + async function generateToken() { + setActionLoading("generate"); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "POST" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to generate token"); + } + const data = await res.json(); + setToken(data.icalToken); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to generate token"); + } finally { + setActionLoading(null); + } + } + + async function revokeToken() { + if (!confirm("Revoke your calendar feed link? Anyone with the current link will lose access.")) { + return; + } + setActionLoading("revoke"); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to revoke token"); + } + setToken(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to revoke token"); + } finally { + setActionLoading(null); + } + } + + async function copyFeedUrl() { + if (!token) return; + const url = `${window.location.origin}/api/calendar/${staffId}.ics?token=${token}`; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const feedUrl = token ? `/api/calendar/${staffId}.ics?token=${token}` : null; + + return ( +
+
+ +

Calendar Sync

+
+ +

+ Generate a calendar feed link to share your upcoming appointments with any calendar app that supports iCal (Apple Calendar, Google Calendar, Outlook). +

+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading...
+ ) : token ? ( +
+
+ +
+ + +
+
+ +
+ + +
+ +

+ Regenerating will create a new URL and invalidate the old one. +

+
+ ) : ( +
+

You don't have a calendar feed set up yet.

+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2539f7d --- /dev/null +++ b/opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow", + "experimental": { + "snapshots": false + } +}