From 73e4a432f40c03c6b3e97917523ddb5a42d71e1a Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 16:42:46 -0800 Subject: [PATCH 1/6] Add Discord draft approval flow for Luma sync --- app/migrations/0012_thin_morlun.sql | 17 + app/migrations/meta/0012_snapshot.json | 1344 ++++++++++++++++++ app/migrations/meta/_journal.json | 7 + app/src/lib/config.ts | 4 + app/src/lib/integrations/config.ts | 10 + app/src/lib/schema.ts | 23 + app/src/workflows/luma-sync/index.ts | 79 +- app/src/workflows/luma-sync/steps/discord.ts | 307 ++++ app/src/workflows/luma-sync/steps/events.ts | 120 +- app/src/workflows/luma-sync/types.ts | 9 +- 10 files changed, 1914 insertions(+), 6 deletions(-) create mode 100644 app/migrations/0012_thin_morlun.sql create mode 100644 app/migrations/meta/0012_snapshot.json create mode 100644 app/src/workflows/luma-sync/steps/discord.ts diff --git a/app/migrations/0012_thin_morlun.sql b/app/migrations/0012_thin_morlun.sql new file mode 100644 index 0000000..7506ec1 --- /dev/null +++ b/app/migrations/0012_thin_morlun.sql @@ -0,0 +1,17 @@ +CREATE TABLE "event_review_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "provider" text DEFAULT 'discord' NOT NULL, + "channel_id" text NOT NULL, + "root_message_id" text NOT NULL, + "thread_id" text NOT NULL, + "last_seen_message_id" text, + "status" text DEFAULT 'pending' NOT NULL, + "approval_message_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "event_review_sessions_event_id_unique" UNIQUE("event_id"), + CONSTRAINT "event_review_sessions_thread_id_unique" UNIQUE("thread_id") +); +--> statement-breakpoint +ALTER TABLE "event_review_sessions" ADD CONSTRAINT "event_review_sessions_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/app/migrations/meta/0012_snapshot.json b/app/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..35e01b7 --- /dev/null +++ b/app/migrations/meta/0012_snapshot.json @@ -0,0 +1,1344 @@ +{ + "id": "ab40da30-7e7c-4e95-a41b-729817ae16de", + "prevId": "0de272f8-1212-4ab7-b6b3-d381f08ed67c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.administrators": { + "name": "administrators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "administrators_user_id_users_sync_id_fk": { + "name": "administrators_user_id_users_sync_id_fk", + "tableFrom": "administrators", + "tableTo": "users_sync", + "schemaTo": "neon_auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.awards": { + "name": "awards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "awards_event_id_events_id_fk": { + "name": "awards_event_id_events_id_fk", + "tableFrom": "awards", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_images": { + "name": "event_images", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "event_images_event_id_events_id_fk": { + "name": "event_images_event_id_events_id_fk", + "tableFrom": "event_images", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_images_image_id_images_id_fk": { + "name": "event_images_image_id_images_id_fk", + "tableFrom": "event_images", + "tableTo": "images", + "columnsFrom": ["image_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_images_event_id_image_id_pk": { + "name": "event_images_event_id_image_id_pk", + "columns": ["event_id", "image_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_review_sessions": { + "name": "event_review_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'discord'" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_message_id": { + "name": "root_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_message_id": { + "name": "last_seen_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "approval_message_id": { + "name": "approval_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "event_review_sessions_event_id_events_id_fk": { + "name": "event_review_sessions_event_id_events_id_fk", + "tableFrom": "event_review_sessions", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_review_sessions_event_id_unique": { + "name": "event_review_sessions_event_id_unique", + "nullsNotDistinct": false, + "columns": ["event_id"] + }, + "event_review_sessions_thread_id_unique": { + "name": "event_review_sessions_thread_id_unique", + "nullsNotDistinct": false, + "columns": ["thread_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_sponsors": { + "name": "event_sponsors", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sponsor_id": { + "name": "sponsor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "event_sponsors_event_id_events_id_fk": { + "name": "event_sponsors_event_id_events_id_fk", + "tableFrom": "event_sponsors", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_sponsors_sponsor_id_sponsors_id_fk": { + "name": "event_sponsors_sponsor_id_sponsors_id_fk", + "tableFrom": "event_sponsors", + "tableTo": "sponsors", + "columnsFrom": ["sponsor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_sponsors_event_id_sponsor_id_pk": { + "name": "event_sponsors_event_id_sponsor_id_pk", + "columns": ["event_id", "sponsor_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_talks": { + "name": "event_talks", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "talk_id": { + "name": "talk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "event_talks_event_id_events_id_fk": { + "name": "event_talks_event_id_events_id_fk", + "tableFrom": "event_talks", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_talks_talk_id_talks_id_fk": { + "name": "event_talks_talk_id_talks_id_fk", + "tableFrom": "event_talks", + "tableTo": "talks", + "columnsFrom": ["talk_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_talks_event_id_talk_id_pk": { + "name": "event_talks_event_id_talk_id_pk", + "columns": ["event_id", "talk_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attendee_limit": { + "name": "attendee_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "street_address": { + "name": "street_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_location": { + "name": "short_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_address": { + "name": "full_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "luma_event_id": { + "name": "luma_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hackathon": { + "name": "is_hackathon", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "highlight_on_landing_page": { + "name": "highlight_on_landing_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preview_image": { + "name": "preview_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recording_url": { + "name": "recording_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hackathon_state": { + "name": "hackathon_state", + "type": "hackathon_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "hack_started_at": { + "name": "hack_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hack_until": { + "name": "hack_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vote_started_at": { + "name": "vote_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vote_until": { + "name": "vote_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "events_preview_image_images_id_fk": { + "name": "events_preview_image_images_id_fk", + "tableFrom": "events", + "tableTo": "images", + "columnsFrom": ["preview_image"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_slug_unique": { + "name": "events_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "events_luma_event_id_unique": { + "name": "events_luma_event_id_unique", + "nullsNotDistinct": false, + "columns": ["luma_event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hack_users": { + "name": "hack_users", + "schema": "", + "columns": { + "hack_id": { + "name": "hack_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "hack_users_hack_id_hacks_id_fk": { + "name": "hack_users_hack_id_hacks_id_fk", + "tableFrom": "hack_users", + "tableTo": "hacks", + "columnsFrom": ["hack_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "hack_users_user_id_users_sync_id_fk": { + "name": "hack_users_user_id_users_sync_id_fk", + "tableFrom": "hack_users", + "tableTo": "users_sync", + "schemaTo": "neon_auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hack_users_hack_id_user_id_pk": { + "name": "hack_users_hack_id_user_id_pk", + "columns": ["hack_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hack_votes": { + "name": "hack_votes", + "schema": "", + "columns": { + "hack_id": { + "name": "hack_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "award_id": { + "name": "award_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "hack_votes_hack_id_hacks_id_fk": { + "name": "hack_votes_hack_id_hacks_id_fk", + "tableFrom": "hack_votes", + "tableTo": "hacks", + "columnsFrom": ["hack_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "hack_votes_award_id_awards_id_fk": { + "name": "hack_votes_award_id_awards_id_fk", + "tableFrom": "hack_votes", + "tableTo": "awards", + "columnsFrom": ["award_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "hack_votes_user_id_users_sync_id_fk": { + "name": "hack_votes_user_id_users_sync_id_fk", + "tableFrom": "hack_votes", + "tableTo": "users_sync", + "schemaTo": "neon_auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hack_votes_hack_id_award_id_user_id_pk": { + "name": "hack_votes_hack_id_award_id_user_id_pk", + "columns": ["hack_id", "award_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hacks": { + "name": "hacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_description": { + "name": "project_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_link": { + "name": "project_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_image": { + "name": "team_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "hacks_event_id_events_id_fk": { + "name": "hacks_event_id_events_id_fk", + "tableFrom": "hacks", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "hacks_team_image_images_id_fk": { + "name": "hacks_team_image_images_id_fk", + "tableFrom": "hacks", + "tableTo": "images", + "columnsFrom": ["team_image"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder": { + "name": "placeholder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_users": { + "name": "profile_users", + "schema": "", + "columns": { + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "profile_users_profile_id_profiles_id_fk": { + "name": "profile_users_profile_id_profiles_id_fk", + "tableFrom": "profile_users", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "profile_users_user_id_users_sync_id_fk": { + "name": "profile_users_user_id_users_sync_id_fk", + "tableFrom": "profile_users", + "tableTo": "users_sync", + "schemaTo": "neon_auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "profile_users_profile_id_user_id_pk": { + "name": "profile_users_profile_id_user_id_pk", + "columns": ["profile_id", "user_id"] + } + }, + "uniqueConstraints": { + "profile_users_profile_id_unique": { + "name": "profile_users_profile_id_unique", + "nullsNotDistinct": false, + "columns": ["profile_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "twitter_handle": { + "name": "twitter_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bluesky_handle": { + "name": "bluesky_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_handle": { + "name": "linkedin_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_type": { + "name": "profile_type", + "type": "profile_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_image_images_id_fk": { + "name": "profiles_image_images_id_fk", + "tableFrom": "profiles", + "tableTo": "images", + "columnsFrom": ["image"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.redirects": { + "name": "redirects", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "destination_url": { + "name": "destination_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sponsors": { + "name": "sponsors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "about": { + "name": "about", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "square_logo_dark": { + "name": "square_logo_dark", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "square_logo_light": { + "name": "square_logo_light", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sponsors_square_logo_dark_images_id_fk": { + "name": "sponsors_square_logo_dark_images_id_fk", + "tableFrom": "sponsors", + "tableTo": "images", + "columnsFrom": ["square_logo_dark"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sponsors_square_logo_light_images_id_fk": { + "name": "sponsors_square_logo_light_images_id_fk", + "tableFrom": "sponsors", + "tableTo": "images", + "columnsFrom": ["square_logo_light"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sponsors_name_unique": { + "name": "sponsors_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.talk_speakers": { + "name": "talk_speakers", + "schema": "", + "columns": { + "talk_id": { + "name": "talk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "speaker_id": { + "name": "speaker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "talk_speakers_talk_id_talks_id_fk": { + "name": "talk_speakers_talk_id_talks_id_fk", + "tableFrom": "talk_speakers", + "tableTo": "talks", + "columnsFrom": ["talk_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "talk_speakers_speaker_id_profiles_id_fk": { + "name": "talk_speakers_speaker_id_profiles_id_fk", + "tableFrom": "talk_speakers", + "tableTo": "profiles", + "columnsFrom": ["speaker_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "talk_speakers_talk_id_speaker_id_pk": { + "name": "talk_speakers_talk_id_speaker_id_pk", + "columns": ["talk_id", "speaker_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.talks": { + "name": "talks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "neon_auth.users_sync": { + "name": "users_sync", + "schema": "neon_auth", + "columns": { + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.hackathon_state": { + "name": "hackathon_state", + "schema": "public", + "values": ["before_start", "hacking", "voting", "ended"] + }, + "public.profile_type": { + "name": "profile_type", + "schema": "public", + "values": ["organizer", "member"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/app/migrations/meta/_journal.json b/app/migrations/meta/_journal.json index ab2a7b6..e5e0129 100644 --- a/app/migrations/meta/_journal.json +++ b/app/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1758600063490, "tag": "0011_aberrant_darwin", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1771807291166, + "tag": "0012_thin_morlun", + "breakpoints": true } ] } diff --git a/app/src/lib/config.ts b/app/src/lib/config.ts index d473d0a..54f6abd 100644 --- a/app/src/lib/config.ts +++ b/app/src/lib/config.ts @@ -19,6 +19,10 @@ export const mainConfig = { luma: { apiKey: integrationsConfig.lumaApiKey, }, + discord: { + botToken: integrationsConfig.discordBotToken, + reviewChannelId: integrationsConfig.discordReviewChannelId, + }, }; export type MainConfig = typeof mainConfig; diff --git a/app/src/lib/integrations/config.ts b/app/src/lib/integrations/config.ts index cca0f1c..03fd4c7 100644 --- a/app/src/lib/integrations/config.ts +++ b/app/src/lib/integrations/config.ts @@ -9,9 +9,19 @@ const integrationsEnvConfig = configSchema("Integrations", { env: "LUMA_API_KEY", optional: true, }), + discordBotToken: server({ + env: "DISCORD_BOT_TOKEN", + optional: true, + }), + discordReviewChannelId: server({ + env: "DISCORD_REVIEW_CHANNEL_ID", + optional: true, + }), }); export const integrationsConfig = { resendApiKey: integrationsEnvConfig.server.resendApiKey, lumaApiKey: integrationsEnvConfig.server.lumaApiKey, + discordBotToken: integrationsEnvConfig.server.discordBotToken, + discordReviewChannelId: integrationsEnvConfig.server.discordReviewChannelId, }; diff --git a/app/src/lib/schema.ts b/app/src/lib/schema.ts index f80a27c..e332bf9 100644 --- a/app/src/lib/schema.ts +++ b/app/src/lib/schema.ts @@ -194,6 +194,25 @@ export const eventImagesTable = pgTable( (table) => [primaryKey({ columns: [table.eventId, table.imageId] })], ); +export const eventReviewSessionsTable = pgTable("event_review_sessions", { + id: uuid("id").primaryKey().defaultRandom(), + eventId: uuid("event_id") + .notNull() + .references(() => eventsTable.id, { + onDelete: "cascade", + }) + .unique(), + provider: text("provider").notNull().default("discord"), + channelId: text("channel_id").notNull(), + rootMessageId: text("root_message_id").notNull(), + threadId: text("thread_id").notNull().unique(), + lastSeenMessageId: text("last_seen_message_id"), + status: text("status").notNull().default("pending"), + approvalMessageId: text("approval_message_id"), + createdAt, + updatedAt, +}); + export type InsertEvent = typeof eventsTable.$inferInsert; export type SelectEvent = typeof eventsTable.$inferSelect; export type InsertEventSponsor = typeof eventSponsorsTable.$inferInsert; @@ -202,6 +221,10 @@ export type InsertEventTalk = typeof eventTalksTable.$inferInsert; export type SelectEventTalk = typeof eventTalksTable.$inferSelect; export type InsertEventImage = typeof eventImagesTable.$inferInsert; export type SelectEventImage = typeof eventImagesTable.$inferSelect; +export type InsertEventReviewSession = + typeof eventReviewSessionsTable.$inferInsert; +export type SelectEventReviewSession = + typeof eventReviewSessionsTable.$inferSelect; export const hacksTable = pgTable("hacks", { id: uuid("id").primaryKey().defaultRandom(), diff --git a/app/src/workflows/luma-sync/index.ts b/app/src/workflows/luma-sync/index.ts index 896a1e7..ba50606 100644 --- a/app/src/workflows/luma-sync/index.ts +++ b/app/src/workflows/luma-sync/index.ts @@ -1,9 +1,17 @@ import type { LumaEvent } from "@/lib/luma"; import { generateEventDraftWithAI } from "./steps/ai"; import { + createDiscordReviewThreadForEvent, + pollDiscordThreadForApproval, +} from "./steps/discord"; +import { + createDiscordReviewSession, createEventFromDraft, getExistingLumaEventIds, + listPendingDiscordReviewSessions, resolveUniqueSlug, + set_live_after_explicit_approval, + updateDiscordReviewSessionCursor, } from "./steps/events"; import { fetchLatestLumaEvents, getLumaEventId } from "./steps/luma"; import type { @@ -93,7 +101,7 @@ function deriveEventDraft( geoAddress?.full_address ?? geoAddress?.description, ), lumaEventId, - isDraft: lumaEvent.visibility === "private", + isDraft: true, }; } @@ -187,7 +195,8 @@ export async function syncLumaEventsWorkflow( eventDraft = mergeAISuggestedDraft(fallbackDraft, aiSuggestedDraft); } catch (aiError) { errors.push({ - lumaEventId: item.lumaEventId, + scope: "import", + reference: item.lumaEventId, error: `AI generation failed: ${toErrorMessage(aiError)}`, }); eventDraft = fallbackDraft; @@ -198,19 +207,83 @@ export async function syncLumaEventsWorkflow( const createdEvent = await createEventFromDraft(eventDraft); if (createdEvent) { createdEvents.push(createdEvent); + + try { + const reviewThread = + await createDiscordReviewThreadForEvent(createdEvent); + await createDiscordReviewSession({ + eventId: createdEvent.id, + channelId: reviewThread.channelId, + rootMessageId: reviewThread.rootMessageId, + threadId: reviewThread.threadId, + lastSeenMessageId: reviewThread.lastSeenMessageId, + }); + } catch (reviewSetupError) { + errors.push({ + scope: "review", + reference: createdEvent.id, + error: `Failed to create Discord review thread: ${toErrorMessage( + reviewSetupError, + )}`, + }); + } } } catch (error) { errors.push({ - lumaEventId: item.lumaEventId, + scope: "import", + reference: item.lumaEventId, error: toErrorMessage(error), }); } } + const pendingReviewSessions = await listPendingDiscordReviewSessions(100); + let approvedCount = 0; + + for (const session of pendingReviewSessions) { + try { + const reviewScanResult = await pollDiscordThreadForApproval({ + threadId: session.threadId, + afterMessageId: session.lastSeenMessageId, + }); + + if (reviewScanResult.approvalMessageId) { + const updated = await set_live_after_explicit_approval({ + reviewSessionId: session.id, + eventId: session.eventId, + approvalMessageId: reviewScanResult.approvalMessageId, + }); + + if (updated) { + approvedCount += 1; + } + + continue; + } + + if ( + reviewScanResult.latestSeenMessageId && + reviewScanResult.latestSeenMessageId !== session.lastSeenMessageId + ) { + await updateDiscordReviewSessionCursor( + session.id, + reviewScanResult.latestSeenMessageId, + ); + } + } catch (reviewError) { + errors.push({ + scope: "review", + reference: session.id, + error: toErrorMessage(reviewError), + }); + } + } + return { fetchedCount: uniqueEvents.length, skippedExistingCount: existingLumaEventIds.length, createdCount: createdEvents.length, + approvedCount, createdEvents, skippedEventIds: existingLumaEventIds, errors, diff --git a/app/src/workflows/luma-sync/steps/discord.ts b/app/src/workflows/luma-sync/steps/discord.ts new file mode 100644 index 0000000..0828f12 --- /dev/null +++ b/app/src/workflows/luma-sync/steps/discord.ts @@ -0,0 +1,307 @@ +import { mainConfig } from "@/lib/config"; +import { getLumaUrl } from "@/lib/luma"; +import type { LumaSyncCreatedEvent } from "../types"; + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; +const DISCORD_MAX_MESSAGE_LENGTH = 2000; + +type DiscordMessageAuthor = { + id: string; + bot?: boolean; +}; + +type DiscordMessage = { + id: string; + content: string; + author: DiscordMessageAuthor; +}; + +type DiscordCreatedMessage = { + id: string; + channel_id: string; +}; + +type DiscordThreadChannel = { + id: string; +}; + +type DiscordConfig = { + botToken: string; + reviewChannelId: string; +}; + +export type DiscordReviewThread = { + channelId: string; + rootMessageId: string; + threadId: string; + lastSeenMessageId: string | null; +}; + +export type DiscordApprovalScanResult = { + latestSeenMessageId: string | null; + approvalMessageId: string | null; +}; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return "Unknown error"; +} + +function getDiscordConfig(): DiscordConfig { + const botToken = mainConfig.discord.botToken; + const reviewChannelId = mainConfig.discord.reviewChannelId; + + if (!botToken) { + throw new Error("DISCORD_BOT_TOKEN is not set"); + } + + if (!reviewChannelId) { + throw new Error("DISCORD_REVIEW_CHANNEL_ID is not set"); + } + + return { botToken, reviewChannelId }; +} + +function compareSnowflakeIds(a: string, b: string): number { + try { + const aValue = BigInt(a); + const bValue = BigInt(b); + if (aValue === bValue) return 0; + return aValue > bValue ? 1 : -1; + } catch { + return a.localeCompare(b); + } +} + +function truncateText(input: string, maxLength: number): string { + if (input.length <= maxLength) { + return input; + } + + const safeLength = Math.max(maxLength - 3, 1); + return `${input.slice(0, safeLength)}...`; +} + +function toOneLine(input: string): string { + return input.replace(/\s+/g, " ").trim(); +} + +function toIsoString(value: Date): string { + return new Date(value).toISOString(); +} + +function buildRootReviewMessage(event: LumaSyncCreatedEvent): string { + return [ + `New Luma event draft: **${event.name}**`, + `Event ID: \`${event.id}\``, + "Reply with `Approved` in this thread to publish.", + ].join("\n"); +} + +function buildThreadName(eventName: string): string { + const base = `review-${toOneLine(eventName).toLowerCase()}`; + return truncateText(base, 95); +} + +function buildDraftDetailsMessage(event: LumaSyncCreatedEvent): string { + const lumaUrl = getLumaUrl(event.lumaEventId); + const details = [ + "Draft event details:", + `- id: ${event.id}`, + `- name: ${event.name}`, + `- slug: ${event.slug}`, + `- startDate: ${toIsoString(event.startDate)}`, + `- endDate: ${toIsoString(event.endDate)}`, + `- tagline: ${truncateText(toOneLine(event.tagline), 220)}`, + `- attendeeLimit: ${event.attendeeLimit}`, + `- lumaEventId: ${event.lumaEventId ?? "n/a"}`, + `- lumaUrl: ${lumaUrl ?? "n/a"}`, + `- isDraft: ${event.isDraft}`, + "", + "Reply with exactly `Approved` to set `isDraft` to `false`.", + ].join("\n"); + + return truncateText(details, DISCORD_MAX_MESSAGE_LENGTH); +} + +function isExplicitApprovalMessage(content: string): boolean { + return /^approved[.!]*$/i.test(content.trim()); +} + +async function discordRequest( + path: string, + init: RequestInit, + botToken: string, +): Promise { + const response = await fetch(`${DISCORD_API_BASE_URL}${path}`, { + ...init, + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + + const responseText = await response.text(); + const responseData = + responseText.length > 0 ? JSON.parse(responseText) : null; + + if (!response.ok) { + throw new Error( + `Discord API request failed (${response.status}): ${ + typeof responseData === "object" && + responseData && + "message" in responseData + ? String(responseData.message) + : responseText + }`, + ); + } + + return responseData as T; +} + +async function postChannelMessage( + channelId: string, + content: string, + botToken: string, +): Promise { + return discordRequest( + `/channels/${channelId}/messages`, + { + method: "POST", + body: JSON.stringify({ content }), + }, + botToken, + ); +} + +async function createThreadFromMessage( + channelId: string, + messageId: string, + name: string, + botToken: string, +): Promise { + return discordRequest( + `/channels/${channelId}/messages/${messageId}/threads`, + { + method: "POST", + body: JSON.stringify({ + name, + auto_archive_duration: 1440, + }), + }, + botToken, + ); +} + +async function listThreadMessages( + threadId: string, + botToken: string, + afterMessageId: string | null, +): Promise { + const searchParams = new URLSearchParams({ + limit: "100", + }); + if (afterMessageId) { + searchParams.set("after", afterMessageId); + } + + return discordRequest( + `/channels/${threadId}/messages?${searchParams.toString()}`, + { + method: "GET", + }, + botToken, + ); +} + +export async function createDiscordReviewThreadForEvent( + event: LumaSyncCreatedEvent, +): Promise { + "use step"; + + const { botToken, reviewChannelId } = getDiscordConfig(); + const rootMessage = await postChannelMessage( + reviewChannelId, + buildRootReviewMessage(event), + botToken, + ); + + const thread = await createThreadFromMessage( + reviewChannelId, + rootMessage.id, + buildThreadName(event.name), + botToken, + ); + + const detailsMessage = await postChannelMessage( + thread.id, + buildDraftDetailsMessage(event), + botToken, + ); + + return { + channelId: reviewChannelId, + rootMessageId: rootMessage.id, + threadId: thread.id, + lastSeenMessageId: detailsMessage.id, + }; +} + +export async function pollDiscordThreadForApproval({ + threadId, + afterMessageId, +}: { + threadId: string; + afterMessageId: string | null; +}): Promise { + "use step"; + + const { botToken } = getDiscordConfig(); + + let messages: DiscordMessage[] = []; + try { + messages = await listThreadMessages(threadId, botToken, afterMessageId); + } catch (error) { + throw new Error( + `Failed to poll Discord thread ${threadId}: ${toErrorMessage(error)}`, + ); + } + + if (messages.length === 0) { + return { + latestSeenMessageId: null, + approvalMessageId: null, + }; + } + + messages.sort((left, right) => compareSnowflakeIds(left.id, right.id)); + + let latestSeenMessageId: string | null = afterMessageId; + let approvalMessageId: string | null = null; + + for (const message of messages) { + if ( + !latestSeenMessageId || + compareSnowflakeIds(message.id, latestSeenMessageId) > 0 + ) { + latestSeenMessageId = message.id; + } + + if (message.author.bot) { + continue; + } + + if (isExplicitApprovalMessage(message.content) && !approvalMessageId) { + approvalMessageId = message.id; + } + } + + return { + latestSeenMessageId, + approvalMessageId, + }; +} diff --git a/app/src/workflows/luma-sync/steps/events.ts b/app/src/workflows/luma-sync/steps/events.ts index a631020..fbe528a 100644 --- a/app/src/workflows/luma-sync/steps/events.ts +++ b/app/src/workflows/luma-sync/steps/events.ts @@ -1,6 +1,6 @@ import { db } from "@/lib/db"; -import { eventsTable } from "@/lib/schema"; -import { eq, inArray, like, or } from "drizzle-orm"; +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 { @@ -93,8 +93,124 @@ export async function createEventFromDraft( 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 [updatedEvent] = await db + .update(eventsTable) + .set({ + isDraft: false, + }) + .where(and(eq(eventsTable.id, eventId), eq(eventsTable.isDraft, true))) + .returning({ id: eventsTable.id }); + + await db + .update(eventReviewSessionsTable) + .set({ + status: "approved", + approvalMessageId, + lastSeenMessageId: approvalMessageId, + }) + .where(eq(eventReviewSessionsTable.id, reviewSessionId)); + + return Boolean(updatedEvent); +} + +export const setLiveAfterExplicitApproval = set_live_after_explicit_approval; diff --git a/app/src/workflows/luma-sync/types.ts b/app/src/workflows/luma-sync/types.ts index f8f6ecf..8985e13 100644 --- a/app/src/workflows/luma-sync/types.ts +++ b/app/src/workflows/luma-sync/types.ts @@ -36,11 +36,17 @@ 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 = { - lumaEventId: string; + scope: "import" | "review"; + reference: string; error: string; }; @@ -48,6 +54,7 @@ export type LumaSyncResult = { fetchedCount: number; skippedExistingCount: number; createdCount: number; + approvedCount: number; createdEvents: LumaSyncCreatedEvent[]; skippedEventIds: string[]; errors: LumaSyncError[]; From 250bbe70210de56b0e900106b8bc432187eb0240 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 17:12:14 -0800 Subject: [PATCH 2/6] Require CRON_SECRET for luma sync cron route --- app/src/app/api/cron/luma-sync/route.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/app/api/cron/luma-sync/route.ts b/app/src/app/api/cron/luma-sync/route.ts index 5f47bb4..de19994 100644 --- a/app/src/app/api/cron/luma-sync/route.ts +++ b/app/src/app/api/cron/luma-sync/route.ts @@ -8,18 +8,24 @@ export const dynamic = "force-dynamic"; const DEFAULT_LIMIT = 10; const DEFAULT_CALENDAR_HANDLE = "allthingswebcalendar"; -function isAuthorized(request: Request): boolean { - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret) { - return true; - } - +function isAuthorized(request: Request, cronSecret: string): boolean { return request.headers.get("authorization") === `Bearer ${cronSecret}`; } export async function GET(request: Request) { - if (!isAuthorized(request)) { + const cronSecret = process.env.CRON_SECRET?.trim(); + + if (!cronSecret) { + return NextResponse.json( + { + ok: false, + error: "Server misconfigured: CRON_SECRET is not set", + }, + { status: 500 }, + ); + } + + if (!isAuthorized(request, cronSecret)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } From 6ba5b4618aa48cd276a2d2acee1b363eba1e65e1 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 17:13:44 -0800 Subject: [PATCH 3/6] Use better-env config for luma cron calendar settings --- app/src/app/api/cron/luma-sync/route.ts | 5 +++-- app/src/lib/config.ts | 2 ++ app/src/lib/integrations/config.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/app/api/cron/luma-sync/route.ts b/app/src/app/api/cron/luma-sync/route.ts index de19994..48809ae 100644 --- a/app/src/app/api/cron/luma-sync/route.ts +++ b/app/src/app/api/cron/luma-sync/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { start } from "workflow/api"; +import { mainConfig } from "@/lib/config"; import { syncLumaEventsWorkflow } from "@/workflows/luma-sync"; export const runtime = "nodejs"; @@ -30,9 +31,9 @@ export async function GET(request: Request) { } try { - const calendarApiId = process.env.LUMA_CALENDAR_API_ID; + const calendarApiId = mainConfig.luma.calendarApiId; const calendarHandle = - process.env.LUMA_CALENDAR_HANDLE ?? DEFAULT_CALENDAR_HANDLE; + mainConfig.luma.calendarHandle ?? DEFAULT_CALENDAR_HANDLE; const run = await start(syncLumaEventsWorkflow, [ { diff --git a/app/src/lib/config.ts b/app/src/lib/config.ts index 54f6abd..40f795b 100644 --- a/app/src/lib/config.ts +++ b/app/src/lib/config.ts @@ -18,6 +18,8 @@ export const mainConfig = { }, luma: { apiKey: integrationsConfig.lumaApiKey, + calendarApiId: integrationsConfig.lumaCalendarApiId, + calendarHandle: integrationsConfig.lumaCalendarHandle, }, discord: { botToken: integrationsConfig.discordBotToken, diff --git a/app/src/lib/integrations/config.ts b/app/src/lib/integrations/config.ts index 03fd4c7..f45bfef 100644 --- a/app/src/lib/integrations/config.ts +++ b/app/src/lib/integrations/config.ts @@ -9,6 +9,14 @@ const integrationsEnvConfig = configSchema("Integrations", { env: "LUMA_API_KEY", optional: true, }), + lumaCalendarApiId: server({ + env: "LUMA_CALENDAR_API_ID", + optional: true, + }), + lumaCalendarHandle: server({ + env: "LUMA_CALENDAR_HANDLE", + optional: true, + }), discordBotToken: server({ env: "DISCORD_BOT_TOKEN", optional: true, @@ -22,6 +30,8 @@ const integrationsEnvConfig = configSchema("Integrations", { export const integrationsConfig = { resendApiKey: integrationsEnvConfig.server.resendApiKey, lumaApiKey: integrationsEnvConfig.server.lumaApiKey, + lumaCalendarApiId: integrationsEnvConfig.server.lumaCalendarApiId, + lumaCalendarHandle: integrationsEnvConfig.server.lumaCalendarHandle, discordBotToken: integrationsEnvConfig.server.discordBotToken, discordReviewChannelId: integrationsEnvConfig.server.discordReviewChannelId, }; From c10857e7c69ca3dc658bc49fa2269562b3daba35 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 17:15:19 -0800 Subject: [PATCH 4/6] Move Luma env schema and client into lib/luma --- app/src/lib/config.ts | 7 +- app/src/lib/integrations/config.ts | 15 -- app/src/lib/luma/config.ts | 22 +++ app/src/lib/luma/index.ts | 1 + app/src/lib/luma/luma.ts | 291 +++++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 20 deletions(-) create mode 100644 app/src/lib/luma/config.ts create mode 100644 app/src/lib/luma/index.ts create mode 100644 app/src/lib/luma/luma.ts diff --git a/app/src/lib/config.ts b/app/src/lib/config.ts index 40f795b..24c5842 100644 --- a/app/src/lib/config.ts +++ b/app/src/lib/config.ts @@ -3,6 +3,7 @@ import { databaseConfig } from "./database/config"; import { electricConfig } from "./electric/config"; import { instanceConfig } from "./instance/config"; import { integrationsConfig } from "./integrations/config"; +import { lumaConfig } from "./luma/config"; import { storageConfig } from "./storage/config"; export const mainConfig = { @@ -16,11 +17,7 @@ export const mainConfig = { resend: { apiKey: integrationsConfig.resendApiKey, }, - luma: { - apiKey: integrationsConfig.lumaApiKey, - calendarApiId: integrationsConfig.lumaCalendarApiId, - calendarHandle: integrationsConfig.lumaCalendarHandle, - }, + luma: lumaConfig, discord: { botToken: integrationsConfig.discordBotToken, reviewChannelId: integrationsConfig.discordReviewChannelId, diff --git a/app/src/lib/integrations/config.ts b/app/src/lib/integrations/config.ts index f45bfef..09da5dc 100644 --- a/app/src/lib/integrations/config.ts +++ b/app/src/lib/integrations/config.ts @@ -5,18 +5,6 @@ const integrationsEnvConfig = configSchema("Integrations", { env: "RESEND_API_KEY", optional: true, }), - lumaApiKey: server({ - env: "LUMA_API_KEY", - optional: true, - }), - lumaCalendarApiId: server({ - env: "LUMA_CALENDAR_API_ID", - optional: true, - }), - lumaCalendarHandle: server({ - env: "LUMA_CALENDAR_HANDLE", - optional: true, - }), discordBotToken: server({ env: "DISCORD_BOT_TOKEN", optional: true, @@ -29,9 +17,6 @@ const integrationsEnvConfig = configSchema("Integrations", { export const integrationsConfig = { resendApiKey: integrationsEnvConfig.server.resendApiKey, - lumaApiKey: integrationsEnvConfig.server.lumaApiKey, - lumaCalendarApiId: integrationsEnvConfig.server.lumaCalendarApiId, - lumaCalendarHandle: integrationsEnvConfig.server.lumaCalendarHandle, discordBotToken: integrationsEnvConfig.server.discordBotToken, discordReviewChannelId: integrationsEnvConfig.server.discordReviewChannelId, }; diff --git a/app/src/lib/luma/config.ts b/app/src/lib/luma/config.ts new file mode 100644 index 0000000..52f33bb --- /dev/null +++ b/app/src/lib/luma/config.ts @@ -0,0 +1,22 @@ +import { configSchema, server } from "better-env/config-schema"; + +const lumaEnvConfig = configSchema("Luma", { + apiKey: server({ + env: "LUMA_API_KEY", + optional: true, + }), + calendarApiId: server({ + env: "LUMA_CALENDAR_API_ID", + optional: true, + }), + calendarHandle: server({ + env: "LUMA_CALENDAR_HANDLE", + optional: true, + }), +}); + +export const lumaConfig = { + apiKey: lumaEnvConfig.server.apiKey, + calendarApiId: lumaEnvConfig.server.calendarApiId, + calendarHandle: lumaEnvConfig.server.calendarHandle, +}; diff --git a/app/src/lib/luma/index.ts b/app/src/lib/luma/index.ts new file mode 100644 index 0000000..eaa6c23 --- /dev/null +++ b/app/src/lib/luma/index.ts @@ -0,0 +1 @@ +export * from "./luma"; diff --git a/app/src/lib/luma/luma.ts b/app/src/lib/luma/luma.ts new file mode 100644 index 0000000..2f4349f --- /dev/null +++ b/app/src/lib/luma/luma.ts @@ -0,0 +1,291 @@ +import { mainConfig } from "@/lib/config"; + +type LumaGeoAddress = { + city: string; + type: "google" | "string"; + country: string; + latitude: number; + longitude: number; + place_id: string; + address: string; + description: string; + city_state: string; + full_address: string; +}; + +export type LumaEvent = { + api_id?: string; + app_id?: string; + calendar_api_id?: string; + created_at: string; + cover_url?: string | null; + name: string; + description?: string | null; + description_md?: string | null; + series_api_id?: string; + start_at: string; + duration_interval: string; + end_at: string; + geo_address_json?: LumaGeoAddress | null; + geo_latitude?: number | null; + geo_longitude?: number | null; + url: string; + timezone: string; + event_type: "independent" | "series"; + user_api_id: string; + visibility: "public" | "private"; + zoom_meeting_url?: string | null; + meeting_url?: string | null; +}; + +export type LumaHost = { + api_id: string; + name: string; + email: string; +}; + +export type LumaEventPayload = { + event: LumaEvent; + hosts: LumaHost[]; +}; + +export type LumaAttendee = { + api_id: string; + approval_status: "approved" | "declined" | "pending_approval" | "rejected"; + created_at: string; + registered_at: string; + user_api_id: string; + user_name: string; + user_email: string; + name: string; + email: string; +}; + +export type LumaClient = ReturnType; + +export function getLumaUrl(lumaEventId?: string | null): string | null { + if (!lumaEventId) return null; + return `https://lu.ma/event/${lumaEventId}`; +} + +export const createLumaClient = () => { + const apiKey = mainConfig.luma.apiKey; + + if (!apiKey) { + return { + getUpcomingEvents: async () => { + console.warn( + "Did not fetch upcoming events because LUMA_API_KEY is not set", + ); + return []; + }, + getCalendarEvents: async () => { + console.warn( + "Did not fetch calendar events because LUMA_API_KEY is not set", + ); + return []; + }, + getEvent: async () => { + console.warn("Did not fetch event because LUMA_API_KEY is not set"); + return null; + }, + getAttendees: async () => { + console.warn("Did not fetch attendees because LUMA_API_KEY is not set"); + return [[], { hasMoreToFetch: false, nextCursor: undefined }]; + }, + getAllAttendees: async () => { + console.warn( + "Did not fetch all attendees because LUMA_API_KEY is not set", + ); + return []; + }, + getAttendeeCount: async () => { + console.warn( + "Did not fetch attendee count because LUMA_API_KEY is not set", + ); + return 0; + }, + addAttendees: async () => { + console.warn("Did not add attendees because LUMA_API_KEY is not set"); + return undefined; + }, + }; + } + + const headers = { + accept: "application/json", + "x-luma-api-key": apiKey, + }; + + const parseCalendarEventsResponse = (resData: any): LumaEvent[] => { + const entries = resData?.entries ?? resData?.events?.entries ?? []; + if (!Array.isArray(entries)) return []; + return entries + .map((entry: any) => entry?.event ?? entry) + .filter(Boolean) as LumaEvent[]; + }; + + const getCalendarEvents = async ({ + limit = 10, + after, + before, + calendarApiId, + calendarHandle, + }: { + limit?: number; + after?: string; + before?: string; + calendarApiId?: string; + calendarHandle?: string; + } = {}) => { + const searchParams = new URLSearchParams({ + pagination_limit: String(limit), + }); + if (after) { + searchParams.append("after", after); + } + if (before) { + searchParams.append("before", before); + } + if (calendarApiId) { + searchParams.append("calendar_api_id", calendarApiId); + } + if (calendarHandle) { + searchParams.append("calendar_handle", calendarHandle); + } + + const url = `https://api.lu.ma/public/v1/calendar/list-events?${searchParams.toString()}`; + const res = await fetch(url, { + method: "GET", + headers, + }); + if (!res.ok) { + throw new Error( + `Failed to fetch upcoming events. Status: ${res.status} - ${res.statusText}`, + ); + } + const resData = await res.json(); + return parseCalendarEventsResponse(resData); + }; + + const getUpcomingEvents = async () => { + return getCalendarEvents({ + limit: 50, + after: new Date().toISOString(), + }); + }; + + const getEvent = async (eventId: string): Promise => { + const url = `https://api.lu.ma/public/v1/event/get?api_id=${eventId}`; + const res = await fetch(url, { + method: "GET", + headers, + }); + if (!res.ok) { + throw new Error( + `Failed to fetch event. Status: ${res.status} - ${res.statusText}`, + ); + } + return await res.json(); + }; + + const getAttendees = async ( + eventId: string, + options?: { + cursor?: string; + approvalStatus?: LumaAttendee["approval_status"]; + }, + ): Promise< + [ + LumaAttendee[], + { hasMoreToFetch: boolean; nextCursor: string | undefined }, + ] + > => { + const urlSearchParams = new URLSearchParams({ + event_api_id: eventId, + pagination_limit: "50", // 50 is the maximum limit + }); + if (options?.cursor) { + urlSearchParams.append("pagination_cursor", options.cursor); + } + if (options?.approvalStatus) { + urlSearchParams.append("approval_status", options.approvalStatus); + } + const url = `https://api.lu.ma/public/v1/event/get-guests?${urlSearchParams.toString()}`; + const res = await fetch(url, { + method: "GET", + headers, + }); + if (!res.ok) { + throw new Error( + `Failed to fetch attendees. Status: ${res.status} - ${res.statusText}`, + ); + } + const resData = await res.json(); + const attendees = resData.entries.map((e: any) => e.guest); + return [ + attendees, + { hasMoreToFetch: resData.has_more, nextCursor: resData.next_cursor }, + ]; + }; + + const getAllAttendees = async ( + eventId: string, + { + approvalStatus, + }: { approvalStatus?: LumaAttendee["approval_status"] } = {}, + ) => { + let attendees: LumaAttendee[] = []; + let hasMore = true; + let cursor: string | undefined; + while (hasMore) { + const [newAttendees, { hasMoreToFetch, nextCursor }] = await getAttendees( + eventId, + { cursor, approvalStatus }, + ); + attendees = [...attendees, ...newAttendees]; + hasMore = hasMoreToFetch; + cursor = nextCursor; + } + return attendees; + }; + + const getAttendeeCount = async (eventId: string) => { + const attendees = await getAllAttendees(eventId, { + approvalStatus: "approved", + }); + return attendees.length; + }; + + const addAttendees = async ( + eventId: string, + guests: { email: string; name: string | null }[], + ) => { + const url = `https://api.lu.ma/public/v1/event/add-guests`; + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + event_api_id: eventId, + guests, + }), + }); + if (!res.ok) { + const resData = await res.json(); + console.warn(resData); + throw new Error( + `Failed to add attendee. Status: ${res.status} - ${res.statusText}`, + ); + } + }; + + return { + getUpcomingEvents, + getCalendarEvents, + getEvent, + getAttendees, + getAllAttendees, + getAttendeeCount, + addAttendees, + }; +}; From 9f8e1e62065d9ec628bcbc0279694c3d6287fdd7 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 17:16:06 -0800 Subject: [PATCH 5/6] Refine AI prompt guidance for shortLocation formatting --- app/src/workflows/luma-sync/steps/ai.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/workflows/luma-sync/steps/ai.ts b/app/src/workflows/luma-sync/steps/ai.ts index cf4a0da..84acc99 100644 --- a/app/src/workflows/luma-sync/steps/ai.ts +++ b/app/src/workflows/luma-sync/steps/ai.ts @@ -42,7 +42,7 @@ export async function generateEventDraftWithAI({ model: getGatewayModel(), schema: aiSuggestedEventSchema, system: - "You convert Luma events to AllThingsWeb event records. Return realistic, concise values.", + "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. @@ -51,6 +51,7 @@ Requirements: - 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: From 1efb63ca4df9d0c7aca1bb079d7203c653d3cf72 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 22 Feb 2026 17:18:02 -0800 Subject: [PATCH 6/6] Replace runtime process.env reads with better-env config --- app/src/app/api/cron/luma-sync/route.ts | 2 +- app/src/lib/config.ts | 6 + app/src/lib/cron/config.ts | 12 + app/src/lib/integrations/config.ts | 10 + app/src/lib/luma.ts | 291 ------------------------ app/src/workflows/luma-sync/steps/ai.ts | 5 +- 6 files changed, 32 insertions(+), 294 deletions(-) create mode 100644 app/src/lib/cron/config.ts delete mode 100644 app/src/lib/luma.ts diff --git a/app/src/app/api/cron/luma-sync/route.ts b/app/src/app/api/cron/luma-sync/route.ts index 48809ae..74fe675 100644 --- a/app/src/app/api/cron/luma-sync/route.ts +++ b/app/src/app/api/cron/luma-sync/route.ts @@ -14,7 +14,7 @@ function isAuthorized(request: Request, cronSecret: string): boolean { } export async function GET(request: Request) { - const cronSecret = process.env.CRON_SECRET?.trim(); + const cronSecret = mainConfig.cron.secret?.trim(); if (!cronSecret) { return NextResponse.json( diff --git a/app/src/lib/config.ts b/app/src/lib/config.ts index 24c5842..fbd33b3 100644 --- a/app/src/lib/config.ts +++ b/app/src/lib/config.ts @@ -1,4 +1,5 @@ import { authConfig } from "./auth/config"; +import { cronConfig } from "./cron/config"; import { databaseConfig } from "./database/config"; import { electricConfig } from "./electric/config"; import { instanceConfig } from "./instance/config"; @@ -17,6 +18,11 @@ export const mainConfig = { resend: { apiKey: integrationsConfig.resendApiKey, }, + ai: { + gatewayApiKey: integrationsConfig.aiGatewayApiKey, + vercelOidcToken: integrationsConfig.vercelOidcToken, + }, + cron: cronConfig, luma: lumaConfig, discord: { botToken: integrationsConfig.discordBotToken, diff --git a/app/src/lib/cron/config.ts b/app/src/lib/cron/config.ts new file mode 100644 index 0000000..df07b8c --- /dev/null +++ b/app/src/lib/cron/config.ts @@ -0,0 +1,12 @@ +import { configSchema, server } from "better-env/config-schema"; + +const cronEnvConfig = configSchema("Cron", { + secret: server({ + env: "CRON_SECRET", + optional: true, + }), +}); + +export const cronConfig = { + secret: cronEnvConfig.server.secret, +}; diff --git a/app/src/lib/integrations/config.ts b/app/src/lib/integrations/config.ts index 09da5dc..c942e1f 100644 --- a/app/src/lib/integrations/config.ts +++ b/app/src/lib/integrations/config.ts @@ -5,6 +5,14 @@ const integrationsEnvConfig = configSchema("Integrations", { env: "RESEND_API_KEY", optional: true, }), + aiGatewayApiKey: server({ + env: "AI_GATEWAY_API_KEY", + optional: true, + }), + vercelOidcToken: server({ + env: "VERCEL_OIDC_TOKEN", + optional: true, + }), discordBotToken: server({ env: "DISCORD_BOT_TOKEN", optional: true, @@ -17,6 +25,8 @@ const integrationsEnvConfig = configSchema("Integrations", { export const integrationsConfig = { resendApiKey: integrationsEnvConfig.server.resendApiKey, + aiGatewayApiKey: integrationsEnvConfig.server.aiGatewayApiKey, + vercelOidcToken: integrationsEnvConfig.server.vercelOidcToken, discordBotToken: integrationsEnvConfig.server.discordBotToken, discordReviewChannelId: integrationsEnvConfig.server.discordReviewChannelId, }; diff --git a/app/src/lib/luma.ts b/app/src/lib/luma.ts deleted file mode 100644 index 2f4349f..0000000 --- a/app/src/lib/luma.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { mainConfig } from "@/lib/config"; - -type LumaGeoAddress = { - city: string; - type: "google" | "string"; - country: string; - latitude: number; - longitude: number; - place_id: string; - address: string; - description: string; - city_state: string; - full_address: string; -}; - -export type LumaEvent = { - api_id?: string; - app_id?: string; - calendar_api_id?: string; - created_at: string; - cover_url?: string | null; - name: string; - description?: string | null; - description_md?: string | null; - series_api_id?: string; - start_at: string; - duration_interval: string; - end_at: string; - geo_address_json?: LumaGeoAddress | null; - geo_latitude?: number | null; - geo_longitude?: number | null; - url: string; - timezone: string; - event_type: "independent" | "series"; - user_api_id: string; - visibility: "public" | "private"; - zoom_meeting_url?: string | null; - meeting_url?: string | null; -}; - -export type LumaHost = { - api_id: string; - name: string; - email: string; -}; - -export type LumaEventPayload = { - event: LumaEvent; - hosts: LumaHost[]; -}; - -export type LumaAttendee = { - api_id: string; - approval_status: "approved" | "declined" | "pending_approval" | "rejected"; - created_at: string; - registered_at: string; - user_api_id: string; - user_name: string; - user_email: string; - name: string; - email: string; -}; - -export type LumaClient = ReturnType; - -export function getLumaUrl(lumaEventId?: string | null): string | null { - if (!lumaEventId) return null; - return `https://lu.ma/event/${lumaEventId}`; -} - -export const createLumaClient = () => { - const apiKey = mainConfig.luma.apiKey; - - if (!apiKey) { - return { - getUpcomingEvents: async () => { - console.warn( - "Did not fetch upcoming events because LUMA_API_KEY is not set", - ); - return []; - }, - getCalendarEvents: async () => { - console.warn( - "Did not fetch calendar events because LUMA_API_KEY is not set", - ); - return []; - }, - getEvent: async () => { - console.warn("Did not fetch event because LUMA_API_KEY is not set"); - return null; - }, - getAttendees: async () => { - console.warn("Did not fetch attendees because LUMA_API_KEY is not set"); - return [[], { hasMoreToFetch: false, nextCursor: undefined }]; - }, - getAllAttendees: async () => { - console.warn( - "Did not fetch all attendees because LUMA_API_KEY is not set", - ); - return []; - }, - getAttendeeCount: async () => { - console.warn( - "Did not fetch attendee count because LUMA_API_KEY is not set", - ); - return 0; - }, - addAttendees: async () => { - console.warn("Did not add attendees because LUMA_API_KEY is not set"); - return undefined; - }, - }; - } - - const headers = { - accept: "application/json", - "x-luma-api-key": apiKey, - }; - - const parseCalendarEventsResponse = (resData: any): LumaEvent[] => { - const entries = resData?.entries ?? resData?.events?.entries ?? []; - if (!Array.isArray(entries)) return []; - return entries - .map((entry: any) => entry?.event ?? entry) - .filter(Boolean) as LumaEvent[]; - }; - - const getCalendarEvents = async ({ - limit = 10, - after, - before, - calendarApiId, - calendarHandle, - }: { - limit?: number; - after?: string; - before?: string; - calendarApiId?: string; - calendarHandle?: string; - } = {}) => { - const searchParams = new URLSearchParams({ - pagination_limit: String(limit), - }); - if (after) { - searchParams.append("after", after); - } - if (before) { - searchParams.append("before", before); - } - if (calendarApiId) { - searchParams.append("calendar_api_id", calendarApiId); - } - if (calendarHandle) { - searchParams.append("calendar_handle", calendarHandle); - } - - const url = `https://api.lu.ma/public/v1/calendar/list-events?${searchParams.toString()}`; - const res = await fetch(url, { - method: "GET", - headers, - }); - if (!res.ok) { - throw new Error( - `Failed to fetch upcoming events. Status: ${res.status} - ${res.statusText}`, - ); - } - const resData = await res.json(); - return parseCalendarEventsResponse(resData); - }; - - const getUpcomingEvents = async () => { - return getCalendarEvents({ - limit: 50, - after: new Date().toISOString(), - }); - }; - - const getEvent = async (eventId: string): Promise => { - const url = `https://api.lu.ma/public/v1/event/get?api_id=${eventId}`; - const res = await fetch(url, { - method: "GET", - headers, - }); - if (!res.ok) { - throw new Error( - `Failed to fetch event. Status: ${res.status} - ${res.statusText}`, - ); - } - return await res.json(); - }; - - const getAttendees = async ( - eventId: string, - options?: { - cursor?: string; - approvalStatus?: LumaAttendee["approval_status"]; - }, - ): Promise< - [ - LumaAttendee[], - { hasMoreToFetch: boolean; nextCursor: string | undefined }, - ] - > => { - const urlSearchParams = new URLSearchParams({ - event_api_id: eventId, - pagination_limit: "50", // 50 is the maximum limit - }); - if (options?.cursor) { - urlSearchParams.append("pagination_cursor", options.cursor); - } - if (options?.approvalStatus) { - urlSearchParams.append("approval_status", options.approvalStatus); - } - const url = `https://api.lu.ma/public/v1/event/get-guests?${urlSearchParams.toString()}`; - const res = await fetch(url, { - method: "GET", - headers, - }); - if (!res.ok) { - throw new Error( - `Failed to fetch attendees. Status: ${res.status} - ${res.statusText}`, - ); - } - const resData = await res.json(); - const attendees = resData.entries.map((e: any) => e.guest); - return [ - attendees, - { hasMoreToFetch: resData.has_more, nextCursor: resData.next_cursor }, - ]; - }; - - const getAllAttendees = async ( - eventId: string, - { - approvalStatus, - }: { approvalStatus?: LumaAttendee["approval_status"] } = {}, - ) => { - let attendees: LumaAttendee[] = []; - let hasMore = true; - let cursor: string | undefined; - while (hasMore) { - const [newAttendees, { hasMoreToFetch, nextCursor }] = await getAttendees( - eventId, - { cursor, approvalStatus }, - ); - attendees = [...attendees, ...newAttendees]; - hasMore = hasMoreToFetch; - cursor = nextCursor; - } - return attendees; - }; - - const getAttendeeCount = async (eventId: string) => { - const attendees = await getAllAttendees(eventId, { - approvalStatus: "approved", - }); - return attendees.length; - }; - - const addAttendees = async ( - eventId: string, - guests: { email: string; name: string | null }[], - ) => { - const url = `https://api.lu.ma/public/v1/event/add-guests`; - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - event_api_id: eventId, - guests, - }), - }); - if (!res.ok) { - const resData = await res.json(); - console.warn(resData); - throw new Error( - `Failed to add attendee. Status: ${res.status} - ${res.statusText}`, - ); - } - }; - - return { - getUpcomingEvents, - getCalendarEvents, - getEvent, - getAttendees, - getAllAttendees, - getAttendeeCount, - addAttendees, - }; -}; diff --git a/app/src/workflows/luma-sync/steps/ai.ts b/app/src/workflows/luma-sync/steps/ai.ts index 84acc99..1f29ed0 100644 --- a/app/src/workflows/luma-sync/steps/ai.ts +++ b/app/src/workflows/luma-sync/steps/ai.ts @@ -1,4 +1,5 @@ import { createGateway, generateObject } from "ai"; +import { mainConfig } from "@/lib/config"; import type { LumaEvent } from "@/lib/luma"; import { aiSuggestedEventSchema, @@ -9,8 +10,8 @@ import { const AI_MODEL = "openai/gpt-5.3-medium"; function getGatewayModel() { - const gatewayApiKey = process.env.AI_GATEWAY_API_KEY; - const oidcToken = process.env.VERCEL_OIDC_TOKEN; + const gatewayApiKey = mainConfig.ai.gatewayApiKey; + const oidcToken = mainConfig.ai.vercelOidcToken; if (!gatewayApiKey && !oidcToken) { throw new Error(