diff --git a/backend/functions/src/calendar/index.ts b/backend/functions/src/calendar/index.ts new file mode 100644 index 0000000..9708e1a --- /dev/null +++ b/backend/functions/src/calendar/index.ts @@ -0,0 +1,267 @@ +import * as admin from "firebase-admin"; +import {onRequest} from "firebase-functions/v2/https"; + +// admin may already be initialized by another module in this codebase. +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +const CALENDAR_TIMEZONE = "America/New_York"; + +// Canonical US Eastern VTIMEZONE. Emitting wall-clock times with a TZID +// reference lets calendar clients resolve DST themselves, so we never do +// offset math on the stored times. +const VTIMEZONE = [ + "BEGIN:VTIMEZONE", + `TZID:${CALENDAR_TIMEZONE}`, + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:-0500", + "TZOFFSETTO:-0400", + "TZNAME:EDT", + "DTSTART:19700308T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:-0400", + "TZOFFSETTO:-0500", + "TZNAME:EST", + "DTSTART:19701101T020000", + "RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU", + "END:STANDARD", + "END:VTIMEZONE", +]; + +interface EventDoc { + title?: string; + date?: string; // "YYYY-MM-DD" + startTime?: string; // "HH:MM" + endTime?: string; // "HH:MM" + description?: string; + tags?: string[]; + location?: string; + isVirtual?: boolean; + createdAt?: admin.firestore.Timestamp; + updatedAt?: admin.firestore.Timestamp; +} + +// Escape a value for use in an ICS TEXT field (RFC 5545 §3.3.11). +function escapeText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r\n|\n|\r/g, "\\n"); +} + +// Zero-pad to two digits. +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +// Build a floating "basic format" local timestamp (YYYYMMDDTHHMMSS) from the +// stored date/time strings, optionally shifted by a number of minutes. +// Components are treated as UTC purely for arithmetic so the host server's +// timezone never affects the result. +function localStamp( + dateStr: string, + timeStr: string, + addMinutes = 0, +): string | null { + const dateParts = dateStr.split("-").map(Number); + const timeParts = timeStr.split(":").map(Number); + if (dateParts.length !== 3 || timeParts.length < 2) return null; + const [y, mo, d] = dateParts; + const [h, mi] = timeParts; + if ([y, mo, d, h, mi].some((v) => Number.isNaN(v))) return null; + const dt = new Date(Date.UTC(y, mo - 1, d, h, mi)); + if (addMinutes) dt.setUTCMinutes(dt.getUTCMinutes() + addMinutes); + return ( + `${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}` + + `T${pad(dt.getUTCHours())}${pad(dt.getUTCMinutes())}00` + ); +} + +// All-day DATE value (YYYYMMDD), optionally shifted by whole days. +function dateOnly(dateStr: string, addDays = 0): string | null { + const dateParts = dateStr.split("-").map(Number); + if (dateParts.length !== 3) return null; + const [y, mo, d] = dateParts; + if ([y, mo, d].some((v) => Number.isNaN(v))) return null; + const dt = new Date(Date.UTC(y, mo - 1, d)); + if (addDays) dt.setUTCDate(dt.getUTCDate() + addDays); + return `${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}`; +} + +// Current UTC timestamp as an ICS UTC value (YYYYMMDDTHHMMSSZ). +function utcStamp(date: Date): string { + return ( + `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` + ); +} + +// Fold a content line to <=75 octets per RFC 5545 §3.1, splitting on byte +// boundaries so multi-byte UTF-8 characters are never broken. +function foldLine(line: string): string { + const bytes = Buffer.from(line, "utf8"); + if (bytes.length <= 75) return line; + const chunks: Buffer[] = []; + let start = 0; + // First line: 75 bytes. Continuation lines start with a space, so they hold + // 74 bytes of content. + let limit = 75; + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + // Don't split in the middle of a multi-byte sequence (continuation bytes + // are 0b10xxxxxx). + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + chunks.push(bytes.subarray(start, end)); + start = end; + limit = 74; + } + return chunks + .map((c, i) => (i === 0 ? "" : " ") + c.toString("utf8")) + .join("\r\n"); +} + +// True only for a well-formed http(s) URL, so we never emit a bogus join link. +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + +function buildEvent( + id: string, + ev: EventDoc, + dtstamp: string, + projectId: string, +): string[] { + const lines: string[] = ["BEGIN:VEVENT"]; + lines.push(`UID:${id}@${projectId}`); + lines.push(`DTSTAMP:${dtstamp}`); + + const date = ev.date ?? ""; + const start = (ev.startTime ?? "").trim(); + const end = (ev.endTime ?? "").trim(); + + if (start) { + const dtStart = localStamp(date, start); + if (dtStart) lines.push(`DTSTART;TZID=${CALENDAR_TIMEZONE}:${dtStart}`); + let dtEnd = end ? localStamp(date, end) : null; + // Default to a one-hour block when no end time is provided. + if (!dtEnd) dtEnd = localStamp(date, start, 60); + if (dtEnd) lines.push(`DTEND;TZID=${CALENDAR_TIMEZONE}:${dtEnd}`); + } else { + // All-day event. DTEND is exclusive, so it points at the next day. + const dtStart = dateOnly(date); + const dtEnd = dateOnly(date, 1); + if (dtStart) lines.push(`DTSTART;VALUE=DATE:${dtStart}`); + if (dtEnd) lines.push(`DTEND;VALUE=DATE:${dtEnd}`); + } + + if (ev.title) lines.push(`SUMMARY:${escapeText(ev.title)}`); + + const location = (ev.location ?? "").trim(); + const format = ev.isVirtual ? "Virtual" : "In Person"; + // Only treat the location as a join link when the event is virtual and the + // location is actually a URL — otherwise it's a physical address. + const joinUrl = ev.isVirtual && isHttpUrl(location) ? location : null; + + // Structured LOCATION field: shown only when a location is provided. + if (location) lines.push(`LOCATION:${escapeText(location)}`); + + // Conference join link so calendar apps render a "Join" button. CONFERENCE + // is RFC 7986; URL is added for older clients that don't support it. The + // value is a URI, so it is not TEXT-escaped. + if (joinUrl) { + lines.push(`CONFERENCE;VALUE=URI;FEATURE=VIDEO;LABEL=Join:${joinUrl}`); + lines.push(`URL:${joinUrl}`); + } + + // DESCRIPTION always carries the format (and location, if any) so the core + // details survive even if a client drops the structured props above. + const metaLines = [`Format: ${format}`]; + if (location) metaLines.push(`Location: ${location}`); + if (ev.tags && ev.tags.length > 0) { + metaLines.push(`Tags: ${ev.tags.join(", ")}`); + } + const descParts: string[] = []; + if (ev.description) descParts.push(ev.description); + descParts.push(metaLines.join("\n")); + lines.push(`DESCRIPTION:${escapeText(descParts.join("\n\n"))}`); + + if (ev.updatedAt) { + lines.push(`LAST-MODIFIED:${utcStamp(ev.updatedAt.toDate())}`); + } + + lines.push("END:VEVENT"); + return lines; +} + +// Build the full VCALENDAR document from a list of events. Pure and +// side-effect free so it can be unit tested without Firestore. +export function buildCalendar( + events: Array, + projectId: string, + now: Date = new Date(), +): string { + const dtstamp = utcStamp(now); + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//CancerLINC//Events Calendar//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:CancerLINC Events", + `X-WR-TIMEZONE:${CALENDAR_TIMEZONE}`, + ...VTIMEZONE, + ]; + + for (const ev of events) { + if (!ev.date) continue; // an event with no date cannot be scheduled + lines.push(...buildEvent(ev.id, ev, dtstamp, projectId)); + } + + lines.push("END:VCALENDAR"); + return lines.map(foldLine).join("\r\n") + "\r\n"; +} + +// Public HTTPS endpoint that serves the CancerLINC events calendar as a live +// iCalendar (ICS) feed. Calendar clients (Google, Apple, WordPress plugins) +// subscribe to this URL and re-poll it for updates. +export const calendarIcs = onRequest( + {invoker: "public", cors: true}, + async (req, res) => { + try { + const projectId = + process.env.GCLOUD_PROJECT ?? process.env.GCP_PROJECT ?? "cancerlinc"; + const snapshot = await admin + .firestore() + .collection("events") + .orderBy("date") + .get(); + + const events = snapshot.docs.map((doc) => ({ + id: doc.id, + ...(doc.data() as EventDoc), + })); + const body = buildCalendar(events, projectId); + + res.set("Content-Type", "text/calendar; charset=utf-8"); + res.set( + "Content-Disposition", + "inline; filename=\"cancerlinc-events.ics\"", + ); + // Let subscribers and any CDN cache briefly; the feed stays effectively live. + res.set("Cache-Control", "public, max-age=300"); + res.status(200).send(body); + } catch (err) { + console.error("Failed to build ICS feed", err); + res.status(500).send("Failed to build calendar feed"); + } + }, +); diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts index 9b9bf7b..ce20ad8 100644 --- a/backend/functions/src/index.ts +++ b/backend/functions/src/index.ts @@ -1,19 +1,20 @@ export { + deactivateStaleChats, onAuthUserCreated, onMessageCreated, - deactivateStaleChats, sendMessageNotification, } from "./shared"; export { createUserChat, - sendChatMessage, sendChatImageMessage, + sendChatMessage, } from "./patient"; export { createStaffAccount, - setStaffDisabled, deleteStaffAccount, + setStaffDisabled, } from "./admin"; +export { calendarIcs } from "./calendar"; diff --git a/web/app/firebase.js b/web/app/firebase.js deleted file mode 100644 index 82077f8..0000000 --- a/web/app/firebase.js +++ /dev/null @@ -1,19 +0,0 @@ -import { initializeApp } from "firebase/app"; -import { getFirestore } from "firebase/firestore"; -import { getAuth } from "firebase/auth"; -import { getStorage } from "firebase/storage"; - -const firebaseConfig = { - apiKey: import.meta.env.VITE_FIREBASE_API_KEY, - authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, - projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, - storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, - messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, - appId: import.meta.env.VITE_FIREBASE_APP_ID, - measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, -}; - -const app = initializeApp(firebaseConfig); -export const db = getFirestore(app); -export const auth = getAuth(app); -export const storage = getStorage(app); diff --git a/web/app/hooks/useReferrals.ts b/web/app/hooks/useReferrals.ts index 4ef488d..b594a80 100644 --- a/web/app/hooks/useReferrals.ts +++ b/web/app/hooks/useReferrals.ts @@ -11,7 +11,7 @@ import { addDoc, Timestamp, } from "firebase/firestore"; -import { db } from "~/firebase"; +import { db } from "~/services/firebase_app"; import type { Referral, ReferralWithProvider } from "~/types/referral"; import type { User } from "~/types/user"; diff --git a/web/app/routes/calendar.tsx b/web/app/routes/calendar.tsx index 95bba1b..6754942 100644 --- a/web/app/routes/calendar.tsx +++ b/web/app/routes/calendar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -9,6 +9,10 @@ import { Calendar, MapPin, Video, + Download, + Copy, + Check, + ChevronDown, } from "lucide-react"; import { collection, @@ -21,7 +25,13 @@ import { query, orderBy, } from "firebase/firestore"; -import { db } from "~/firebase"; +import { db } from "~/services/firebase_app"; + +// Public ICS feed served by the `calendarIcs` Cloud Function. Calendar apps +// (Google, Apple, WordPress) can subscribe to this URL for a live feed. +const ICS_FEED_URL = `https://us-central1-${ + import.meta.env.VITE_FIREBASE_PROJECT_ID +}.cloudfunctions.net/calendarIcs`; // types interface CalendarEvent { @@ -124,6 +134,24 @@ export default function CalendarPage() { const [editTarget, setEditTarget] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [copied, setCopied] = useState(false); + const exportRef = useRef(null); + + // Close the export menu when clicking outside of it. + useEffect(() => { + if (!exportOpen) return; + function onClick(e: MouseEvent) { + if ( + exportRef.current && + !exportRef.current.contains(e.target as Node) + ) { + setExportOpen(false); + } + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [exportOpen]); //Firestore real-time listener useEffect(() => { @@ -245,6 +273,43 @@ export default function CalendarPage() { } } + // Fetch the live ICS feed and save it as a file. We download via a blob so + // the browser honors the .ics filename even though the feed is cross-origin. + async function downloadIcs() { + setExportOpen(false); + try { + const res = await fetch(ICS_FEED_URL); + if (!res.ok) throw new Error(`Feed returned ${res.status}`); + const text = await res.text(); + const url = URL.createObjectURL( + new Blob([text], { type: "text/calendar" }) + ); + const a = document.createElement("a"); + a.href = url; + a.download = "cancerlinc-events.ics"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError( + e instanceof Error + ? `Could not download calendar: ${e.message}` + : "Could not download calendar" + ); + } + } + + async function copyFeedLink() { + try { + await navigator.clipboard.writeText(ICS_FEED_URL); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError("Could not copy link to clipboard"); + } + } + function toggleTag(tag: string) { setForm((f) => ({ ...f, @@ -267,13 +332,63 @@ export default function CalendarPage() { Schedule and manage community events for patients.

- +
+
+ + {exportOpen && ( +
+ + +
+ )} +
+ +
{error && ( @@ -397,13 +512,13 @@ export default function CalendarPage() { className="rounded-lg bg-black p-1.5 text-white hover:bg-gray-800" title="Add event" > - +