diff --git a/.github/workflows/app.yaml b/.github/workflows/app.yaml index 0c158bf..c89fefc 100644 --- a/.github/workflows/app.yaml +++ b/.github/workflows/app.yaml @@ -6,6 +6,9 @@ on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + jobs: test: name: ๐Ÿงช Tests @@ -31,5 +34,5 @@ jobs: working-directory: app run: bun run typecheck - name: ๐Ÿ„ Run the tests - run: echo "No tests configured yet. Add tests as needed." - + working-directory: app + run: bun run test diff --git a/app/bun.lock b/app/bun.lock index 79e13f0..6beeaf2 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "app", @@ -8,6 +9,7 @@ "@aws-sdk/s3-request-presigner": "^3.888.0", "@chat-adapter/discord": "^4.15.0", "@chat-adapter/state-redis": "^4.15.0", + "@date-fns/tz": "^1.5.0", "@hookform/resolvers": "^5.2.2", "@neondatabase/serverless": "^1.0.1", "@radix-ui/react-accordion": "^1.2.12", @@ -55,6 +57,7 @@ "drizzle-orm": "^0.44.5", "embla-carousel-react": "^8.6.0", "heic-convert": "^2.1.0", + "ical.js": "^2.2.1", "input-otp": "^1.4.2", "lucide-react": "^0.544.0", "next": "^16.0.10", @@ -78,8 +81,10 @@ "zod": "^4.1.8", }, "devDependencies": { + "@electric-sql/pglite": "^0.5.8", "@modelcontextprotocol/sdk": "^1.18.0", "@tailwindcss/postcss": "^4", + "@types/bun": "^1.4.2", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/qrcode": "^1.5.5", @@ -259,7 +264,7 @@ "@chat-adapter/state-redis": ["@chat-adapter/state-redis@4.15.0", "", { "dependencies": { "chat": "4.15.0", "redis": "^4.7.0" } }, "sha512-156CGZCkMXzsnMC20nIpNkAGScbasOHxR9YcPMuLm2IwSC1L/Kyea7o9TEv2ldvk63q/e+AaU+98T7T5/2+mvg=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], @@ -277,6 +282,8 @@ "@electric-sql/client": ["@electric-sql/client@1.0.9", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-9fLjGww+5mGBJ9o47sizCSkyLixjoA49IRh8jBVxzpDq4EGwv77k54df0hRYWtD4VoBQL+49MyT9KDfE/a5Ygw=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], + "@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -1031,6 +1038,8 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -1295,6 +1304,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1703,6 +1714,8 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="], + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -2781,6 +2794,8 @@ "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bun-types/@types/node": ["@types/node@22.18.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw=="], + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], @@ -2841,6 +2856,8 @@ "qrcode/pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "react-day-picker/@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "react-router/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "rimraf/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], diff --git a/app/package.json b/app/package.json index d2cbf77..6be4365 100644 --- a/app/package.json +++ b/app/package.json @@ -17,13 +17,15 @@ "typecheck": "tsc --noEmit", "typecheck:scripts": "tsc --noEmit --project scripts/tsconfig.json", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "db:migrate": "drizzle-kit migrate", + "test": "bun test" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", "@aws-sdk/s3-request-presigner": "^3.888.0", "@chat-adapter/discord": "^4.15.0", "@chat-adapter/state-redis": "^4.15.0", + "@date-fns/tz": "^1.5.0", "@hookform/resolvers": "^5.2.2", "@neondatabase/serverless": "^1.0.1", "@radix-ui/react-accordion": "^1.2.12", @@ -71,6 +73,7 @@ "drizzle-orm": "^0.44.5", "embla-carousel-react": "^8.6.0", "heic-convert": "^2.1.0", + "ical.js": "^2.2.1", "input-otp": "^1.4.2", "lucide-react": "^0.544.0", "next": "^16.0.10", @@ -94,8 +97,10 @@ "zod": "^4.1.8" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.8", "@modelcontextprotocol/sdk": "^1.18.0", "@tailwindcss/postcss": "^4", + "@types/bun": "^1.4.2", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/qrcode": "^1.5.5", diff --git a/app/src/app/[slug]/page.tsx b/app/src/app/[slug]/page.tsx index 9929b9a..3f6d27d 100644 --- a/app/src/app/[slug]/page.tsx +++ b/app/src/app/[slug]/page.tsx @@ -24,7 +24,7 @@ export async function generateMetadata({ const { slug } = await params; const event = await getExpandedEventBySlug(slug); - if (!event) { + if (!event || event.isDraft) { return { title: "Event not found", }; @@ -67,16 +67,17 @@ export default async function EventPage({ params }: PageProps) { const { slug } = await params; const event = await getExpandedEventBySlug(slug); - if (!event) { + if (!event || event.isDraft) { notFound(); } // Get real attendee count from Luma API - const attendeeCount = event.lumaEventId - ? await getEventAttendeeCount(event.lumaEventId) - : 0; + const attendeeCount = + event.lumaEventId && event.attendeeLimit > 0 + ? await getEventAttendeeCount(event.lumaEventId) + : 0; const attendeeLimit = event.attendeeLimit; - const isAtCapacity = attendeeCount >= attendeeLimit; + const isAtCapacity = attendeeLimit > 0 && attendeeCount >= attendeeLimit; const isInPast = isEventInPast(event); return ( diff --git a/app/src/app/api/cron/luma-sync/route.ts b/app/src/app/api/cron/luma-sync/route.ts index 74fe675..91b983a 100644 --- a/app/src/app/api/cron/luma-sync/route.ts +++ b/app/src/app/api/cron/luma-sync/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from "next/server"; -import { start } from "workflow/api"; +import { revalidatePath } from "next/cache"; +import { captureException } from "@sentry/nextjs"; import { mainConfig } from "@/lib/config"; -import { syncLumaEventsWorkflow } from "@/workflows/luma-sync"; +import { db } from "@/lib/db"; +import { syncPublicLumaEvents } from "@/lib/luma/sync"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const DEFAULT_LIMIT = 10; -const DEFAULT_CALENDAR_HANDLE = "allthingswebcalendar"; +export const maxDuration = 60; function isAuthorized(request: Request, cronSecret: string): boolean { return request.headers.get("authorization") === `Bearer ${cronSecret}`; @@ -31,24 +32,28 @@ export async function GET(request: Request) { } try { - const calendarApiId = mainConfig.luma.calendarApiId; - const calendarHandle = - mainConfig.luma.calendarHandle ?? DEFAULT_CALENDAR_HANDLE; - - const run = await start(syncLumaEventsWorkflow, [ - { - limit: DEFAULT_LIMIT, - calendarApiId, - calendarHandle, - }, - ]); + const { slugs, ...result } = await syncPublicLumaEvents( + db, + mainConfig.luma.calendarApiId, + ); + for (const path of [ + "/", + "/api/v1/events", + "/rss", + "/sitemap.xml", + ...slugs.map((slug) => `/${slug}`), + ]) { + revalidatePath(path); + } + console.info("Luma calendar sync completed", result); return NextResponse.json({ ok: true, - runId: run.runId, - message: "Luma sync workflow started", + ...result, }); } catch (error) { + captureException(error); + console.error("Luma calendar sync failed", error); const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ ok: false, error: message }, { status: 500 }); diff --git a/app/src/app/api/v1/events/[id]/route.ts b/app/src/app/api/v1/events/[id]/route.ts index 5ff2a46..d2b472b 100644 --- a/app/src/app/api/v1/events/[id]/route.ts +++ b/app/src/app/api/v1/events/[id]/route.ts @@ -17,7 +17,7 @@ export async function GET( try { const event = await getExpandedEventById(id); - if (!event) { + if (!event || event.isDraft) { return NextResponse.json({ error: "Event not found" }, { status: 404 }); } diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index dc7b150..f3366c0 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -210,9 +210,7 @@ export default async function HomePage() { )} - {remainingEvents.length > 0 && ( - - )} +
@@ -294,7 +292,7 @@ function OtherUpcomingEventsSection({ events }: { events: Event[] }) {

- Other events + Events on Luma

Discover more upcoming web development events in the Bay Area here @@ -311,7 +309,7 @@ function OtherUpcomingEventsSection({ events }: { events: Event[] }) {

- + {events.length > 0 && }
); diff --git a/app/src/components/event-details.tsx b/app/src/components/event-details.tsx index 2b68140..e3c0783 100644 --- a/app/src/components/event-details.tsx +++ b/app/src/components/event-details.tsx @@ -213,14 +213,26 @@ export function AllYouNeedToKnowSection({

- {!isInPast && attendeeCount < attendeeLimit - ? "Spots available" - : !isInPast && attendeeCount >= attendeeLimit - ? "At capacity" - : "Event has ended"} + {isInPast + ? "Event has ended" + : attendeeLimit <= 0 + ? "Registration on Luma" + : attendeeCount < attendeeLimit + ? "Spots available" + : "At capacity"}

- {attendeeCount} / {attendeeLimit} guests registered + {attendeeLimit > 0 ? ( + <> + {attendeeCount} / {attendeeLimit} guests registered + + ) : ( + + View event details on Luma + + )}

diff --git a/app/src/lib/luma/public-calendar.ts b/app/src/lib/luma/public-calendar.ts new file mode 100644 index 0000000..019d75a --- /dev/null +++ b/app/src/lib/luma/public-calendar.ts @@ -0,0 +1,126 @@ +import ICAL from "ical.js"; +import { TZDate } from "@date-fns/tz"; + +export const ALL_THINGS_WEB_CALENDAR_ID = "cal-3AAimKnRVQEId4r"; +const CALENDAR_TIMEZONE = "America/Los_Angeles"; + +export type PublicLumaEvent = { + lumaEventId: string; + name: string; + startDate: Date; + endDate: Date; + location: string | null; + isDraft: boolean; +}; + +function toDate(time: ICAL.Time, property: ICAL.Property | null): Date { + const timezone = time.isDate + ? CALENDAR_TIMEZONE + : property?.getParameter("tzid"); + + if (timezone || time.zone.tzid === "floating") { + return new Date( + new TZDate( + time.year, + time.month - 1, + time.day, + time.hour, + time.minute, + time.second, + String(timezone || CALENDAR_TIMEZONE), + ).getTime(), + ); + } + + return time.toJSDate(); +} + +export function parsePublicLumaCalendar(text: string): PublicLumaEvent[] { + if (!text.trim().endsWith("END:VCALENDAR")) { + throw new Error("Luma returned an incomplete calendar"); + } + const calendar = new ICAL.Component(ICAL.parse(text)); + if (calendar.name !== "vcalendar") { + throw new Error("Luma did not return an iCalendar feed"); + } + + const events = new Map< + string, + { event: PublicLumaEvent; sequence: number } + >(); + for (const component of calendar.getAllSubcomponents("vevent")) { + const event = new ICAL.Event(component); + const id = event.uid?.match(/^(evt-[A-Za-z0-9]+)@events\.lu\.ma$/)?.[1]; + if (!id || !event.summary?.trim() || !event.startDate || !event.endDate) { + throw new Error("Luma returned an event missing its ID, title or dates"); + } + if (event.isRecurring() || event.isRecurrenceException()) { + throw new Error(`Luma returned an unexpanded recurring event: ${id}`); + } + + const startDate = toDate( + event.startDate, + component.getFirstProperty("dtstart"), + ); + const endDate = toDate(event.endDate, component.getFirstProperty("dtend")); + if ( + !Number.isFinite(startDate.getTime()) || + !Number.isFinite(endDate.getTime()) || + endDate <= startDate + ) { + throw new Error(`Luma returned invalid dates for ${id}`); + } + + const visibility = String(component.getFirstPropertyValue("class") ?? "") + .trim() + .toUpperCase(); + const status = String(component.getFirstPropertyValue("status") ?? "") + .trim() + .toUpperCase(); + const isDraft = + status === "CANCELLED" || + visibility === "PRIVATE" || + visibility === "CONFIDENTIAL"; + const previous = events.get(id); + if (!previous || event.sequence >= previous.sequence) { + events.set(id, { + event: { + lumaEventId: id, + name: event.summary.trim(), + startDate, + endDate, + location: event.location?.trim() || null, + isDraft, + }, + sequence: event.sequence, + }); + } + } + + if (events.size === 0) { + throw new Error( + "Luma returned an empty calendar; stored events were not changed", + ); + } + return Array.from(events.values(), ({ event }) => event); +} + +export async function fetchPublicLumaEvents( + calendarId = ALL_THINGS_WEB_CALENDAR_ID, +): Promise { + if (!/^cal-[A-Za-z0-9]+$/.test(calendarId)) { + throw new Error("Invalid Luma calendar ID"); + } + const url = new URL("https://api.luma.com/ics/get"); + url.searchParams.set("entity", "calendar"); + url.searchParams.set("id", calendarId); + const response = await fetch(url, { + cache: "no-store", + signal: AbortSignal.timeout(20_000), + headers: { accept: "text/calendar" }, + }); + if (!response.ok) { + throw new Error(`Luma calendar request failed: ${response.status}`); + } + return parsePublicLumaCalendar(await response.text()); +} diff --git a/app/src/lib/luma/sync.ts b/app/src/lib/luma/sync.ts new file mode 100644 index 0000000..5e55a32 --- /dev/null +++ b/app/src/lib/luma/sync.ts @@ -0,0 +1,67 @@ +import { TZDate } from "@date-fns/tz"; +import { sql } from "drizzle-orm"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { eventsTable } from "@/lib/schema"; +import { fetchPublicLumaEvents, type PublicLumaEvent } from "./public-calendar"; + +function eventSlug(event: PublicLumaEvent): string { + const date = new TZDate(event.startDate, "America/Los_Angeles"); + const datePrefix = [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); + const name = event.name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 100); + return `${datePrefix}-${name || "event"}-${event.lumaEventId}`; +} + +export async function syncPublicLumaEvents( + database: Pick, "insert">, + calendarId?: string, +) { + // Validate the complete feed before making a single atomic database write. + const events = await fetchPublicLumaEvents(calendarId); + const rows = await database + .insert(eventsTable) + .values( + events.map((event) => ({ + name: event.name, + startDate: event.startDate, + endDate: event.endDate, + lumaEventId: event.lumaEventId, + isDraft: event.isDraft, + slug: eventSlug(event), + tagline: "See Luma for event details and registration.", + attendeeLimit: 0, + streetAddress: event.location, + shortLocation: event.location?.split(",")[0] ?? null, + fullAddress: event.location, + })), + ) + .onConflictDoUpdate({ + target: eventsTable.lumaEventId, + // Keep the event ID, slug, editorial content and all related records. + set: { + name: sql`excluded.name`, + startDate: sql`excluded.start_date`, + endDate: sql`excluded.end_date`, + isDraft: sql`excluded.is_draft`, + streetAddress: sql`coalesce(excluded.street_address, ${eventsTable.streetAddress})`, + shortLocation: sql`coalesce(excluded.short_location, ${eventsTable.shortLocation})`, + fullAddress: sql`coalesce(excluded.full_address, ${eventsTable.fullAddress})`, + }, + }) + .returning({ slug: eventsTable.slug, isDraft: eventsTable.isDraft }); + + return { + syncedCount: rows.length, + publishedCount: rows.filter((event) => !event.isDraft).length, + slugs: rows.map((event) => event.slug), + }; +} diff --git a/app/src/workflows/luma-sync/index.ts b/app/src/workflows/luma-sync/index.ts deleted file mode 100644 index 7ec3762..0000000 --- a/app/src/workflows/luma-sync/index.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { LumaEvent } from "@/lib/luma"; -import { generateEventDraftWithAI } from "./steps/ai"; -import { - createDiscordReviewThreadForEvent, - postDiscordLumaSyncStatusUpdate, -} from "./steps/discord"; -import { - createDiscordReviewSession, - createEventFromDraft, - getExistingLumaEventIds, - resolveUniqueSlug, -} from "./steps/events"; -import { fetchLatestLumaEvents, getLumaEventId } from "./steps/luma"; -import type { - AISuggestedEvent, - EventDraft, - LumaSyncError, - LumaSyncInput, - LumaSyncResult, -} from "./types"; - -function normalizeSlugPart(input: string): string { - return input - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") - .replace(/-{2,}/g, "-"); -} - -function cleanNullableText(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function sanitizeAttendeeLimit(value: number, fallback: number): number { - if (!Number.isInteger(value) || value < 1) { - return fallback; - } - - return Math.min(value, 50000); -} - -function stripMarkdown(markdown: string): string { - return markdown - .replace(/\[(.*?)\]\((.*?)\)/g, "$1") - .replace(/[>*_`#\-]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function toIsoDate(rawValue: string, fieldName: string): string { - const date = new Date(rawValue); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid ${fieldName}: ${rawValue}`); - } - - return date.toISOString(); -} - -function buildFallbackTagline(event: LumaEvent): string { - const rawDescription = event.description_md ?? event.description ?? ""; - const plainText = stripMarkdown(rawDescription); - - if (plainText.length > 0) { - const sentenceCandidate = plainText.split(/[.!?](?:\s|$)/)[0] ?? plainText; - return sentenceCandidate.slice(0, 180).trim(); - } - - return `${event.name} at All Things Web`; -} - -function deriveEventDraft( - lumaEvent: LumaEvent, - lumaEventId: string, -): EventDraft { - const startDate = toIsoDate(lumaEvent.start_at, "start_at"); - const endDate = toIsoDate(lumaEvent.end_at, "end_at"); - const datePrefix = startDate.slice(0, 10); - - const slugBase = normalizeSlugPart(`${datePrefix}-${lumaEvent.name}`); - const geoAddress = lumaEvent.geo_address_json; - - return { - name: lumaEvent.name.trim(), - startDate, - endDate, - slug: slugBase || `${datePrefix}-event`, - tagline: buildFallbackTagline(lumaEvent), - attendeeLimit: 200, - streetAddress: cleanNullableText(geoAddress?.address), - shortLocation: cleanNullableText( - geoAddress?.city_state ?? geoAddress?.city, - ), - fullAddress: cleanNullableText( - geoAddress?.full_address ?? geoAddress?.description, - ), - lumaEventId, - isDraft: true, - }; -} - -function mergeAISuggestedDraft( - fallbackDraft: EventDraft, - aiSuggestedDraft: AISuggestedEvent, -): EventDraft { - return { - ...fallbackDraft, - name: aiSuggestedDraft.name.trim() || fallbackDraft.name, - slug: normalizeSlugPart(aiSuggestedDraft.slug) || fallbackDraft.slug, - tagline: aiSuggestedDraft.tagline.trim() || fallbackDraft.tagline, - attendeeLimit: sanitizeAttendeeLimit( - aiSuggestedDraft.attendeeLimit, - fallbackDraft.attendeeLimit, - ), - streetAddress: - cleanNullableText(aiSuggestedDraft.streetAddress) ?? - fallbackDraft.streetAddress, - shortLocation: - cleanNullableText(aiSuggestedDraft.shortLocation) ?? - fallbackDraft.shortLocation, - fullAddress: - cleanNullableText(aiSuggestedDraft.fullAddress) ?? - fallbackDraft.fullAddress, - }; -} - -function toErrorMessage(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message; - } - - return "Unknown error"; -} - -export async function syncLumaEventsWorkflow( - input: LumaSyncInput = {}, -): Promise { - "use workflow"; - - const limit = Math.min(Math.max(input.limit ?? 10, 1), 50); - const calendarApiId = input.calendarApiId ?? undefined; - const calendarHandle = input.calendarHandle ?? "allthingswebcalendar"; - - const lumaEvents = await fetchLatestLumaEvents({ - limit, - calendarApiId, - calendarHandle, - }); - - const uniqueEvents: Array<{ lumaEventId: string; event: LumaEvent }> = []; - const seenEventIds = new Set(); - - for (const event of lumaEvents) { - const lumaEventId = getLumaEventId(event); - if (!lumaEventId || seenEventIds.has(lumaEventId)) { - continue; - } - - seenEventIds.add(lumaEventId); - uniqueEvents.push({ lumaEventId, event }); - - if (uniqueEvents.length >= limit) { - break; - } - } - - const eventIds = uniqueEvents.map((item) => item.lumaEventId); - const existingLumaEventIds = await getExistingLumaEventIds(eventIds); - const existingLumaEventIdSet = new Set(existingLumaEventIds); - - const createdEvents: LumaSyncResult["createdEvents"] = []; - const errors: LumaSyncError[] = []; - - for (const item of uniqueEvents) { - if (existingLumaEventIdSet.has(item.lumaEventId)) { - continue; - } - - let eventDraft: EventDraft; - - try { - const fallbackDraft = deriveEventDraft(item.event, item.lumaEventId); - - try { - const aiSuggestedDraft = await generateEventDraftWithAI({ - lumaEvent: item.event, - derivedDraft: fallbackDraft, - }); - eventDraft = mergeAISuggestedDraft(fallbackDraft, aiSuggestedDraft); - } catch (aiError) { - errors.push({ - scope: "import", - reference: item.lumaEventId, - error: `AI generation failed: ${toErrorMessage(aiError)}`, - }); - eventDraft = fallbackDraft; - } - - eventDraft.slug = await resolveUniqueSlug(eventDraft.slug); - - const createdEvent = await createEventFromDraft(eventDraft); - if (createdEvent) { - createdEvents.push(createdEvent); - let reviewMessageId: string | null = null; - let reviewSessionCreated = false; - let reviewSetupErrorMessage: string | null = null; - - try { - const reviewThread = - await createDiscordReviewThreadForEvent(createdEvent); - reviewMessageId = reviewThread.rootMessageId; - await createDiscordReviewSession({ - eventId: createdEvent.id, - channelId: reviewThread.channelId, - rootMessageId: reviewThread.rootMessageId, - threadId: reviewThread.threadId, - lastSeenMessageId: reviewThread.lastSeenMessageId, - }); - reviewSessionCreated = true; - } catch (reviewSetupError) { - reviewSetupErrorMessage = toErrorMessage(reviewSetupError); - errors.push({ - scope: "review", - reference: createdEvent.id, - error: `Failed to create Discord review thread: ${toErrorMessage( - reviewSetupError, - )}`, - }); - } - - try { - await postDiscordLumaSyncStatusUpdate({ - event: createdEvent, - reviewMessageId, - reviewSessionCreated, - reviewSetupError: reviewSetupErrorMessage, - }); - } catch (statusUpdateError) { - errors.push({ - scope: "review", - reference: createdEvent.id, - error: `Failed to post Discord status update: ${toErrorMessage( - statusUpdateError, - )}`, - }); - } - } - } catch (error) { - errors.push({ - scope: "import", - reference: item.lumaEventId, - error: toErrorMessage(error), - }); - } - } - - return { - fetchedCount: uniqueEvents.length, - skippedExistingCount: existingLumaEventIds.length, - createdCount: createdEvents.length, - approvedCount: 0, - createdEvents, - skippedEventIds: existingLumaEventIds, - errors, - }; -} diff --git a/app/src/workflows/luma-sync/steps/ai.ts b/app/src/workflows/luma-sync/steps/ai.ts deleted file mode 100644 index 1f29ed0..0000000 --- a/app/src/workflows/luma-sync/steps/ai.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createGateway, generateObject } from "ai"; -import { mainConfig } from "@/lib/config"; -import type { LumaEvent } from "@/lib/luma"; -import { - aiSuggestedEventSchema, - type AISuggestedEvent, - type EventDraft, -} from "../types"; - -const AI_MODEL = "openai/gpt-5.3-medium"; - -function getGatewayModel() { - const gatewayApiKey = mainConfig.ai.gatewayApiKey; - const oidcToken = mainConfig.ai.vercelOidcToken; - - if (!gatewayApiKey && !oidcToken) { - throw new Error( - "AI gateway auth missing. Set AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN.", - ); - } - - if (gatewayApiKey) { - return createGateway({ apiKey: gatewayApiKey })(AI_MODEL); - } - - return createGateway({ - headers: { - Authorization: `Bearer ${oidcToken}`, - }, - })(AI_MODEL); -} - -export async function generateEventDraftWithAI({ - lumaEvent, - derivedDraft, -}: { - lumaEvent: LumaEvent; - derivedDraft: EventDraft; -}): Promise { - "use step"; - - const { object } = await generateObject({ - model: getGatewayModel(), - schema: aiSuggestedEventSchema, - system: - "You convert Luma events to AllThingsWeb event records. Return realistic, concise values. shortLocation must be a short street/company-style label (for example: 'Mux Office', 'Vercel', 'Market St') and must not be only a city name like 'San Francisco'.", - prompt: ` -Given a Luma event and a deterministic fallback draft, return an improved AllThingsWeb event payload. - -Requirements: -- Keep values factual and grounded in the Luma event. -- Keep slug lowercase and URL-safe with hyphens only. -- Tagline should be concise and usable as public event copy. -- attendeeLimit must be a realistic positive integer. -- shortLocation should be a concise street/company/venue label, not just city-only text. -- For unknown optional address fields, return null. - -AllThingsWeb required fields: -- name (string) -- slug (string) -- tagline (string) -- attendeeLimit (number) - -AllThingsWeb optional location fields: -- streetAddress (string | null) -- shortLocation (string | null) -- fullAddress (string | null) - -Luma event JSON: -${JSON.stringify(lumaEvent, null, 2)} - -Fallback draft JSON: -${JSON.stringify(derivedDraft, null, 2)} -`, - }); - - return object; -} diff --git a/app/src/workflows/luma-sync/steps/discord.ts b/app/src/workflows/luma-sync/steps/discord.ts deleted file mode 100644 index ac17294..0000000 --- a/app/src/workflows/luma-sync/steps/discord.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { LumaSyncCreatedEvent } from "../types"; - -export type DiscordReviewThread = { - channelId: string; - rootMessageId: string; - threadId: string; - lastSeenMessageId: string | null; -}; - -export async function createDiscordReviewThreadForEvent( - event: LumaSyncCreatedEvent, -): Promise { - "use step"; - const { postEventReviewCard } = await import("@/lib/discord/review-bot"); - const message = await postEventReviewCard({ - eventId: event.id, - name: event.name, - slug: event.slug, - startDate: event.startDate, - endDate: event.endDate, - tagline: event.tagline, - attendeeLimit: event.attendeeLimit, - lumaEventId: event.lumaEventId, - isDraft: event.isDraft, - }); - - return { - channelId: message.channelId, - rootMessageId: message.rootMessageId, - threadId: message.rootMessageId, - lastSeenMessageId: null, - }; -} - -export async function postDiscordLumaSyncStatusUpdate(input: { - event: LumaSyncCreatedEvent; - reviewMessageId: string | null; - reviewSessionCreated: boolean; - reviewSetupError: string | null; -}): Promise { - "use step"; - const { postLumaSyncStatusUpdate } = await import("@/lib/discord/review-bot"); - const { event, reviewMessageId, reviewSessionCreated, reviewSetupError } = - input; - - await postLumaSyncStatusUpdate({ - eventId: event.id, - name: event.name, - slug: event.slug, - lumaEventId: event.lumaEventId, - isDraft: event.isDraft, - startDate: event.startDate, - endDate: event.endDate, - reviewMessageId, - reviewSessionCreated, - reviewSetupError, - }); -} diff --git a/app/src/workflows/luma-sync/steps/events.ts b/app/src/workflows/luma-sync/steps/events.ts deleted file mode 100644 index 7b029e2..0000000 --- a/app/src/workflows/luma-sync/steps/events.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { db } from "@/lib/db"; -import { approveDiscordReviewSessionByEventId } from "@/lib/review/approval"; -import { eventReviewSessionsTable, eventsTable } from "@/lib/schema"; -import { and, eq, inArray, like, or } from "drizzle-orm"; -import type { EventDraft, LumaSyncCreatedEvent } from "../types"; - -function normalizeSlugPart(input: string): string { - return input - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") - .replace(/-{2,}/g, "-"); -} - -function toInsertDate(value: string): Date { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid date value: ${value}`); - } - return date; -} - -export async function getExistingLumaEventIds( - lumaEventIds: string[], -): Promise { - "use step"; - - if (lumaEventIds.length === 0) { - return []; - } - - const rows = await db - .select({ lumaEventId: eventsTable.lumaEventId }) - .from(eventsTable) - .where(inArray(eventsTable.lumaEventId, lumaEventIds)); - - return rows - .map((row) => row.lumaEventId) - .filter((lumaEventId): lumaEventId is string => Boolean(lumaEventId)); -} - -export async function resolveUniqueSlug(desiredSlug: string): Promise { - "use step"; - - const baseSlug = normalizeSlugPart(desiredSlug) || `event-${Date.now()}`; - - const rows = await db - .select({ slug: eventsTable.slug }) - .from(eventsTable) - .where( - or( - eq(eventsTable.slug, baseSlug), - like(eventsTable.slug, `${baseSlug}-%`), - ), - ); - - const existingSlugs = new Set(rows.map((row) => row.slug)); - if (!existingSlugs.has(baseSlug)) { - return baseSlug; - } - - let counter = 2; - while (existingSlugs.has(`${baseSlug}-${counter}`)) { - counter += 1; - } - - return `${baseSlug}-${counter}`; -} - -export async function createEventFromDraft( - draft: EventDraft, -): Promise { - "use step"; - - const [created] = await db - .insert(eventsTable) - .values({ - name: draft.name, - startDate: toInsertDate(draft.startDate), - endDate: toInsertDate(draft.endDate), - slug: draft.slug, - tagline: draft.tagline, - attendeeLimit: draft.attendeeLimit, - streetAddress: draft.streetAddress, - shortLocation: draft.shortLocation, - fullAddress: draft.fullAddress, - lumaEventId: draft.lumaEventId, - isDraft: draft.isDraft, - }) - .onConflictDoNothing({ target: eventsTable.lumaEventId }) - .returning({ - id: eventsTable.id, - name: eventsTable.name, - slug: eventsTable.slug, - startDate: eventsTable.startDate, - endDate: eventsTable.endDate, - tagline: eventsTable.tagline, - attendeeLimit: eventsTable.attendeeLimit, - isDraft: eventsTable.isDraft, - lumaEventId: eventsTable.lumaEventId, - }); - - return created ?? null; -} - -export type PendingDiscordReviewSession = { - id: string; - eventId: string; - threadId: string; - lastSeenMessageId: string | null; -}; - -export async function createDiscordReviewSession(input: { - eventId: string; - channelId: string; - rootMessageId: string; - threadId: string; - lastSeenMessageId: string | null; -}): Promise { - "use step"; - - await db - .insert(eventReviewSessionsTable) - .values({ - eventId: input.eventId, - provider: "discord", - channelId: input.channelId, - rootMessageId: input.rootMessageId, - threadId: input.threadId, - lastSeenMessageId: input.lastSeenMessageId, - status: "pending", - approvalMessageId: null, - }) - .onConflictDoUpdate({ - target: eventReviewSessionsTable.eventId, - set: { - provider: "discord", - channelId: input.channelId, - rootMessageId: input.rootMessageId, - threadId: input.threadId, - lastSeenMessageId: input.lastSeenMessageId, - status: "pending", - approvalMessageId: null, - }, - }); -} - -export async function listPendingDiscordReviewSessions( - limit = 100, -): Promise { - "use step"; - - return db - .select({ - id: eventReviewSessionsTable.id, - eventId: eventReviewSessionsTable.eventId, - threadId: eventReviewSessionsTable.threadId, - lastSeenMessageId: eventReviewSessionsTable.lastSeenMessageId, - }) - .from(eventReviewSessionsTable) - .where( - and( - eq(eventReviewSessionsTable.provider, "discord"), - eq(eventReviewSessionsTable.status, "pending"), - ), - ) - .limit(limit); -} - -export async function updateDiscordReviewSessionCursor( - reviewSessionId: string, - lastSeenMessageId: string, -): Promise { - "use step"; - - await db - .update(eventReviewSessionsTable) - .set({ - lastSeenMessageId, - }) - .where(eq(eventReviewSessionsTable.id, reviewSessionId)); -} - -export async function set_live_after_explicit_approval({ - reviewSessionId, - eventId, - approvalMessageId, -}: { - reviewSessionId: string; - eventId: string; - approvalMessageId: string; -}): Promise { - "use step"; - const result = await approveDiscordReviewSessionByEventId({ - eventId, - approvalMessageId, - }); - - return result.status === "approved" || result.status === "already_approved"; -} - -export const setLiveAfterExplicitApproval = set_live_after_explicit_approval; diff --git a/app/src/workflows/luma-sync/steps/luma.ts b/app/src/workflows/luma-sync/steps/luma.ts deleted file mode 100644 index 3f7883d..0000000 --- a/app/src/workflows/luma-sync/steps/luma.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createLumaClient, type LumaEvent } from "@/lib/luma"; - -export function getLumaEventId(event: LumaEvent): string | null { - const rawId = event.api_id ?? event.app_id; - if (!rawId) return null; - - const id = rawId.trim(); - return id.length > 0 ? id : null; -} - -export async function fetchLatestLumaEvents({ - limit, - calendarApiId, - calendarHandle, -}: { - limit: number; - calendarApiId?: string; - calendarHandle?: string; -}): Promise { - "use step"; - - const lumaClient = createLumaClient(); - - return lumaClient.getCalendarEvents({ - limit, - after: new Date().toISOString(), - calendarApiId, - calendarHandle, - }); -} diff --git a/app/src/workflows/luma-sync/types.ts b/app/src/workflows/luma-sync/types.ts deleted file mode 100644 index 8985e13..0000000 --- a/app/src/workflows/luma-sync/types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod"; - -export const aiSuggestedEventSchema = z.object({ - name: z.string().trim().min(1).max(200), - slug: z.string().trim().min(1).max(200), - tagline: z.string().trim().min(1).max(280), - attendeeLimit: z.number().int().positive().max(50000), - streetAddress: z.string().trim().min(1).max(300).nullable().optional(), - shortLocation: z.string().trim().min(1).max(160).nullable().optional(), - fullAddress: z.string().trim().min(1).max(500).nullable().optional(), -}); - -export type AISuggestedEvent = z.infer; - -export type EventDraft = { - name: string; - startDate: string; - endDate: string; - slug: string; - tagline: string; - attendeeLimit: number; - streetAddress: string | null; - shortLocation: string | null; - fullAddress: string | null; - lumaEventId: string; - isDraft: boolean; -}; - -export type LumaSyncInput = { - limit?: number; - calendarApiId?: string | null; - calendarHandle?: string | null; -}; - -export type LumaSyncCreatedEvent = { - id: string; - name: string; - slug: string; - startDate: Date; - endDate: Date; - tagline: string; - attendeeLimit: number; - isDraft: boolean; - lumaEventId: string | null; -}; - -export type LumaSyncError = { - scope: "import" | "review"; - reference: string; - error: string; -}; - -export type LumaSyncResult = { - fetchedCount: number; - skippedExistingCount: number; - createdCount: number; - approvedCount: number; - createdEvents: LumaSyncCreatedEvent[]; - skippedEventIds: string[]; - errors: LumaSyncError[]; -}; diff --git a/app/tests/luma-sync.test.ts b/app/tests/luma-sync.test.ts new file mode 100644 index 0000000..df7ce68 --- /dev/null +++ b/app/tests/luma-sync.test.ts @@ -0,0 +1,345 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { generateDrizzleJson, generateMigration } from "drizzle-kit/api"; +import { eq } from "drizzle-orm"; +import { parsePublicLumaCalendar } from "../src/lib/luma/public-calendar"; +import { syncPublicLumaEvents } from "../src/lib/luma/sync"; +import * as schema from "../src/lib/schema"; +import { + eventsTable, + imagesTable, + eventImagesTable, + talksTable, + eventTalksTable, + sponsorsTable, + eventSponsorsTable, + eventReviewSessionsTable, +} from "../src/lib/schema"; + +function event( + id = "evt-test", + extra = "", + start = "20260916T003000Z", + end = "20260916T033000Z", +) { + return `BEGIN:VEVENT\r\nUID:${id}@events.lu.ma\r\nSUMMARY:All Things Web\r\nDTSTART:${start}\r\nDTEND:${end}\r\nLOCATION:Sentry\\, San Francisco\r\n${extra}END:VEVENT\r\n`; +} + +function calendar(...events: string[]) { + return `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Luma//EN\r\n${events.join("")}END:VCALENDAR\r\n`; +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function respond(body: string, status = 200) { + globalThis.fetch = Object.assign(async () => new Response(body, { status }), { + preconnect: originalFetch.preconnect, + }); +} + +describe("public Luma calendar", () => { + test("reads past and future events, unfolded text, and escaped locations", () => { + const rows = parsePublicLumaCalendar( + calendar( + event("evt-history", "", "20240731T000000Z", "20240731T030000Z"), + event("evt-upcoming").replace( + "SUMMARY:All Things Web", + "SUMMARY:All Things\r\n Web", + ), + ), + ); + expect(rows).toHaveLength(2); + expect(rows[0].startDate.toISOString()).toBe("2024-07-31T00:00:00.000Z"); + expect(rows[1].name).toBe("All ThingsWeb"); + expect(rows[1].location).toBe("Sentry, San Francisco"); + expect(rows[1].isDraft).toBe(false); + }); + + test("treats Luma's tentative events as published but withholds private/cancelled entries", () => { + const rows = parsePublicLumaCalendar( + calendar( + event("evt-public", "STATUS:TENTATIVE\r\n"), + event("evt-private", "CLASS:PRIVATE\r\n"), + event("evt-cancelled", "STATUS:CANCELLED\r\n"), + ), + ); + expect(rows.map((row) => row.isDraft)).toEqual([false, true, true]); + }); + + test("deduplicates event IDs using the newest sequence", () => { + const rows = parsePublicLumaCalendar( + calendar( + event("evt-test", "SEQUENCE:2\r\n").replace( + "SUMMARY:All Things Web", + "SUMMARY:Updated title", + ), + event("evt-test", "SEQUENCE:1\r\n"), + ), + ); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe("Updated title"); + }); + + test("withholds private and cancelled entries regardless of value casing", () => { + for (const property of [ + "STATUS:cancelled", + "STATUS:CaNcElLeD", + "CLASS:private", + "CLASS:PrIvAtE", + "CLASS:confidential", + "CLASS:CoNfIdEnTiAl", + "CLASS: private ", + ]) { + expect( + parsePublicLumaCalendar( + calendar(event("evt-test", `${property}\r\n`)), + )[0].isDraft, + ).toBe(true); + } + expect( + parsePublicLumaCalendar( + calendar(event("evt-test", "CLASS:public\r\nSTATUS:tentative\r\n")), + )[0].isDraft, + ).toBe(false); + }); + + test("resolves named timezones and date-only events independently of the host timezone", () => { + const timed = event() + .replace( + "DTSTART:20260916T003000Z", + "DTSTART;TZID=America/Los_Angeles:20260915T173000", + ) + .replace( + "DTEND:20260916T033000Z", + "DTEND;TZID=America/Los_Angeles:20260915T203000", + ); + expect( + parsePublicLumaCalendar(calendar(timed))[0].startDate.toISOString(), + ).toBe("2026-09-16T00:30:00.000Z"); + const allDay = event() + .replace("DTSTART:20260916T003000Z", "DTSTART;VALUE=DATE:20260915") + .replace("DTEND:20260916T033000Z", "DTEND;VALUE=DATE:20260917"); + const [row] = parsePublicLumaCalendar(calendar(allDay)); + expect(row.startDate.toISOString()).toBe("2026-09-15T07:00:00.000Z"); + expect(row.endDate.toISOString()).toBe("2026-09-17T07:00:00.000Z"); + }); + + test("rejects incomplete, empty, invalid and unexpanded recurring feeds", () => { + for (const input of [ + "Unavailable", + calendar(event()).replace("END:VCALENDAR", ""), + calendar(), + calendar(event().replace("UID:evt-test@events.lu.ma", "UID:unknown")), + calendar(event("evt-test", "", "20260916T033000Z", "20260916T003000Z")), + calendar(event("evt-test", "RRULE:FREQ=WEEKLY\r\n")), + ]) { + expect(() => parsePublicLumaCalendar(input)).toThrow(); + } + }); +}); + +describe("Luma synchronization against Postgres", () => { + const client = new PGlite(); + const db = drizzle(client); + + beforeAll(async () => { + const statements = await generateMigration( + generateDrizzleJson({}), + generateDrizzleJson(schema), + ); + await client.exec("CREATE SCHEMA IF NOT EXISTS neon_auth"); + for (const statement of statements) { + await client.exec(statement); + } + }); + + beforeEach(async () => { + await client.exec("TRUNCATE events, images, talks, sponsors CASCADE"); + }); + + afterAll(async () => { + await client.close(); + }); + + const seed = { + name: "Original title", + slug: "existing-url", + startDate: new Date("2026-09-15T17:30:00Z"), + endDate: new Date("2026-09-15T20:30:00Z"), + tagline: "Our hand-written introduction", + attendeeLimit: 123, + lumaEventId: "evt-test", + isDraft: true, + recordingUrl: "https://example.com/recording", + isHackathon: true, + hackathonState: "ended" as const, + }; + + test("publishes stranded drafts and refreshes source facts without losing editorial content or relations", async () => { + const [image] = await db + .insert(imagesTable) + .values({ + url: "https://example.com/photo", + alt: "Archive photo", + placeholder: "", + width: 100, + height: 100, + }) + .returning(); + const [original] = await db + .insert(eventsTable) + .values({ ...seed, previewImage: image.id }) + .returning(); + const [talk] = await db + .insert(talksTable) + .values({ title: "Original talk", description: "Talk description" }) + .returning(); + const [sponsor] = await db + .insert(sponsorsTable) + .values({ name: "Original sponsor", about: "Sponsor description" }) + .returning(); + await db + .insert(eventImagesTable) + .values({ eventId: original.id, imageId: image.id }); + await db + .insert(eventTalksTable) + .values({ eventId: original.id, talkId: talk.id }); + await db + .insert(eventSponsorsTable) + .values({ eventId: original.id, sponsorId: sponsor.id }); + await db.insert(eventReviewSessionsTable).values({ + eventId: original.id, + channelId: "old-channel", + rootMessageId: "old-message", + threadId: "old-thread", + status: "pending", + }); + const relatedBefore = { + images: await db.select().from(eventImagesTable), + talks: await db.select().from(eventTalksTable), + sponsors: await db.select().from(eventSponsorsTable), + reviews: await db.select().from(eventReviewSessionsTable), + }; + + respond(calendar(event())); + const result = await syncPublicLumaEvents(db); + expect(result.publishedCount).toBe(1); + const [updated] = await db.select().from(eventsTable); + expect(updated).toMatchObject({ + ...seed, + id: original.id, + createdAt: original.createdAt, + previewImage: image.id, + name: "All Things Web", + startDate: new Date("2026-09-16T00:30:00Z"), + endDate: new Date("2026-09-16T03:30:00Z"), + isDraft: false, + fullAddress: "Sentry, San Francisco", + }); + expect({ + images: await db.select().from(eventImagesTable), + talks: await db.select().from(eventTalksTable), + sponsors: await db.select().from(eventSponsorsTable), + reviews: await db.select().from(eventReviewSessionsTable), + }).toEqual(relatedBefore); + await syncPublicLumaEvents(db); + expect(await db.select().from(eventsTable)).toHaveLength(1); + }); + + test("imports more than ten events including history, preserves missing and website-only records, and is repeatable", async () => { + const originals = await db + .insert(eventsTable) + .values([ + { + ...seed, + slug: "not-in-feed", + lumaEventId: "evt-absent", + isDraft: false, + }, + { ...seed, slug: "website-only", lumaEventId: null, isDraft: false }, + ]) + .returning(); + respond( + calendar( + ...Array.from({ length: 25 }, (_, index) => + event(`evt-${index}`, "", "20240731T000000Z", "20240731T030000Z"), + ), + ), + ); + const result = await syncPublicLumaEvents(db); + expect(result.syncedCount).toBe(25); + expect(result.publishedCount).toBe(25); + for (const original of originals) { + expect( + ( + await db + .select() + .from(eventsTable) + .where(eq(eventsTable.id, original.id)) + )[0], + ).toEqual(original); + } + expect(await db.select().from(eventsTable)).toHaveLength(27); + await syncPublicLumaEvents(db); + expect(await db.select().from(eventsTable)).toHaveLength(27); + const [imported] = await db + .select() + .from(eventsTable) + .where(eq(eventsTable.lumaEventId, "evt-0")); + expect(imported.slug).toBe("2024-07-30-all-things-web-evt-0"); + expect(imported.attendeeLimit).toBe(0); + }); + + test("failed, malformed or empty feeds leave all database records untouched", async () => { + const originals = await db.insert(eventsTable).values(seed).returning(); + for (const [body, status] of [ + ["Unavailable", 503], + ["Not a calendar", 200], + [calendar(), 200], + [calendar(event(), event("bad-id")), 200], + ] as const) { + respond(body, status); + await expect(syncPublicLumaEvents(db)).rejects.toThrow(); + expect(await db.select().from(eventsTable)).toEqual(originals); + } + }); + + test("a database conflict rolls back the entire snapshot", async () => { + const originals = await db + .insert(eventsTable) + .values({ + ...seed, + lumaEventId: null, + slug: "2026-09-15-all-things-web-evt-conflict", + }) + .returning(); + respond(calendar(event("evt-new"), event("evt-conflict"))); + await expect(syncPublicLumaEvents(db)).rejects.toThrow(); + expect(await db.select().from(eventsTable)).toEqual(originals); + }); + + test("explicit cancellation hides a previously imported event without deleting it", async () => { + const [original] = await db + .insert(eventsTable) + .values({ ...seed, isDraft: false }) + .returning(); + respond(calendar(event("evt-test", "STATUS:cancelled\r\n"))); + await syncPublicLumaEvents(db); + const [updated] = await db.select().from(eventsTable); + expect(updated.id).toBe(original.id); + expect(updated.isDraft).toBe(true); + expect(updated.recordingUrl).toBe(original.recordingUrl); + }); +}); diff --git a/app/vercel.json b/app/vercel.json index 8e7d06b..42a2b2c 100644 --- a/app/vercel.json +++ b/app/vercel.json @@ -1,9 +1,5 @@ { "crons": [ - { - "path": "/api/discord/gateway", - "schedule": "*/9 * * * *" - }, { "path": "/api/cron/luma-sync", "schedule": "0 * * * *"