diff --git a/drizzle/0049_slimy_puma.sql b/drizzle/0049_slimy_puma.sql new file mode 100644 index 0000000..fe74d27 --- /dev/null +++ b/drizzle/0049_slimy_puma.sql @@ -0,0 +1,110 @@ +CREATE TABLE "event_ticketing_settings" ( + "event_id" uuid PRIMARY KEY NOT NULL, + "mode" varchar(32) NOT NULL, + "provider" varchar(32), + "provider_account_id" text, + "currency" varchar(3), + "enabled" boolean DEFAULT true NOT NULL, + "legacy" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ticket_payments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reservation_id" uuid NOT NULL, + "provider" varchar(32) NOT NULL, + "provider_payment_id" text, + "provider_tx_id" text, + "checkout_id" text, + "status" varchar(32) NOT NULL, + "amount" integer NOT NULL, + "currency" varchar(3) NOT NULL, + "raw_event" jsonb, + "paid_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ticket_reservations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "tier_id" uuid, + "user_id" uuid, + "token" varchar(64), + "rsvp_id" uuid, + "provider" varchar(32) NOT NULL, + "provider_account_id" text, + "amount" integer NOT NULL, + "currency" varchar(3) NOT NULL, + "status" varchar(32) NOT NULL, + "checkout_id" text, + "answers_snapshot" jsonb, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "event_ticketing_settings" ADD CONSTRAINT "event_ticketing_settings_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ticket_payments" ADD CONSTRAINT "ticket_payments_reservation_id_ticket_reservations_id_fk" FOREIGN KEY ("reservation_id") REFERENCES "public"."ticket_reservations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ticket_reservations" ADD CONSTRAINT "ticket_reservations_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ticket_reservations" ADD CONSTRAINT "ticket_reservations_tier_id_event_tiers_id_fk" FOREIGN KEY ("tier_id") REFERENCES "public"."event_tiers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ticket_reservations" ADD CONSTRAINT "ticket_reservations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ticket_reservations" ADD CONSTRAINT "ticket_reservations_rsvp_id_rsvps_id_fk" FOREIGN KEY ("rsvp_id") REFERENCES "public"."rsvps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +INSERT INTO "event_ticketing_settings" ( + "event_id", + "mode", + "provider", + "provider_account_id", + "currency", + "enabled", + "legacy", + "created_at", + "updated_at" +) +SELECT + "events"."id", + CASE + WHEN "events"."external_url" IS NOT NULL AND "events"."external_url" <> '' THEN 'external' + WHEN EXISTS ( + SELECT 1 + FROM "event_tiers" + WHERE "event_tiers"."event_id" = "events"."id" + AND COALESCE("event_tiers"."price_amount", 0) > 0 + ) THEN 'paid' + ELSE 'free' + END, + CASE + WHEN EXISTS ( + SELECT 1 + FROM "event_tiers" + WHERE "event_tiers"."event_id" = "events"."id" + AND COALESCE("event_tiers"."price_amount", 0) > 0 + ) THEN 'portone' + ELSE NULL + END, + NULL, + CASE + WHEN EXISTS ( + SELECT 1 + FROM "event_tiers" + WHERE "event_tiers"."event_id" = "events"."id" + AND COALESCE("event_tiers"."price_amount", 0) > 0 + ) THEN 'KRW' + ELSE NULL + END, + CASE + WHEN "events"."external_url" IS NOT NULL AND "events"."external_url" <> '' THEN true + WHEN EXISTS ( + SELECT 1 + FROM "event_tiers" + WHERE "event_tiers"."event_id" = "events"."id" + AND COALESCE("event_tiers"."price_amount", 0) > 0 + ) THEN false + ELSE true + END, + true, + now(), + now() +FROM "events" +ON CONFLICT ("event_id") DO NOTHING; diff --git a/drizzle/meta/0049_snapshot.json b/drizzle/meta/0049_snapshot.json new file mode 100644 index 0000000..1a2517e --- /dev/null +++ b/drizzle/meta/0049_snapshot.json @@ -0,0 +1,3466 @@ +{ + "id": "efc7b288-f80c-443d-a529-52209097c3f2", + "prevId": "4539f0f6-c409-418d-ab91-e703714965b9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "activity_url": { + "name": "activity_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_post_id": { + "name": "reply_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "activity_logs_actor_id_actors_id_fk": { + "name": "activity_logs_actor_id_actors_id_fk", + "tableFrom": "activity_logs", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_logs_post_id_posts_id_fk": { + "name": "activity_logs_post_id_posts_id_fk", + "tableFrom": "activity_logs", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_logs_event_id_events_id_fk": { + "name": "activity_logs_event_id_events_id_fk", + "tableFrom": "activity_logs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_logs_reply_post_id_posts_id_fk": { + "name": "activity_logs_reply_post_id_posts_id_fk", + "tableFrom": "activity_logs", + "tableTo": "posts", + "columnsFrom": [ + "reply_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "activity_logs_actor_id_post_id_type_emoji_unique": { + "name": "activity_logs_actor_id_post_id_type_emoji_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_id", + "post_id", + "type", + "emoji" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "handle": { + "name": "handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'Person'" + }, + "actor_url": { + "name": "actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbox_url": { + "name": "outbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "following_url": { + "name": "following_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_local": { + "name": "is_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "manually_approves_followers": { + "name": "manually_approves_followers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "following_count": { + "name": "following_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "actors_user_id_users_id_fk": { + "name": "actors_user_id_users_id_fk", + "tableFrom": "actors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "actors_handle_unique": { + "name": "actors_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.banners": { + "name": "banners", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "requester": { + "name": "requester", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "h3_index": { + "name": "h3_index", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "hop_count": { + "name": "hop_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "impression_count": { + "name": "impression_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkins": { + "name": "checkins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkins_user_id_users_id_fk": { + "name": "checkins_user_id_users_id_fk", + "tableFrom": "checkins", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkins_place_id_places_id_fk": { + "name": "checkins_place_id_places_id_fk", + "tableFrom": "checkins", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.countries": { + "name": "countries", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "varchar(2)", + "primaryKey": true, + "notNull": true + }, + "alpha3": { + "name": "alpha3", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "geometry": { + "name": "geometry", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "bbox": { + "name": "bbox", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_categories": { + "name": "event_categories", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "emoji": { + "name": "emoji", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_favourites": { + "name": "event_favourites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_favourites_user_id_users_id_fk": { + "name": "event_favourites_user_id_users_id_fk", + "tableFrom": "event_favourites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_favourites_event_id_events_id_fk": { + "name": "event_favourites_event_id_events_id_fk", + "tableFrom": "event_favourites", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_favourites_user_id_event_id_unique": { + "name": "event_favourites_user_id_event_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "event_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_notices": { + "name": "event_notices", + "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 + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_notices_event_id_events_id_fk": { + "name": "event_notices_event_id_events_id_fk", + "tableFrom": "event_notices", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_notices_post_id_posts_id_fk": { + "name": "event_notices_post_id_posts_id_fk", + "tableFrom": "event_notices", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_notices_sent_by_user_id_users_id_fk": { + "name": "event_notices_sent_by_user_id_users_id_fk", + "tableFrom": "event_notices", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_organizers": { + "name": "event_organizers", + "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 + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "event_organizers_event_id_events_id_fk": { + "name": "event_organizers_event_id_events_id_fk", + "tableFrom": "event_organizers", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_organizers_actor_id_actors_id_fk": { + "name": "event_organizers_actor_id_actors_id_fk", + "tableFrom": "event_organizers", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_questions": { + "name": "event_questions", + "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 + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_questions_event_id_events_id_fk": { + "name": "event_questions_event_id_events_id_fk", + "tableFrom": "event_questions", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_tags": { + "name": "event_tags", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "event_tags_event_id_events_id_fk": { + "name": "event_tags_event_id_events_id_fk", + "tableFrom": "event_tags", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_tags_tag_id_tags_id_fk": { + "name": "event_tags_tag_id_tags_id_fk", + "tableFrom": "event_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_tags_event_id_tag_id_pk": { + "name": "event_tags_event_id_tag_id_pk", + "columns": [ + "event_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_ticketing_settings": { + "name": "event_ticketing_settings", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "legacy": { + "name": "legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_ticketing_settings_event_id_events_id_fk": { + "name": "event_ticketing_settings_event_id_events_id_fk", + "tableFrom": "event_ticketing_settings", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_tiers": { + "name": "event_tiers", + "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": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "price_amount": { + "name": "price_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "opens_at": { + "name": "opens_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closes_at": { + "name": "closes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_tiers_event_id_events_id_fk": { + "name": "event_tiers_event_id_events_id_fk", + "tableFrom": "event_tiers", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organizer_id": { + "name": "organizer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_actor_id": { + "name": "group_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "venue_detail": { + "name": "venue_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'in_person'" + }, + "meeting_url": { + "name": "meeting_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_image_url": { + "name": "header_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_anonymous_rsvp": { + "name": "allow_anonymous_rsvp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "anonymous_contact_fields": { + "name": "anonymous_contact_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "events_organizer_id_users_id_fk": { + "name": "events_organizer_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "organizer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_group_actor_id_actors_id_fk": { + "name": "events_group_actor_id_actors_id_fk", + "tableFrom": "events", + "tableTo": "actors", + "columnsFrom": [ + "group_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_category_id_event_categories_slug_fk": { + "name": "events_category_id_event_categories_slug_fk", + "tableFrom": "events", + "tableTo": "event_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_place_id_places_id_fk": { + "name": "events_place_id_places_id_fk", + "tableFrom": "events", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "follows_follower_id_actors_id_fk": { + "name": "follows_follower_id_actors_id_fk", + "tableFrom": "follows", + "tableTo": "actors", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "follows_following_id_actors_id_fk": { + "name": "follows_following_id_actors_id_fk", + "tableFrom": "follows", + "tableTo": "actors", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_members": { + "name": "group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_actor_id": { + "name": "group_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_actor_id": { + "name": "member_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "group_members_group_actor_id_actors_id_fk": { + "name": "group_members_group_actor_id_actors_id_fk", + "tableFrom": "group_members", + "tableTo": "actors", + "columnsFrom": [ + "group_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "group_members_member_actor_id_actors_id_fk": { + "name": "group_members_member_actor_id_actors_id_fk", + "tableFrom": "group_members", + "tableTo": "actors", + "columnsFrom": [ + "member_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_places": { + "name": "group_places", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_actor_id": { + "name": "group_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "group_places_group_actor_id_actors_id_fk": { + "name": "group_places_group_actor_id_actors_id_fk", + "tableFrom": "group_places", + "tableTo": "actors", + "columnsFrom": [ + "group_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "group_places_place_id_places_id_fk": { + "name": "group_places_place_id_places_id_fk", + "tableFrom": "group_places", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "group_places_assigned_by_user_id_users_id_fk": { + "name": "group_places_assigned_by_user_id_users_id_fk", + "tableFrom": "group_places", + "tableTo": "users", + "columnsFrom": [ + "assigned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_places_group_actor_id_place_id_unique": { + "name": "group_places_group_actor_id_place_id_unique", + "nullsNotDistinct": false, + "columns": [ + "group_actor_id", + "place_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keypairs": { + "name": "keypairs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "algorithm": { + "name": "algorithm", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "keypairs_actor_id_actors_id_fk": { + "name": "keypairs_actor_id_actors_id_fk", + "tableFrom": "keypairs", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenges": { + "name": "otp_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "handle": { + "name": "handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expected_emojis": { + "name": "expected_emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_url": { + "name": "actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenges_question_id_unique": { + "name": "otp_challenges_question_id_unique", + "nullsNotDistinct": false, + "columns": [ + "question_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_votes": { + "name": "otp_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "challenge_id": { + "name": "challenge_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "voter_actor_url": { + "name": "voter_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "otp_votes_challenge_id_otp_challenges_id_fk": { + "name": "otp_votes_challenge_id_otp_challenges_id_fk", + "tableFrom": "otp_votes", + "tableTo": "otp_challenges", + "columnsFrom": [ + "challenge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_votes_challenge_id_emoji_unique": { + "name": "otp_votes_challenge_id_emoji_unique", + "nullsNotDistinct": false, + "columns": [ + "challenge_id", + "emoji" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.place_audit_log": { + "name": "place_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_actor_id": { + "name": "group_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "place_audit_log_place_id_places_id_fk": { + "name": "place_audit_log_place_id_places_id_fk", + "tableFrom": "place_audit_log", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "place_audit_log_group_actor_id_actors_id_fk": { + "name": "place_audit_log_group_actor_id_actors_id_fk", + "tableFrom": "place_audit_log", + "tableTo": "actors", + "columnsFrom": [ + "group_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "place_audit_log_user_id_users_id_fk": { + "name": "place_audit_log_user_id_users_id_fk", + "tableFrom": "place_audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.place_categories": { + "name": "place_categories", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "emoji": { + "name": "emoji", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "parent_slug": { + "name": "parent_slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "place_categories_parent_slug_place_categories_slug_fk": { + "name": "place_categories_parent_slug_place_categories_slug_fk", + "tableFrom": "place_categories", + "tableTo": "place_categories", + "columnsFrom": [ + "parent_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.place_tags": { + "name": "place_tags", + "schema": "", + "columns": { + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "place_tags_place_id_places_id_fk": { + "name": "place_tags_place_id_places_id_fk", + "tableFrom": "place_tags", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "place_tags_tag_id_tags_id_fk": { + "name": "place_tags_tag_id_tags_id_fk", + "tableFrom": "place_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "place_tags_place_id_tag_id_pk": { + "name": "place_tags_place_id_tag_id_pk", + "columns": [ + "place_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.places": { + "name": "places", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "map_image_url": { + "name": "map_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "places_category_id_place_categories_slug_fk": { + "name": "places_category_id_place_categories_slug_fk", + "tableFrom": "places", + "tableTo": "place_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "places_created_by_id_users_id_fk": { + "name": "places_created_by_id_users_id_fk", + "tableFrom": "places", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_options": { + "name": "poll_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "poll_options_poll_id_polls_id_fk": { + "name": "poll_options_poll_id_polls_id_fk", + "tableFrom": "poll_options", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "voter_actor_url": { + "name": "voter_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_instance": { + "name": "source_instance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "poll_votes_poll_id_polls_id_fk": { + "name": "poll_votes_poll_id_polls_id_fk", + "tableFrom": "poll_votes", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "poll_votes_option_id_poll_options_id_fk": { + "name": "poll_votes_option_id_poll_options_id_fk", + "tableFrom": "poll_votes", + "tableTo": "poll_options", + "columnsFrom": [ + "option_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "poll_votes_poll_id_option_id_voter_actor_url_unique": { + "name": "poll_votes_poll_id_option_id_voter_actor_url_unique", + "nullsNotDistinct": false, + "columns": [ + "poll_id", + "option_id", + "voter_actor_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.polls": { + "name": "polls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_actor_id": { + "name": "group_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "polls_group_actor_id_actors_id_fk": { + "name": "polls_group_actor_id_actors_id_fk", + "tableFrom": "polls", + "tableTo": "actors", + "columnsFrom": [ + "group_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "polls_question_id_unique": { + "name": "polls_question_id_unique", + "nullsNotDistinct": false, + "columns": [ + "question_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ap_uri": { + "name": "ap_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to_post_id": { + "name": "in_reply_to_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "thread_root_id": { + "name": "thread_root_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "thread_status": { + "name": "thread_status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "last_replied_at": { + "name": "last_replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'public'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_actor_id_actors_id_fk": { + "name": "posts_actor_id_actors_id_fk", + "tableFrom": "posts", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "posts_event_id_events_id_fk": { + "name": "posts_event_id_events_id_fk", + "tableFrom": "posts", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "posts_in_reply_to_post_id_posts_id_fk": { + "name": "posts_in_reply_to_post_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "in_reply_to_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "posts_thread_root_id_posts_id_fk": { + "name": "posts_thread_root_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "thread_root_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rsvp_answers": { + "name": "rsvp_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rsvp_id": { + "name": "rsvp_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rsvp_answers_rsvp_id_rsvps_id_fk": { + "name": "rsvp_answers_rsvp_id_rsvps_id_fk", + "tableFrom": "rsvp_answers", + "tableTo": "rsvps", + "columnsFrom": [ + "rsvp_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rsvp_answers_user_id_users_id_fk": { + "name": "rsvp_answers_user_id_users_id_fk", + "tableFrom": "rsvp_answers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rsvp_answers_event_id_events_id_fk": { + "name": "rsvp_answers_event_id_events_id_fk", + "tableFrom": "rsvp_answers", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rsvp_answers_question_id_event_questions_id_fk": { + "name": "rsvp_answers_question_id_event_questions_id_fk", + "tableFrom": "rsvp_answers", + "tableTo": "event_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rsvp_answers_rsvp_id_question_id_unique": { + "name": "rsvp_answers_rsvp_id_question_id_unique", + "nullsNotDistinct": false, + "columns": [ + "rsvp_id", + "question_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rsvps": { + "name": "rsvps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rsvps_user_id_users_id_fk": { + "name": "rsvps_user_id_users_id_fk", + "tableFrom": "rsvps", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rsvps_event_id_events_id_fk": { + "name": "rsvps_event_id_events_id_fk", + "tableFrom": "rsvps", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rsvps_tier_id_event_tiers_id_fk": { + "name": "rsvps_tier_id_event_tiers_id_fk", + "tableFrom": "rsvps", + "tableTo": "event_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ticket_payments": { + "name": "ticket_payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reservation_id": { + "name": "reservation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tx_id": { + "name": "provider_tx_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "raw_event": { + "name": "raw_event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ticket_payments_reservation_id_ticket_reservations_id_fk": { + "name": "ticket_payments_reservation_id_ticket_reservations_id_fk", + "tableFrom": "ticket_payments", + "tableTo": "ticket_reservations", + "columnsFrom": [ + "reservation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ticket_reservations": { + "name": "ticket_reservations", + "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 + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "rsvp_id": { + "name": "rsvp_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answers_snapshot": { + "name": "answers_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ticket_reservations_event_id_events_id_fk": { + "name": "ticket_reservations_event_id_events_id_fk", + "tableFrom": "ticket_reservations", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ticket_reservations_tier_id_event_tiers_id_fk": { + "name": "ticket_reservations_tier_id_event_tiers_id_fk", + "tableFrom": "ticket_reservations", + "tableTo": "event_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ticket_reservations_user_id_users_id_fk": { + "name": "ticket_reservations_user_id_users_id_fk", + "tableFrom": "ticket_reservations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ticket_reservations_rsvp_id_rsvps_id_fk": { + "name": "ticket_reservations_rsvp_id_rsvps_id_fk", + "tableFrom": "ticket_reservations", + "tableTo": "rsvps", + "columnsFrom": [ + "rsvp_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fediverse_accounts": { + "name": "user_fediverse_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fediverse_handle": { + "name": "fediverse_handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "proxy_handle": { + "name": "proxy_handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_fediverse_accounts_user_id_users_id_fk": { + "name": "user_fediverse_accounts_user_id_users_id_fk", + "tableFrom": "user_fediverse_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_fediverse_accounts_fediverse_handle_unique": { + "name": "user_fediverse_accounts_fediverse_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "fediverse_handle" + ] + }, + "user_fediverse_accounts_proxy_handle_unique": { + "name": "user_fediverse_accounts_proxy_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "proxy_handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "handle": { + "name": "handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "fediverse_handle": { + "name": "fediverse_handle", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_source_hash": { + "name": "avatar_source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendar_token": { + "name": "calendar_token", + "type": "varchar(64)", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_handle_unique": { + "name": "users_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + }, + "users_calendar_token_unique": { + "name": "users_calendar_token_unique", + "nullsNotDistinct": false, + "columns": [ + "calendar_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dce7be2..9cd07ea 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1776408696065, "tag": "0048_wonderful_nehzno", "breakpoints": true + }, + { + "idx": 49, + "version": "7", + "when": 1777293520020, + "tag": "0049_slimy_puma", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 4785887..a0b6701 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "vite dev", "build": "vite build", "start": "node .output/server/index.mjs", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint src", "db:generate": "drizzle-kit generate", @@ -78,6 +80,7 @@ "tsx": "^4.21.0", "typescript": "^5", "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f7cb27..f9d5c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) packages: @@ -1030,72 +1033,84 @@ packages: engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.1': resolution: {integrity: sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==} engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.1': resolution: {integrity: sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==} engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.1': resolution: {integrity: sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==} engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.1': resolution: {integrity: sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==} engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.1': resolution: {integrity: sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==} engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.2': resolution: {integrity: sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.2': resolution: {integrity: sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==} engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.2': resolution: {integrity: sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==} engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.2': resolution: {integrity: sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.2': resolution: {integrity: sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.2': resolution: {integrity: sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.2': resolution: {integrity: sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==} @@ -2112,24 +2127,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.5': resolution: {integrity: sha512-WFljyDkxtXRlWxMjxeegf7xMYXxUr8u7JdXlOEWKYgDqEgxUnSEsVDxBiNWQ1D5kQKwf8Wo4sVKEYPRhCdsjwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.5': resolution: {integrity: sha512-CUlplTujmbDWp2gamvrqVKi2Or8lmngXT1WxsizJfts7JrvfGhZObciaY/+CbdbS9qNnskvwMZNEhTPrn7b+WA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.5': resolution: {integrity: sha512-wdf7g9NbVZCeAo2iGhsjJb7I8ZFfs6X8bumfrWg82VK+8P6AlLXwk48a1ASiJQDTS7Svq2xVzZg3sGO2aXpHRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.5': resolution: {integrity: sha512-0CWY7ubu12nhzz+tkpHjoG3IRSTlWYe0wrfJRf4qqjqQSGtAYgoL9kwzdvlhaFdZ5ffVeyYw9qLsChcjUMEloQ==} @@ -2197,66 +2216,79 @@ packages: resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.58.0': resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.58.0': resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.58.0': resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.58.0': resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.58.0': resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.58.0': resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.58.0': resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.58.0': resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.58.0': resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.58.0': resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.58.0': resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.58.0': resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.58.0': resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} @@ -2511,6 +2543,9 @@ packages: resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -2553,24 +2588,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.0': resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.0': resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.0': resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.0': resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} @@ -2812,6 +2851,12 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2877,6 +2922,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2921,6 +2995,10 @@ packages: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3008,6 +3086,10 @@ packages: resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} hasBin: true + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3367,6 +3449,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} @@ -3445,10 +3530,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -3796,24 +3888,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -4008,6 +4104,9 @@ packages: observable-fns@0.6.1: resolution: {integrity: sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@2.0.0-alpha.3: resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} @@ -4380,6 +4479,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4419,9 +4521,15 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + staticmaps@1.13.1: resolution: {integrity: sha512-lywUJN8uJFuGgrucMmAOzzNAO7lOruP9NGmG0demPjwtNdQ+NUw/jCH7nXlgL72i05NA+IznVsQ564mZ9Dt4DQ==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4465,10 +4573,21 @@ packages: tiny-worker@2.3.0: resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4717,6 +4836,47 @@ packages: vite: optional: true + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -4740,6 +4900,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7317,6 +7482,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -7713,6 +7880,13 @@ snapshots: '@types/node': 22.19.11 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -7785,6 +7959,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -7825,6 +8040,8 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -7910,6 +8127,8 @@ snapshots: canonicalize@2.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8179,6 +8398,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.1.0: {} + es-toolkit@1.44.0: {} esbuild-register@3.6.0(esbuild@0.25.12): @@ -8346,8 +8567,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} @@ -8848,6 +9075,8 @@ snapshots: observable-fns@0.6.1: {} + obug@2.1.1: {} + ofetch@2.0.0-alpha.3: {} ohash@2.0.11: {} @@ -9330,6 +9559,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -9355,6 +9586,8 @@ snapshots: srvx@0.11.7: {} + stackback@0.0.2: {} + staticmaps@1.13.1: dependencies: '@babel/runtime': 7.28.6 @@ -9371,6 +9604,8 @@ snapshots: modern-async: 1.1.5 sharp: 0.33.2 + std-env@4.1.0: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -9415,11 +9650,17 @@ snapshots: esm: 3.2.25 optional: true + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9549,6 +9790,34 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.11 + transitivePeerDependencies: + - msw + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -9567,6 +9836,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/src/routes/events/$eventId/register.tsx b/src/routes/events/$eventId/register.tsx index 9b8d648..82beeea 100644 --- a/src/routes/events/$eventId/register.tsx +++ b/src/routes/events/$eventId/register.tsx @@ -22,6 +22,7 @@ export const Route = createFileRoute("/events/$eventId/register")({ validateSearch: zodValidator( z.object({ token: z.string().optional(), + payment: z.enum(["success", "cancel"]).optional(), }), ), }); @@ -78,7 +79,7 @@ type EventMeta = { function RegisterPage() { const { eventId } = Route.useParams(); - const { token: urlToken } = Route.useSearch(); + const { token: urlToken, payment } = Route.useSearch(); const navigate = useNavigate(); const [rsvpData, setRsvpData] = useState(null); @@ -164,9 +165,48 @@ function RegisterPage() { const { tiers, questions } = rsvpData; const event = eventMeta.event; const contactConfig = rsvpData.anonymousContactFields; + const existingRsvp = rsvpData.userRsvp; + + if (payment === "success" && !existingRsvp) { + return ( +
+ + +
+ +
+

Payment is being confirmed

+

+ Your payment was completed. Refresh this page in a moment if your ticket does not appear. +

+ +
+
+
+ ); + } + + if (payment === "cancel") { + return ( +
+ + +

Payment was cancelled

+

+ Your registration has not been confirmed. You can try again when you're ready. +

+ +
+
+
+ ); + } // Already registered — show status instead of form - const existingRsvp = rsvpData.userRsvp; if (existingRsvp && (existingRsvp.status === "accepted" || existingRsvp.status === "waitlisted")) { const existingTier = tiers.find((t) => t.id === existingRsvp.tierId); return ( @@ -328,6 +368,10 @@ function RegisterPage() { setSubmitting(false); return; } + if (result.requiresPayment && result.checkoutUrl) { + window.location.href = result.checkoutUrl; + return; + } setResultStatus(result.status); if (result.token) { setAnonToken(result.token); diff --git a/src/server-entry.ts b/src/server-entry.ts index 26f8a70..781208f 100644 --- a/src/server-entry.ts +++ b/src/server-entry.ts @@ -111,6 +111,7 @@ import { GET as hackerspubGraphqlCallback } from "./server/controllers/auth/hack import { POST as hackerspubGraphqlCallbackApi } from "./server/controllers/auth/hackerspub/graphql-callback-api" import { startHackersPubCleanupInterval } from "./server/hackerspub-sessions" import { startGdprCleanupInterval } from "./server/events/gdpr-cleanup" +import { POST as ticketPaymentCallback } from "./server/controllers/ticket-payments/callback" const startFetch = createStartHandler(defaultStreamHandler); @@ -259,6 +260,10 @@ apiRouter.post("/auth/hackerspub/graphql-callback", defineEventHandler(async (ev return hackerspubGraphqlCallbackApi({ request: toWebRequest(event) }); })); +apiRouter.post("/ticket-payment-callbacks", defineEventHandler(async (event) => { + return ticketPaymentCallback({ request: toWebRequest(event) }); +})); + apiRouter.get("/session", defineEventHandler(async (event) => { return getMe({ request: toWebRequest(event) }); })); diff --git a/src/server/controllers/events/create.ts b/src/server/controllers/events/create.ts index aff671d..3b38bdb 100644 --- a/src/server/controllers/events/create.ts +++ b/src/server/controllers/events/create.ts @@ -7,6 +7,8 @@ import { announceEvent } from "~/server/fediverse/category"; import { reverseGeocodeCountry } from "~/server/geo/reverse-geocode"; import { getEventCategories } from "~/server/events/categories"; import { sanitizeContactFields } from "~/server/events/rsvp-helpers"; +import { deriveNewEventTicketingSettings } from "~/server/services/ticketing"; +import * as EventTicketingSettingsRepo from "~/server/repositories/event-ticketing-settings"; export const POST = async ({ request }: { request: Request }) => { const user = await getSessionUser(request); @@ -223,6 +225,20 @@ export const POST = async ({ request }: { request: Request }) => { : [{ eventId: event.id, name: "General", sortOrder: 0 }]; await db.insert(eventTiers).values(tiersToInsert); + const ticketingSettings = deriveNewEventTicketingSettings( + tiersToInsert.map((tier) => ({ priceAmount: "priceAmount" in tier ? tier.priceAmount : null })), + process.env.DEFAULT_PORTONE_PROVIDER_ACCOUNT_ID, + ); + await EventTicketingSettingsRepo.upsert({ + eventId: event.id, + mode: ticketingSettings.mode, + provider: ticketingSettings.provider, + providerAccountId: ticketingSettings.providerAccountId, + currency: ticketingSettings.currency, + enabled: ticketingSettings.enabled, + legacy: false, + }); + // Insert survey questions if (body.questions && body.questions.length > 0) { await db.insert(eventQuestions).values( diff --git a/src/server/controllers/events/rsvp.ts b/src/server/controllers/events/rsvp.ts index 9f26f4b..a1171b3 100644 --- a/src/server/controllers/events/rsvp.ts +++ b/src/server/controllers/events/rsvp.ts @@ -1,9 +1,14 @@ import { eq, and, sql } from "drizzle-orm"; import { db } from "~/server/db/client"; -import { rsvps, rsvpAnswers, events } from "~/server/db/schema"; +import { rsvps, rsvpAnswers, events, eventTiers } from "~/server/db/schema"; import { getSessionUser } from "~/server/auth"; import { autoPromoteWaitlist } from "~/server/events/waitlist"; import { resolveTier, validateRequiredAnswers, checkCapacityAndDetermineStatus } from "~/server/events/rsvp-helpers"; +import { startPaidRegistration, TicketingError } from "~/server/services/ticketing"; +import { createTicketPaymentClientFromEnv } from "~/server/services/ticket-payment-client"; +import * as EventTicketingSettingsRepo from "~/server/repositories/event-ticketing-settings"; +import * as TicketReservationsRepo from "~/server/repositories/ticket-reservations"; +import * as TicketPaymentsRepo from "~/server/repositories/ticket-payments"; export const POST = async ({ request }: { request: Request }) => { const user = await getSessionUser(request); @@ -28,7 +33,7 @@ export const POST = async ({ request }: { request: Request }) => { // Verify event exists const [event] = await db - .select({ id: events.id, startsAt: events.startsAt }) + .select({ id: events.id, title: events.title, startsAt: events.startsAt }) .from(events) .where(eq(events.id, body.eventId)) .limit(1); @@ -69,6 +74,108 @@ export const POST = async ({ request }: { request: Request }) => { } } + if (body.status === "accepted" && resolvedTierId) { + const [tier] = await db + .select({ id: eventTiers.id, name: eventTiers.name, priceAmount: eventTiers.priceAmount }) + .from(eventTiers) + .where(and(eq(eventTiers.id, resolvedTierId), eq(eventTiers.eventId, body.eventId))) + .limit(1); + + if (tier && (tier.priceAmount ?? 0) > 0) { + try { + const paymentClient = createTicketPaymentClientFromEnv(); + const url = new URL(request.url); + const result = await startPaidRegistration( + { + event: { id: event.id, title: event.title }, + tier: { id: tier.id, name: tier.name, priceAmount: tier.priceAmount }, + userId: user.id, + customer: {}, + answers: body.answers ?? [], + baseUrl: url.origin, + callbackUrl: `${url.origin}/api/ticket-payment-callbacks`, + }, + { + getTicketingSetting: async (eventId) => { + const setting = await EventTicketingSettingsRepo.findByEventId(eventId); + return setting + ? { + eventId: setting.eventId, + mode: setting.mode as "free" | "paid" | "external", + provider: setting.provider as "portone" | "opencollective" | null, + providerAccountId: setting.providerAccountId, + currency: setting.currency, + enabled: setting.enabled, + } + : null; + }, + createReservation: async (values) => { + const reservation = await TicketReservationsRepo.insert({ + eventId: values.eventId, + tierId: values.tierId, + userId: values.userId, + token: null, + rsvpId: null, + provider: values.provider, + providerAccountId: values.providerAccountId, + amount: values.amount, + currency: values.currency, + status: values.status, + checkoutId: null, + answersSnapshot: values.answersSnapshot, + expiresAt: values.expiresAt, + }); + return { + id: reservation.id, + eventId: reservation.eventId, + tierId: reservation.tierId!, + userId: reservation.userId!, + provider: reservation.provider, + providerAccountId: reservation.providerAccountId, + amount: reservation.amount, + currency: reservation.currency, + status: "pending_payment", + checkoutId: reservation.checkoutId, + answersSnapshot: reservation.answersSnapshot as Array<{ questionId: string; answer: string }>, + expiresAt: reservation.expiresAt, + }; + }, + setReservationCheckoutId: async (reservationId, checkoutId) => { + await TicketReservationsRepo.setCheckoutId(reservationId, checkoutId); + }, + createPayment: async (values) => { + await TicketPaymentsRepo.insert({ + reservationId: values.reservationId, + provider: values.provider, + providerPaymentId: null, + providerTxId: null, + checkoutId: values.checkoutId, + status: values.status, + amount: values.amount, + currency: values.currency, + rawEvent: null, + paidAt: null, + }); + }, + createCheckout: (input) => paymentClient.createCheckout(input), + now: () => new Date(), + addMinutes: (date, minutes) => new Date(date.getTime() + minutes * 60_000), + }, + ); + + if (result.requiresPayment) { + return Response.json({ ok: true, ...result }); + } + } catch (err) { + if (err instanceof TicketingError) { + return Response.json({ error: err.message }, { status: 400 }); + } + const message = err instanceof Error ? err.message : "Failed to start payment"; + return Response.json({ error: message }, { status: 502 }); + } + } + } + try { let finalStatus: string = body.status; diff --git a/src/server/controllers/events/update.ts b/src/server/controllers/events/update.ts index 5de9426..95711ab 100644 --- a/src/server/controllers/events/update.ts +++ b/src/server/controllers/events/update.ts @@ -8,6 +8,8 @@ import { sanitizeContactFields } from "~/server/events/rsvp-helpers"; import { persistRemoteActor } from "~/server/fediverse/resolve"; import { reverseGeocodeCountry } from "~/server/geo/reverse-geocode"; import { optional } from "~/server/controllers/utils"; +import { deriveNewEventTicketingSettings } from "~/server/services/ticketing"; +import * as EventTicketingSettingsRepo from "~/server/repositories/event-ticketing-settings"; export const POST = async ({ request }: { request: Request }) => { const user = await getSessionUser(request); @@ -394,6 +396,24 @@ export const POST = async ({ request }: { request: Request }) => { sortOrder: 0, }); } + + const currentTiers = await db + .select({ priceAmount: eventTiers.priceAmount }) + .from(eventTiers) + .where(eq(eventTiers.eventId, event.id)); + const ticketingSettings = deriveNewEventTicketingSettings( + currentTiers, + process.env.DEFAULT_PORTONE_PROVIDER_ACCOUNT_ID, + ); + await EventTicketingSettingsRepo.upsert({ + eventId: event.id, + mode: ticketingSettings.mode, + provider: ticketingSettings.provider, + providerAccountId: ticketingSettings.providerAccountId, + currency: ticketingSettings.currency, + enabled: ticketingSettings.enabled, + legacy: false, + }); } // Reconcile organizers (replace all when provided) diff --git a/src/server/controllers/ticket-payments/callback.ts b/src/server/controllers/ticket-payments/callback.ts new file mode 100644 index 0000000..003d706 --- /dev/null +++ b/src/server/controllers/ticket-payments/callback.ts @@ -0,0 +1,186 @@ +import { and, eq, sql } from "drizzle-orm"; +import { db } from "~/server/db/client"; +import { eventTiers, rsvpAnswers, rsvps, ticketPayments, ticketReservations } from "~/server/db/schema"; +import { checkCapacityAndDetermineStatus } from "~/server/events/rsvp-helpers"; +import { + parseTicketPaymentPaidCallback, + verifyTicketPaymentCallbackSignature, +} from "~/server/services/ticket-payment-callback"; + +type ReservationRow = { + id: string; + eventId: string; + tierId: string | null; + userId: string | null; + status: string; + checkoutId: string | null; + provider: string; + amount: number; + currency: string; + answersSnapshot: unknown; + expiresAt: Date; +}; + +export const POST = async ({ request }: { request: Request }) => { + const secret = process.env.TICKET_PAYMENT_CALLBACK_SECRET; + if (!secret) { + return Response.json({ error: "Ticket payment callback is not configured" }, { status: 500 }); + } + + const rawBody = await request.text(); + const signature = request.headers.get("x-ticket-payment-signature"); + if (!verifyTicketPaymentCallbackSignature(rawBody, signature, secret)) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const payload = parseTicketPaymentPaidCallback(rawBody); + if (!payload) { + return Response.json({ error: "Invalid callback payload" }, { status: 400 }); + } + + try { + let rsvpId: string | null = null; + + await db.transaction(async (tx) => { + const rows = await tx.execute(sql` + SELECT + id, + event_id AS "eventId", + tier_id AS "tierId", + user_id AS "userId", + status, + checkout_id AS "checkoutId", + provider, + amount, + currency, + answers_snapshot AS "answersSnapshot", + expires_at AS "expiresAt" + FROM ticket_reservations + WHERE id = ${payload.reservationId} + LIMIT 1 + FOR UPDATE + `); + const reservation = rows.rows[0] as ReservationRow | undefined; + + if (!reservation) { + throw new Response(JSON.stringify({ error: "Reservation not found" }), { status: 404 }); + } + if (reservation.status === "confirmed") { + return; + } + if ( + reservation.status !== "pending_payment" || + reservation.checkoutId !== payload.checkoutId || + reservation.provider !== payload.provider || + reservation.amount !== payload.amount || + reservation.currency !== payload.currency + ) { + throw new Response(JSON.stringify({ error: "Callback does not match reservation" }), { status: 409 }); + } + if (reservation.expiresAt <= new Date()) { + await tx + .update(ticketReservations) + .set({ status: "expired", updatedAt: new Date() }) + .where(eq(ticketReservations.id, reservation.id)); + throw new Response(JSON.stringify({ error: "Reservation expired" }), { status: 409 }); + } + if (!reservation.userId || !reservation.tierId) { + throw new Response(JSON.stringify({ error: "Reservation cannot create RSVP" }), { status: 409 }); + } + + const [tier] = await tx + .select({ capacity: eventTiers.capacity }) + .from(eventTiers) + .where(eq(eventTiers.id, reservation.tierId)) + .limit(1); + + const finalStatus = + tier?.capacity != null && tier.capacity > 0 + ? await checkCapacityAndDetermineStatus(tx as any, reservation.tierId, tier.capacity) + : "accepted"; + + const [rsvp] = await tx + .insert(rsvps) + .values({ + userId: reservation.userId, + eventId: reservation.eventId, + tierId: reservation.tierId, + status: finalStatus, + }) + .onConflictDoUpdate({ + target: [rsvps.userId, rsvps.eventId], + targetWhere: sql`user_id IS NOT NULL`, + set: { status: finalStatus, tierId: reservation.tierId }, + }) + .returning({ id: rsvps.id }); + rsvpId = rsvp.id; + + const answers = Array.isArray(reservation.answersSnapshot) + ? reservation.answersSnapshot.filter((answer): answer is { questionId: string; answer: string } => ( + typeof answer === "object" && + answer != null && + typeof (answer as { questionId?: unknown }).questionId === "string" && + typeof (answer as { answer?: unknown }).answer === "string" && + (answer as { answer: string }).answer.trim().length > 0 + )) + : []; + + await tx.delete(rsvpAnswers).where(eq(rsvpAnswers.rsvpId, rsvp.id)); + if (answers.length > 0) { + await tx.insert(rsvpAnswers).values( + answers.map((answer) => ({ + rsvpId: rsvp.id, + userId: reservation.userId, + eventId: reservation.eventId, + questionId: answer.questionId, + answer: answer.answer, + })), + ); + } + + await tx + .update(ticketReservations) + .set({ status: "confirmed", rsvpId: rsvp.id, updatedAt: new Date() }) + .where(eq(ticketReservations.id, reservation.id)); + + const [existingPayment] = await tx + .select({ id: ticketPayments.id }) + .from(ticketPayments) + .where(and(eq(ticketPayments.reservationId, reservation.id), eq(ticketPayments.checkoutId, payload.checkoutId))) + .limit(1); + + const paidAt = new Date(payload.paidAt); + if (existingPayment) { + await tx + .update(ticketPayments) + .set({ + status: "paid", + providerPaymentId: payload.paymentId, + providerTxId: payload.txId ?? null, + paidAt, + rawEvent: payload, + updatedAt: new Date(), + }) + .where(eq(ticketPayments.id, existingPayment.id)); + } else { + await tx.insert(ticketPayments).values({ + reservationId: reservation.id, + provider: payload.provider, + providerPaymentId: payload.paymentId, + providerTxId: payload.txId ?? null, + checkoutId: payload.checkoutId, + status: "paid", + amount: payload.amount, + currency: payload.currency, + paidAt, + rawEvent: payload, + }); + } + }); + + return Response.json({ ok: true, rsvpId }); + } catch (err) { + if (err instanceof Response) return err; + throw err; + } +}; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index f53fcd9..25214dc 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -260,6 +260,53 @@ export const rsvpAnswers = pgTable("rsvp_answers", { uniqueAnswer: unique().on(table.rsvpId, table.questionId), })); +export const eventTicketingSettings = pgTable("event_ticketing_settings", { + eventId: uuid("event_id").references(() => events.id, { onDelete: "cascade" }).primaryKey(), + mode: varchar("mode", { length: 32 }).notNull(), + provider: varchar("provider", { length: 32 }), + providerAccountId: text("provider_account_id"), + currency: varchar("currency", { length: 3 }), + enabled: boolean("enabled").default(true).notNull(), + legacy: boolean("legacy").default(false).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const ticketReservations = pgTable("ticket_reservations", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id").references(() => events.id).notNull(), + tierId: uuid("tier_id").references(() => eventTiers.id), + userId: uuid("user_id").references(() => users.id), + token: varchar("token", { length: 64 }), + rsvpId: uuid("rsvp_id").references(() => rsvps.id), + provider: varchar("provider", { length: 32 }).notNull(), + providerAccountId: text("provider_account_id"), + amount: integer("amount").notNull(), + currency: varchar("currency", { length: 3 }).notNull(), + status: varchar("status", { length: 32 }).notNull(), + checkoutId: text("checkout_id"), + answersSnapshot: jsonb("answers_snapshot"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const ticketPayments = pgTable("ticket_payments", { + id: uuid("id").defaultRandom().primaryKey(), + reservationId: uuid("reservation_id").references(() => ticketReservations.id, { onDelete: "cascade" }).notNull(), + provider: varchar("provider", { length: 32 }).notNull(), + providerPaymentId: text("provider_payment_id"), + providerTxId: text("provider_tx_id"), + checkoutId: text("checkout_id"), + status: varchar("status", { length: 32 }).notNull(), + amount: integer("amount").notNull(), + currency: varchar("currency", { length: 3 }).notNull(), + rawEvent: jsonb("raw_event"), + paidAt: timestamp("paid_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + export const eventFavourites = pgTable("event_favourites", { id: uuid("id").defaultRandom().primaryKey(), userId: uuid("user_id").references(() => users.id).notNull(), diff --git a/src/server/repositories/event-ticketing-settings.ts b/src/server/repositories/event-ticketing-settings.ts new file mode 100644 index 0000000..a44020a --- /dev/null +++ b/src/server/repositories/event-ticketing-settings.ts @@ -0,0 +1,36 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { eventTicketingSettings } from "../db/schema"; + +export type EventTicketingSetting = InferSelectModel; +export type NewEventTicketingSetting = InferInsertModel; + +export async function findByEventId(eventId: string): Promise { + const [row] = await db + .select() + .from(eventTicketingSettings) + .where(eq(eventTicketingSettings.eventId, eventId)) + .limit(1); + return row; +} + +export async function upsert(values: NewEventTicketingSetting): Promise { + const [row] = await db + .insert(eventTicketingSettings) + .values(values) + .onConflictDoUpdate({ + target: eventTicketingSettings.eventId, + set: { + mode: values.mode, + provider: values.provider, + providerAccountId: values.providerAccountId, + currency: values.currency, + enabled: values.enabled, + legacy: values.legacy, + updatedAt: new Date(), + }, + }) + .returning(); + return row; +} diff --git a/src/server/repositories/ticket-payments.ts b/src/server/repositories/ticket-payments.ts new file mode 100644 index 0000000..9eea163 --- /dev/null +++ b/src/server/repositories/ticket-payments.ts @@ -0,0 +1,69 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { ticketPayments } from "../db/schema"; + +export type TicketPayment = InferSelectModel; +export type NewTicketPayment = InferInsertModel; + +export async function insert(values: NewTicketPayment): Promise { + const [row] = await db.insert(ticketPayments).values(values).returning(); + return row; +} + +export async function findByReservationAndCheckout( + reservationId: string, + checkoutId: string, +): Promise { + const [row] = await db + .select() + .from(ticketPayments) + .where(and(eq(ticketPayments.reservationId, reservationId), eq(ticketPayments.checkoutId, checkoutId))) + .limit(1); + return row; +} + +export async function markPaid( + reservationId: string, + values: { + checkoutId: string; + providerPaymentId: string; + providerTxId?: string | null; + amount: number; + currency: string; + provider: string; + paidAt: Date; + rawEvent: unknown; + }, +): Promise { + const existing = await findByReservationAndCheckout(reservationId, values.checkoutId); + + if (existing) { + const [row] = await db + .update(ticketPayments) + .set({ + status: "paid", + providerPaymentId: values.providerPaymentId, + providerTxId: values.providerTxId ?? null, + paidAt: values.paidAt, + rawEvent: values.rawEvent, + updatedAt: new Date(), + }) + .where(eq(ticketPayments.id, existing.id)) + .returning(); + return row; + } + + return insert({ + reservationId, + checkoutId: values.checkoutId, + provider: values.provider, + providerPaymentId: values.providerPaymentId, + providerTxId: values.providerTxId ?? null, + status: "paid", + amount: values.amount, + currency: values.currency, + paidAt: values.paidAt, + rawEvent: values.rawEvent, + }); +} diff --git a/src/server/repositories/ticket-reservations.ts b/src/server/repositories/ticket-reservations.ts new file mode 100644 index 0000000..8c911cb --- /dev/null +++ b/src/server/repositories/ticket-reservations.ts @@ -0,0 +1,39 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { ticketReservations } from "../db/schema"; + +export type TicketReservation = InferSelectModel; +export type NewTicketReservation = InferInsertModel; + +export async function insert(values: NewTicketReservation): Promise { + const [row] = await db.insert(ticketReservations).values(values).returning(); + return row; +} + +export async function findById(id: string): Promise { + const [row] = await db + .select() + .from(ticketReservations) + .where(eq(ticketReservations.id, id)) + .limit(1); + return row; +} + +export async function setCheckoutId(id: string, checkoutId: string): Promise { + const [row] = await db + .update(ticketReservations) + .set({ checkoutId, updatedAt: new Date() }) + .where(eq(ticketReservations.id, id)) + .returning(); + return row; +} + +export async function markConfirmed(id: string, rsvpId: string): Promise { + const [row] = await db + .update(ticketReservations) + .set({ status: "confirmed", rsvpId, updatedAt: new Date() }) + .where(eq(ticketReservations.id, id)) + .returning(); + return row; +} diff --git a/src/server/services/ticket-payment-callback.test.ts b/src/server/services/ticket-payment-callback.test.ts new file mode 100644 index 0000000..b5532a4 --- /dev/null +++ b/src/server/services/ticket-payment-callback.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + parseTicketPaymentPaidCallback, + signTicketPaymentCallback, + verifyTicketPaymentCallbackSignature, +} from "./ticket-payment-callback"; + +describe("ticket payment callback signing", () => { + it("creates deterministic HMAC signatures and verifies them", () => { + const body = JSON.stringify({ type: "ticket_payment.paid", reservationId: "reservation-1" }); + const signature = signTicketPaymentCallback(body, "secret"); + + expect(signature).toBe("cefa120975b3de92e7de589cd6a62f86bbb2b0d13b625ac1f235e4e95c3cdea8"); + expect(verifyTicketPaymentCallbackSignature(body, signature, "secret")).toBe(true); + expect(verifyTicketPaymentCallbackSignature(body, signature, "other")).toBe(false); + expect(verifyTicketPaymentCallbackSignature(body, null, "secret")).toBe(false); + }); +}); + +describe("parseTicketPaymentPaidCallback", () => { + it("parses valid paid callbacks", () => { + expect(parseTicketPaymentPaidCallback(JSON.stringify({ + type: "ticket_payment.paid", + reservationId: "reservation-1", + checkoutId: "checkout-1", + paymentId: "payment-1", + txId: "tx-1", + provider: "portone", + amount: 15000, + currency: "KRW", + paidAt: "2026-04-27T12:00:00.000Z", + }))).toEqual({ + type: "ticket_payment.paid", + reservationId: "reservation-1", + checkoutId: "checkout-1", + paymentId: "payment-1", + txId: "tx-1", + provider: "portone", + amount: 15000, + currency: "KRW", + paidAt: "2026-04-27T12:00:00.000Z", + }); + }); + + it("rejects malformed callbacks", () => { + expect(parseTicketPaymentPaidCallback(JSON.stringify({ type: "ticket_payment.paid" }))).toBeNull(); + }); +}); diff --git a/src/server/services/ticket-payment-callback.ts b/src/server/services/ticket-payment-callback.ts new file mode 100644 index 0000000..1920a10 --- /dev/null +++ b/src/server/services/ticket-payment-callback.ts @@ -0,0 +1,57 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export interface TicketPaymentPaidCallback { + type: "ticket_payment.paid"; + reservationId: string; + checkoutId: string; + paymentId: string; + txId?: string | null; + provider: string; + amount: number; + currency: string; + paidAt: string; +} + +export function signTicketPaymentCallback(body: string, secret: string): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +export function verifyTicketPaymentCallbackSignature( + body: string, + signature: string | null, + secret: string, +): boolean { + if (!signature) return false; + const expected = signTicketPaymentCallback(body, secret); + const actualBuffer = Buffer.from(signature, "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + if (actualBuffer.length !== expectedBuffer.length) return false; + return timingSafeEqual(actualBuffer, expectedBuffer); +} + +export function parseTicketPaymentPaidCallback(body: string): TicketPaymentPaidCallback | null { + const value = JSON.parse(body) as Partial; + if ( + value.type !== "ticket_payment.paid" || + typeof value.reservationId !== "string" || + typeof value.checkoutId !== "string" || + typeof value.paymentId !== "string" || + typeof value.provider !== "string" || + typeof value.amount !== "number" || + typeof value.currency !== "string" || + typeof value.paidAt !== "string" + ) { + return null; + } + return { + type: value.type, + reservationId: value.reservationId, + checkoutId: value.checkoutId, + paymentId: value.paymentId, + txId: value.txId ?? null, + provider: value.provider, + amount: value.amount, + currency: value.currency, + paidAt: value.paidAt, + }; +} diff --git a/src/server/services/ticket-payment-client.test.ts b/src/server/services/ticket-payment-client.test.ts new file mode 100644 index 0000000..50bf287 --- /dev/null +++ b/src/server/services/ticket-payment-client.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { TicketPaymentClient, type CreateTicketCheckoutInput } from "./ticket-payment-client"; + +const input: CreateTicketCheckoutInput = { + provider: "portone", + paymentMethodFamily: "easy_pay", + reservationId: "reservation-1", + eventId: "event-1", + tierId: "tier-1", + orderName: "MoimConf - General", + amount: 15000, + currency: "KRW", + customer: { moimUserId: "user-1", name: "Kim", email: "kim@example.com" }, + successUrl: "https://moim.example/events/event-1/register/success", + cancelUrl: "https://moim.example/events/event-1/register", + callbackUrl: "https://moim.example/api/ticket-payment-callbacks", +}; + +describe("TicketPaymentClient", () => { + it("sends auth and idempotency headers when creating a checkout", async () => { + const fetchImpl = vi.fn(async () => Response.json({ + checkoutId: "checkout-1", + checkoutUrl: "https://payment.example/checkouts/checkout-1", + status: "requires_payment", + })); + const client = new TicketPaymentClient({ + baseUrl: "https://payment.example", + token: "service-token", + fetchImpl, + }); + + await expect(client.createCheckout(input)).resolves.toEqual({ + checkoutId: "checkout-1", + checkoutUrl: "https://payment.example/checkouts/checkout-1", + status: "requires_payment", + }); + + expect(fetchImpl).toHaveBeenCalledWith( + new URL("https://payment.example/v1/ticket-checkouts"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Authorization": "Bearer service-token", + "Content-Type": "application/json", + "Idempotency-Key": "reservation:reservation-1", + }), + body: JSON.stringify(input), + }), + ); + }); + + it("throws a typed error on non-2xx responses", async () => { + const client = new TicketPaymentClient({ + baseUrl: "https://payment.example", + token: "service-token", + fetchImpl: vi.fn(async () => new Response("nope", { status: 503 })), + }); + + await expect(client.createCheckout(input)).rejects.toMatchObject({ + code: "CHECKOUT_CREATE_FAILED", + }); + }); + + it("throws a typed error on malformed success responses", async () => { + const client = new TicketPaymentClient({ + baseUrl: "https://payment.example", + token: "service-token", + fetchImpl: vi.fn(async () => Response.json({ checkoutId: "checkout-1" })), + }); + + await expect(client.createCheckout(input)).rejects.toMatchObject({ + code: "CHECKOUT_CREATE_INVALID_RESPONSE", + }); + }); +}); diff --git a/src/server/services/ticket-payment-client.ts b/src/server/services/ticket-payment-client.ts new file mode 100644 index 0000000..05cf77f --- /dev/null +++ b/src/server/services/ticket-payment-client.ts @@ -0,0 +1,91 @@ +export interface CreateTicketCheckoutInput { + provider: "portone"; + paymentMethodFamily: "easy_pay"; + reservationId: string; + eventId: string; + tierId: string; + orderName: string; + amount: number; + currency: string; + customer: { + moimUserId: string; + name?: string; + email?: string; + }; + successUrl: string; + cancelUrl: string; + callbackUrl: string; +} + +export interface CreateTicketCheckoutOutput { + checkoutId: string; + checkoutUrl: string; + status: "requires_payment"; +} + +export interface TicketPaymentClientConfig { + baseUrl: string; + token: string; + fetchImpl?: typeof fetch; +} + +export class TicketPaymentClientError extends Error { + constructor( + public readonly code: string, + message = code, + ) { + super(message); + } +} + +export class TicketPaymentClient { + private readonly fetchImpl: typeof fetch; + + constructor(private readonly config: TicketPaymentClientConfig) { + this.fetchImpl = config.fetchImpl ?? fetch; + } + + async createCheckout(input: CreateTicketCheckoutInput): Promise { + const response = await this.fetchImpl(new URL("/v1/ticket-checkouts", this.config.baseUrl), { + method: "POST", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${this.config.token}`, + "Content-Type": "application/json", + "Idempotency-Key": `reservation:${input.reservationId}`, + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + throw new TicketPaymentClientError("CHECKOUT_CREATE_FAILED", `Payment service returned ${response.status}`); + } + + const body = (await response.json().catch(() => null)) as Partial | null; + if ( + !body || + typeof body.checkoutId !== "string" || + typeof body.checkoutUrl !== "string" || + body.status !== "requires_payment" + ) { + throw new TicketPaymentClientError("CHECKOUT_CREATE_INVALID_RESPONSE"); + } + + return { + checkoutId: body.checkoutId, + checkoutUrl: body.checkoutUrl, + status: body.status, + }; + } +} + +export function createTicketPaymentClientFromEnv(): TicketPaymentClient { + const baseUrl = process.env.TICKET_PAYMENT_SERVICE_URL; + const token = process.env.TICKET_PAYMENT_SERVICE_TOKEN; + + if (!baseUrl || !token) { + throw new TicketPaymentClientError("TICKET_PAYMENT_SERVICE_NOT_CONFIGURED"); + } + + return new TicketPaymentClient({ baseUrl, token }); +} diff --git a/src/server/services/ticketing-domain.test.ts b/src/server/services/ticketing-domain.test.ts new file mode 100644 index 0000000..7783f5f --- /dev/null +++ b/src/server/services/ticketing-domain.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + deriveTicketingSettings, + tierRequiresPayment, + validatePaidCallbackForReservation, + type PaidTicketCallbackPayload, + type TicketReservationSnapshot, +} from "./ticketing-domain"; + +describe("tierRequiresPayment", () => { + it("treats null, missing, and zero prices as free", () => { + expect(tierRequiresPayment(null)).toBe(false); + expect(tierRequiresPayment({})).toBe(false); + expect(tierRequiresPayment({ priceAmount: null })).toBe(false); + expect(tierRequiresPayment({ priceAmount: 0 })).toBe(false); + }); + + it("treats positive prices as paid", () => { + expect(tierRequiresPayment({ priceAmount: 1 })).toBe(true); + }); +}); + +describe("deriveTicketingSettings", () => { + it("uses free mode and no provider when all tiers are free", () => { + expect(deriveTicketingSettings([{ priceAmount: null }, { priceAmount: 0 }])).toEqual({ + mode: "free", + provider: null, + providerAccountId: null, + currency: null, + enabled: true, + }); + }); + + it("uses PortOne paid settings when any tier is paid", () => { + expect( + deriveTicketingSettings( + [{ priceAmount: 0 }, { priceAmount: 15000 }], + { defaultProviderAccountId: "portone_kakaopay" }, + ), + ).toEqual({ + mode: "paid", + provider: "portone", + providerAccountId: "portone_kakaopay", + currency: "KRW", + enabled: true, + }); + }); +}); + +describe("validatePaidCallbackForReservation", () => { + const now = new Date("2026-04-27T12:00:00.000Z"); + const reservation: TicketReservationSnapshot = { + id: "reservation-1", + status: "pending_payment", + checkoutId: "checkout-1", + provider: "portone", + amount: 15000, + currency: "KRW", + expiresAt: new Date("2026-04-27T12:10:00.000Z"), + }; + const payload: PaidTicketCallbackPayload = { + type: "ticket_payment.paid", + reservationId: "reservation-1", + checkoutId: "checkout-1", + paymentId: "payment-1", + txId: "tx-1", + provider: "portone", + amount: 15000, + currency: "KRW", + paidAt: "2026-04-27T12:01:00.000Z", + }; + + it("accepts a matching pending reservation", () => { + expect(validatePaidCallbackForReservation(reservation, payload, now)).toEqual({ ok: true }); + }); + + it("marks already confirmed reservations as idempotent", () => { + expect( + validatePaidCallbackForReservation( + { ...reservation, status: "confirmed" }, + payload, + now, + ), + ).toEqual({ ok: false, reason: "already_confirmed" }); + }); + + it("rejects non-pending reservations", () => { + expect( + validatePaidCallbackForReservation( + { ...reservation, status: "cancelled" }, + payload, + now, + ), + ).toEqual({ ok: false, reason: "invalid_status" }); + }); + + it("rejects expired reservations", () => { + expect( + validatePaidCallbackForReservation( + { ...reservation, expiresAt: now }, + payload, + now, + ), + ).toEqual({ ok: false, reason: "expired" }); + }); + + it("rejects amount, currency, checkout, provider, or reservation mismatches", () => { + expect(validatePaidCallbackForReservation(reservation, { ...payload, amount: 1000 }, now)).toEqual({ + ok: false, + reason: "mismatch", + }); + expect(validatePaidCallbackForReservation(reservation, { ...payload, currency: "USD" }, now)).toEqual({ + ok: false, + reason: "mismatch", + }); + expect(validatePaidCallbackForReservation(reservation, { ...payload, checkoutId: "other" }, now)).toEqual({ + ok: false, + reason: "mismatch", + }); + expect(validatePaidCallbackForReservation(reservation, { ...payload, provider: "other" }, now)).toEqual({ + ok: false, + reason: "mismatch", + }); + expect(validatePaidCallbackForReservation(reservation, { ...payload, reservationId: "other" }, now)).toEqual({ + ok: false, + reason: "mismatch", + }); + }); +}); diff --git a/src/server/services/ticketing-domain.ts b/src/server/services/ticketing-domain.ts new file mode 100644 index 0000000..bf51ae3 --- /dev/null +++ b/src/server/services/ticketing-domain.ts @@ -0,0 +1,105 @@ +export type TicketingMode = "free" | "paid" | "external"; +export type TicketingProvider = "portone" | "opencollective"; +export type ReservationStatus = "pending_payment" | "confirmed" | "expired" | "cancelled"; + +export interface TicketTierInput { + priceAmount?: number | null; +} + +export interface DerivedTicketingSettings { + mode: TicketingMode; + provider: TicketingProvider | null; + providerAccountId: string | null; + currency: string | null; + enabled: boolean; +} + +export interface DeriveTicketingSettingsOptions { + defaultProviderAccountId?: string | null; + currency?: string; +} + +export function tierRequiresPayment(tier: TicketTierInput | null | undefined): boolean { + return (tier?.priceAmount ?? 0) > 0; +} + +export function deriveTicketingSettings( + tiers: TicketTierInput[], + options: DeriveTicketingSettingsOptions = {}, +): DerivedTicketingSettings { + const hasPaidTier = tiers.some(tierRequiresPayment); + + if (!hasPaidTier) { + return { + mode: "free", + provider: null, + providerAccountId: null, + currency: null, + enabled: true, + }; + } + + return { + mode: "paid", + provider: "portone", + providerAccountId: options.defaultProviderAccountId ?? null, + currency: options.currency ?? "KRW", + enabled: true, + }; +} + +export interface TicketReservationSnapshot { + id: string; + status: ReservationStatus; + checkoutId: string | null; + provider: string; + amount: number; + currency: string; + expiresAt: Date; +} + +export interface PaidTicketCallbackPayload { + type: "ticket_payment.paid"; + reservationId: string; + checkoutId: string; + paymentId: string; + txId?: string | null; + provider: string; + amount: number; + currency: string; + paidAt: string; +} + +export type CallbackValidationResult = + | { ok: true } + | { ok: false; reason: "already_confirmed" | "invalid_status" | "expired" | "mismatch" }; + +export function validatePaidCallbackForReservation( + reservation: TicketReservationSnapshot, + payload: PaidTicketCallbackPayload, + now: Date, +): CallbackValidationResult { + if (reservation.status === "confirmed") { + return { ok: false, reason: "already_confirmed" }; + } + + if (reservation.status !== "pending_payment") { + return { ok: false, reason: "invalid_status" }; + } + + if (reservation.expiresAt <= now) { + return { ok: false, reason: "expired" }; + } + + if ( + reservation.id !== payload.reservationId || + reservation.checkoutId !== payload.checkoutId || + reservation.provider !== payload.provider || + reservation.amount !== payload.amount || + reservation.currency !== payload.currency + ) { + return { ok: false, reason: "mismatch" }; + } + + return { ok: true }; +} diff --git a/src/server/services/ticketing.test.ts b/src/server/services/ticketing.test.ts new file mode 100644 index 0000000..6655466 --- /dev/null +++ b/src/server/services/ticketing.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vitest"; +import { + deriveNewEventTicketingSettings, + startPaidRegistration, + type StartPaidRegistrationDeps, + type TicketPaymentRecord, + type TicketReservationRecord, +} from "./ticketing"; + +function createDeps(overrides: Partial = {}) { + const reservations: TicketReservationRecord[] = []; + const payments: TicketPaymentRecord[] = []; + const deps: StartPaidRegistrationDeps = { + getTicketingSetting: vi.fn(async (eventId) => ({ + eventId, + mode: "paid" as const, + provider: "portone" as const, + providerAccountId: "portone_kakaopay", + currency: "KRW", + enabled: true, + })), + createReservation: vi.fn(async (values) => { + const reservation: TicketReservationRecord = { + ...values, + id: `reservation-${reservations.length + 1}`, + checkoutId: null, + }; + reservations.push(reservation); + return reservation; + }), + setReservationCheckoutId: vi.fn(async (reservationId, checkoutId) => { + const reservation = reservations.find((row) => row.id === reservationId); + if (reservation) reservation.checkoutId = checkoutId; + }), + createPayment: vi.fn(async (values) => { + payments.push(values); + }), + createCheckout: vi.fn(async () => ({ + checkoutId: "checkout-1", + checkoutUrl: "https://payment.example/checkouts/checkout-1", + status: "requires_payment" as const, + })), + now: () => new Date("2026-04-27T12:00:00.000Z"), + addMinutes: (date, minutes) => new Date(date.getTime() + minutes * 60_000), + ...overrides, + }; + + return { deps, reservations, payments }; +} + +const paidInput = { + event: { id: "event-1", title: "MoimConf" }, + tier: { id: "tier-1", name: "General", priceAmount: 15000 }, + userId: "user-1", + customer: { name: "Kim", email: "kim@example.com" }, + answers: [{ questionId: "question-1", answer: "Yes" }], + baseUrl: "https://moim.example", + callbackUrl: "https://moim.example/api/ticket-payment-callbacks", +}; + +describe("deriveNewEventTicketingSettings", () => { + it("keeps free-only events providerless", () => { + expect(deriveNewEventTicketingSettings([{ priceAmount: null }, { priceAmount: 0 }])).toEqual({ + mode: "free", + provider: null, + providerAccountId: null, + currency: null, + enabled: true, + }); + }); + + it("defaults paid events to PortOne", () => { + expect(deriveNewEventTicketingSettings([{ priceAmount: 15000 }], "portone_kakaopay")).toEqual({ + mode: "paid", + provider: "portone", + providerAccountId: "portone_kakaopay", + currency: "KRW", + enabled: true, + }); + }); +}); + +describe("startPaidRegistration", () => { + it("does not enter payment flow for free tiers", async () => { + const { deps } = createDeps(); + + await expect( + startPaidRegistration( + { ...paidInput, tier: { ...paidInput.tier, priceAmount: 0 } }, + deps, + ), + ).resolves.toEqual({ requiresPayment: false }); + expect(deps.createReservation).not.toHaveBeenCalled(); + expect(deps.createCheckout).not.toHaveBeenCalled(); + }); + + it("creates a reservation, checkout, and requires_payment record for paid tiers", async () => { + const { deps, reservations, payments } = createDeps(); + + await expect(startPaidRegistration(paidInput, deps)).resolves.toEqual({ + requiresPayment: true, + reservationId: "reservation-1", + checkoutId: "checkout-1", + checkoutUrl: "https://payment.example/checkouts/checkout-1", + }); + + expect(reservations).toHaveLength(1); + expect(reservations[0]).toMatchObject({ + eventId: "event-1", + tierId: "tier-1", + userId: "user-1", + provider: "portone", + providerAccountId: "portone_kakaopay", + amount: 15000, + currency: "KRW", + status: "pending_payment", + checkoutId: "checkout-1", + answersSnapshot: [{ questionId: "question-1", answer: "Yes" }], + }); + expect(deps.createCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "portone", + paymentMethodFamily: "easy_pay", + reservationId: "reservation-1", + amount: 15000, + currency: "KRW", + successUrl: "https://moim.example/events/event-1/register?payment=success", + cancelUrl: "https://moim.example/events/event-1/register?payment=cancel", + }), + "reservation:reservation-1", + ); + expect(payments).toEqual([ + { + reservationId: "reservation-1", + checkoutId: "checkout-1", + provider: "portone", + status: "requires_payment", + amount: 15000, + currency: "KRW", + }, + ]); + }); + + it("rejects paid registration when paid ticketing is disabled", async () => { + const { deps } = createDeps({ + getTicketingSetting: vi.fn(async (eventId) => ({ + eventId, + mode: "paid" as const, + provider: "portone" as const, + providerAccountId: "portone_kakaopay", + currency: "KRW", + enabled: false, + })), + }); + + await expect(startPaidRegistration(paidInput, deps)).rejects.toMatchObject({ + code: "PAID_TICKETING_NOT_ENABLED", + }); + expect(deps.createReservation).not.toHaveBeenCalled(); + }); + + it("does not create a payment record if checkout creation fails", async () => { + const { deps, reservations, payments } = createDeps({ + createCheckout: vi.fn(async () => { + throw new Error("payment service unavailable"); + }), + }); + + await expect(startPaidRegistration(paidInput, deps)).rejects.toThrow("payment service unavailable"); + expect(reservations).toHaveLength(1); + expect(payments).toHaveLength(0); + expect(deps.setReservationCheckoutId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/services/ticketing.ts b/src/server/services/ticketing.ts new file mode 100644 index 0000000..6f5f3d4 --- /dev/null +++ b/src/server/services/ticketing.ts @@ -0,0 +1,191 @@ +import { deriveTicketingSettings, tierRequiresPayment, type DerivedTicketingSettings } from "./ticketing-domain"; + +export interface RegistrationAnswerInput { + questionId: string; + answer: string; +} + +export interface PaidRegistrationTier { + id: string; + name: string; + priceAmount: number | null; +} + +export interface PaidRegistrationEvent { + id: string; + title: string; +} + +export interface TicketingSettingInput extends DerivedTicketingSettings { + eventId: string; +} + +export interface TicketReservationRecord { + id: string; + eventId: string; + tierId: string; + userId: string; + provider: string; + providerAccountId: string | null; + amount: number; + currency: string; + status: "pending_payment"; + checkoutId: string | null; + answersSnapshot: RegistrationAnswerInput[]; + expiresAt: Date; +} + +export interface TicketPaymentRecord { + reservationId: string; + checkoutId: string; + provider: string; + status: "requires_payment"; + amount: number; + currency: string; +} + +export interface TicketCheckoutRequest { + provider: "portone"; + paymentMethodFamily: "easy_pay"; + reservationId: string; + eventId: string; + tierId: string; + orderName: string; + amount: number; + currency: string; + customer: { + moimUserId: string; + name?: string; + email?: string; + }; + successUrl: string; + cancelUrl: string; + callbackUrl: string; +} + +export interface TicketCheckoutResponse { + checkoutId: string; + checkoutUrl: string; + status: "requires_payment"; +} + +export interface StartPaidRegistrationDeps { + getTicketingSetting(eventId: string): Promise; + createReservation(values: Omit): Promise; + setReservationCheckoutId(reservationId: string, checkoutId: string): Promise; + createPayment(values: TicketPaymentRecord): Promise; + createCheckout(input: TicketCheckoutRequest, idempotencyKey: string): Promise; + now(): Date; + addMinutes(date: Date, minutes: number): Date; +} + +export interface StartPaidRegistrationInput { + event: PaidRegistrationEvent; + tier: PaidRegistrationTier; + userId: string; + customer: { + name?: string; + email?: string; + }; + answers: RegistrationAnswerInput[]; + baseUrl: string; + callbackUrl: string; +} + +export type StartPaidRegistrationResult = + | { requiresPayment: false } + | { + requiresPayment: true; + reservationId: string; + checkoutId: string; + checkoutUrl: string; + }; + +export class TicketingError extends Error { + constructor( + public readonly code: string, + message = code, + ) { + super(message); + } +} + +export function deriveNewEventTicketingSettings( + tiers: Array<{ priceAmount?: number | null }>, + defaultProviderAccountId?: string | null, +): DerivedTicketingSettings { + return deriveTicketingSettings(tiers, { defaultProviderAccountId }); +} + +export async function startPaidRegistration( + input: StartPaidRegistrationInput, + deps: StartPaidRegistrationDeps, +): Promise { + if (!tierRequiresPayment(input.tier)) { + return { requiresPayment: false }; + } + + const setting = await deps.getTicketingSetting(input.event.id); + if (!setting || setting.mode !== "paid" || !setting.enabled || setting.provider !== "portone") { + throw new TicketingError("PAID_TICKETING_NOT_ENABLED"); + } + if (!setting.currency) { + throw new TicketingError("PAID_TICKETING_CURRENCY_MISSING"); + } + + const amount = input.tier.priceAmount; + if (amount == null || amount <= 0) { + return { requiresPayment: false }; + } + + const reservation = await deps.createReservation({ + eventId: input.event.id, + tierId: input.tier.id, + userId: input.userId, + provider: setting.provider, + providerAccountId: setting.providerAccountId, + amount, + currency: setting.currency, + status: "pending_payment", + answersSnapshot: input.answers, + expiresAt: deps.addMinutes(deps.now(), 10), + }); + + const checkout = await deps.createCheckout( + { + provider: "portone", + paymentMethodFamily: "easy_pay", + reservationId: reservation.id, + eventId: input.event.id, + tierId: input.tier.id, + orderName: `${input.event.title} - ${input.tier.name}`, + amount, + currency: setting.currency, + customer: { + moimUserId: input.userId, + ...input.customer, + }, + successUrl: `${input.baseUrl}/events/${input.event.id}/register?payment=success`, + cancelUrl: `${input.baseUrl}/events/${input.event.id}/register?payment=cancel`, + callbackUrl: input.callbackUrl, + }, + `reservation:${reservation.id}`, + ); + + await deps.setReservationCheckoutId(reservation.id, checkout.checkoutId); + await deps.createPayment({ + reservationId: reservation.id, + checkoutId: checkout.checkoutId, + provider: setting.provider, + status: "requires_payment", + amount, + currency: setting.currency, + }); + + return { + requiresPayment: true, + reservationId: reservation.id, + checkoutId: checkout.checkoutId, + checkoutUrl: checkout.checkoutUrl, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..288c5ef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + pool: "forks", + fileParallelism: false, + watch: false, + }, +});