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/app/api/cron/luma-sync/route.ts b/app/src/app/api/cron/luma-sync/route.ts index 5f47bb4..74fe675 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"; @@ -8,25 +9,31 @@ 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 = mainConfig.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 }); } 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 d473d0a..fbd33b3 100644 --- a/app/src/lib/config.ts +++ b/app/src/lib/config.ts @@ -1,8 +1,10 @@ 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"; import { integrationsConfig } from "./integrations/config"; +import { lumaConfig } from "./luma/config"; import { storageConfig } from "./storage/config"; export const mainConfig = { @@ -16,8 +18,15 @@ export const mainConfig = { resend: { apiKey: integrationsConfig.resendApiKey, }, - luma: { - apiKey: integrationsConfig.lumaApiKey, + ai: { + gatewayApiKey: integrationsConfig.aiGatewayApiKey, + vercelOidcToken: integrationsConfig.vercelOidcToken, + }, + cron: cronConfig, + luma: lumaConfig, + discord: { + botToken: integrationsConfig.discordBotToken, + reviewChannelId: integrationsConfig.discordReviewChannelId, }, }; 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 cca0f1c..c942e1f 100644 --- a/app/src/lib/integrations/config.ts +++ b/app/src/lib/integrations/config.ts @@ -5,13 +5,28 @@ const integrationsEnvConfig = configSchema("Integrations", { env: "RESEND_API_KEY", optional: true, }), - lumaApiKey: server({ - env: "LUMA_API_KEY", + 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, + }), + discordReviewChannelId: server({ + env: "DISCORD_REVIEW_CHANNEL_ID", optional: true, }), }); export const integrationsConfig = { resendApiKey: integrationsEnvConfig.server.resendApiKey, - lumaApiKey: integrationsEnvConfig.server.lumaApiKey, + aiGatewayApiKey: integrationsEnvConfig.server.aiGatewayApiKey, + vercelOidcToken: integrationsEnvConfig.server.vercelOidcToken, + 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.ts b/app/src/lib/luma/luma.ts similarity index 100% rename from app/src/lib/luma.ts rename to app/src/lib/luma/luma.ts 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/ai.ts b/app/src/workflows/luma-sync/steps/ai.ts index cf4a0da..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( @@ -42,7 +43,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 +52,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: 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[];