Skip to content

Commit e7e94e7

Browse files
committed
Add hourly Luma sync workflow with Vercel cron
1 parent 27b0bd0 commit e7e94e7

11 files changed

Lines changed: 1997 additions & 28 deletions

File tree

app/bun.lock

Lines changed: 1383 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/next.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { withSentryConfig } from "@sentry/nextjs";
22
import type { NextConfig } from "next";
3+
import { withWorkflow } from "workflow/next";
34

45
const nextConfig: NextConfig = {
56
images: {
@@ -52,7 +53,9 @@ const nextConfig: NextConfig = {
5253
},
5354
};
5455

55-
export default withSentryConfig(nextConfig, {
56+
const workflowConfig = withWorkflow(nextConfig);
57+
58+
export default withSentryConfig(workflowConfig, {
5659
// For all available options, see:
5760
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
5861

app/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
"@tanstack/query-db-collection": "^0.2.16",
5858
"@tanstack/react-db": "^0.1.17",
5959
"@types/heic-convert": "^2.1.0",
60+
"@workflow/ai": "^4.0.1-beta.52",
61+
"ai": "^6.0.97",
6062
"better-env": "^0.3.1",
6163
"canvas-confetti": "^1.9.3",
6264
"class-variance-authority": "^0.7.1",
@@ -84,6 +86,7 @@
8486
"tailwind-merge": "^3.3.1",
8587
"uuid": "^13.0.0",
8688
"vaul": "^1.1.2",
89+
"workflow": "^4.1.0-beta.60",
8790
"zod": "^4.1.8"
8891
},
8992
"devDependencies": {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { NextResponse } from "next/server";
2+
import { start } from "workflow/api";
3+
import { syncLumaEventsWorkflow } from "@/workflows/luma-sync";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
const DEFAULT_LIMIT = 10;
9+
const DEFAULT_CALENDAR_HANDLE = "allthingswebcalendar";
10+
11+
function isAuthorized(request: Request): boolean {
12+
const cronSecret = process.env.CRON_SECRET;
13+
14+
if (!cronSecret) {
15+
return true;
16+
}
17+
18+
return request.headers.get("authorization") === `Bearer ${cronSecret}`;
19+
}
20+
21+
export async function GET(request: Request) {
22+
if (!isAuthorized(request)) {
23+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24+
}
25+
26+
try {
27+
const run = await start(syncLumaEventsWorkflow, [
28+
{
29+
limit: DEFAULT_LIMIT,
30+
calendarHandle: DEFAULT_CALENDAR_HANDLE,
31+
},
32+
]);
33+
34+
return NextResponse.json({
35+
ok: true,
36+
runId: run.runId,
37+
message: "Luma sync workflow started",
38+
});
39+
} catch (error) {
40+
const message = error instanceof Error ? error.message : "Unknown error";
41+
42+
return NextResponse.json({ ok: false, error: message }, { status: 500 });
43+
}
44+
}

app/src/lib/luma.ts

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
11
import { mainConfig } from "@/lib/config";
22

3+
type LumaGeoAddress = {
4+
city: string;
5+
type: "google" | "string";
6+
country: string;
7+
latitude: number;
8+
longitude: number;
9+
place_id: string;
10+
address: string;
11+
description: string;
12+
city_state: string;
13+
full_address: string;
14+
};
15+
316
export type LumaEvent = {
4-
app_id: string;
17+
api_id?: string;
18+
app_id?: string;
19+
calendar_api_id?: string;
520
created_at: string;
6-
cover_url: string;
21+
cover_url?: string | null;
722
name: string;
8-
description: string;
9-
description_md: string;
23+
description?: string | null;
24+
description_md?: string | null;
1025
series_api_id?: string;
1126
start_at: string;
1227
duration_interval: string;
1328
end_at: string;
14-
geo_address_json: {
15-
city: string;
16-
type: "google" | "string";
17-
country: string;
18-
latitude: number;
19-
longitude: number;
20-
place_id: string;
21-
address: string;
22-
description: string;
23-
city_state: string;
24-
full_address: string;
25-
};
26-
geo_latitude: number;
27-
geo_longitude: number;
29+
geo_address_json?: LumaGeoAddress | null;
30+
geo_latitude?: number | null;
31+
geo_longitude?: number | null;
2832
url: string;
2933
timezone: string;
3034
event_type: "independent" | "series";
3135
user_api_id: string;
3236
visibility: "public" | "private";
33-
zoom_meeting_url: string;
34-
meeting_url: string;
37+
zoom_meeting_url?: string | null;
38+
meeting_url?: string | null;
3539
};
3640

3741
export type LumaHost = {
@@ -75,6 +79,12 @@ export const createLumaClient = () => {
7579
);
7680
return [];
7781
},
82+
getCalendarEvents: async () => {
83+
console.warn(
84+
"Did not fetch calendar events because LUMA_API_KEY is not set",
85+
);
86+
return [];
87+
},
7888
getEvent: async () => {
7989
console.warn("Did not fetch event because LUMA_API_KEY is not set");
8090
return null;
@@ -107,8 +117,44 @@ export const createLumaClient = () => {
107117
"x-luma-api-key": apiKey,
108118
};
109119

110-
const getUpcomingEvents = async () => {
111-
const url = `https://api.lu.ma/public/v1/calendar/list-events?pagination_limit=50&after=${new Date().toISOString()}`;
120+
const parseCalendarEventsResponse = (resData: any): LumaEvent[] => {
121+
const entries = resData?.entries ?? resData?.events?.entries ?? [];
122+
if (!Array.isArray(entries)) return [];
123+
return entries
124+
.map((entry: any) => entry?.event ?? entry)
125+
.filter(Boolean) as LumaEvent[];
126+
};
127+
128+
const getCalendarEvents = async ({
129+
limit = 10,
130+
after,
131+
before,
132+
calendarApiId,
133+
calendarHandle,
134+
}: {
135+
limit?: number;
136+
after?: string;
137+
before?: string;
138+
calendarApiId?: string;
139+
calendarHandle?: string;
140+
} = {}) => {
141+
const searchParams = new URLSearchParams({
142+
pagination_limit: String(limit),
143+
});
144+
if (after) {
145+
searchParams.append("after", after);
146+
}
147+
if (before) {
148+
searchParams.append("before", before);
149+
}
150+
if (calendarApiId) {
151+
searchParams.append("calendar_api_id", calendarApiId);
152+
}
153+
if (calendarHandle) {
154+
searchParams.append("calendar_handle", calendarHandle);
155+
}
156+
157+
const url = `https://api.lu.ma/public/v1/calendar/list-events?${searchParams.toString()}`;
112158
const res = await fetch(url, {
113159
method: "GET",
114160
headers,
@@ -119,7 +165,14 @@ export const createLumaClient = () => {
119165
);
120166
}
121167
const resData = await res.json();
122-
return resData.events.entries.map((e: any) => e.event);
168+
return parseCalendarEventsResponse(resData);
169+
};
170+
171+
const getUpcomingEvents = async () => {
172+
return getCalendarEvents({
173+
limit: 50,
174+
after: new Date().toISOString(),
175+
});
123176
};
124177

125178
const getEvent = async (eventId: string): Promise<LumaEventPayload> => {
@@ -228,6 +281,7 @@ export const createLumaClient = () => {
228281

229282
return {
230283
getUpcomingEvents,
284+
getCalendarEvents,
231285
getEvent,
232286
getAttendees,
233287
getAllAttendees,

0 commit comments

Comments
 (0)