From 23a0e4bb6ec9df880bdc77c106f431e2ee1656b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 4 Oct 2024 15:42:35 -0300 Subject: [PATCH 01/19] feat: gift tickets in claimUserTicket flow --- drizzle/migrations/0033_careless_kang.sql | 49 + drizzle/migrations/meta/0033_snapshot.json | 3414 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + emails/templates/helpers/9punto5.tsx | 75 + .../purchase-order-successful/9punto5.tsx | 238 +- .../9punto5.tsx | 49 + .../tickets/ticket-gift-received/9punto5.tsx | 134 + .../tickets/ticket-gift-sent/9punto5.tsx | 65 + src/datasources/db/tickets.ts | 73 +- src/datasources/db/userTickets.ts | 145 +- src/datasources/db/users.ts | 2 + src/datasources/helpers/index.ts | 4 + src/generated/schema.gql | 49 +- src/generated/types.ts | 43 +- src/schema/purchaseOrder/actions.tsx | 105 +- src/schema/shared/refs.ts | 4 + src/schema/userTickets/helpers.ts | 99 +- src/schema/userTickets/mutations.ts | 441 ++- src/schema/userTickets/queries.ts | 42 +- .../acceptGiftedTicket.generated.ts | 6 +- .../acceptGiftedTicket.gql | 4 +- .../acceptGiftedTicket.test.ts | 57 +- .../claimUserTicket.generated.ts | 13 +- .../tests/claimUserTicket/claimUserTicket.gql | 11 + .../claimUserTicket/claimUserTicket.test.ts | 255 +- src/schema/userTickets/types.ts | 100 + .../waitlist/tests/fetchWaitlist.test.ts | 1 + src/tests/fixtures/index.ts | 27 +- src/tests/fixtures/mocks.ts | 3 + workers/transactional_email_service/index.tsx | 145 + 30 files changed, 5216 insertions(+), 444 deletions(-) create mode 100644 drizzle/migrations/0033_careless_kang.sql create mode 100644 drizzle/migrations/meta/0033_snapshot.json create mode 100644 emails/templates/helpers/9punto5.tsx create mode 100644 emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx create mode 100644 emails/templates/tickets/ticket-gift-received/9punto5.tsx create mode 100644 emails/templates/tickets/ticket-gift-sent/9punto5.tsx diff --git a/drizzle/migrations/0033_careless_kang.sql b/drizzle/migrations/0033_careless_kang.sql new file mode 100644 index 00000000..9615008a --- /dev/null +++ b/drizzle/migrations/0033_careless_kang.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS "user_ticket_gifts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_ticket_id" uuid NOT NULL, + "gifter_user_id" uuid NOT NULL, + "receiver_user_id" uuid NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "gift_message" text, + "expiration_date" timestamp NOT NULL, + "is_return" boolean DEFAULT false NOT NULL, + "created_at" timestamp (6) DEFAULT now() NOT NULL, + "updated_at" timestamp (6), + "deleted_at" timestamp (6) +); +--> statement-breakpoint +ALTER TABLE "user_tickets" DROP CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_tickets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_user_ticket_id_user_tickets_id_fk" FOREIGN KEY ("user_ticket_id") REFERENCES "public"."user_tickets"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_gifter_user_id_users_id_fk" FOREIGN KEY ("gifter_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_receiver_user_id_users_id_fk" FOREIGN KEY ("receiver_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_user_ticket_id_index" ON "user_ticket_gifts" USING btree ("user_ticket_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_gifter_user_id_index" ON "user_ticket_gifts" USING btree ("gifter_user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_receiver_user_id_index" ON "user_ticket_gifts" USING btree ("receiver_user_id");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_tickets" ADD CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk" FOREIGN KEY ("purchase_order_id") REFERENCES "public"."purchase_orders"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tickets_event_id_index" ON "tickets" USING btree ("event_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_ticket_template_id_index" ON "user_tickets" USING btree ("ticket_template_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_user_id_index" ON "user_tickets" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_approval_status_index" ON "user_tickets" USING btree ("approval_status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_purchase_order_id_index" ON "user_tickets" USING btree ("purchase_order_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0033_snapshot.json b/drizzle/migrations/meta/0033_snapshot.json new file mode 100644 index 00000000..a6d370a4 --- /dev/null +++ b/drizzle/migrations/meta/0033_snapshot.json @@ -0,0 +1,3414 @@ +{ + "id": "89c491ed-5ad0-49e4-beec-29bccbfa1610", + "prevId": "27492e6d-592d-42b1-9998-27c5736ead28", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.allowed_currencies": { + "name": "allowed_currencies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_methods": { + "name": "payment_methods", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "allowed_currencies_currency_unique": { + "name": "allowed_currencies_currency_unique", + "nullsNotDistinct": false, + "columns": ["currency"] + } + } + }, + "public.communities": { + "name": "communities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_sanity_ref": { + "name": "logo_image_sanity_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_image_sanity_ref": { + "name": "banner_image_sanity_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_success_redirect_url": { + "name": "payment_success_redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_cancel_redirect_url": { + "name": "payment_cancel_redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "communities_slug_unique": { + "name": "communities_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.confirmation_token": { + "name": "confirmation_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "confirmation_date": { + "name": "confirmation_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "confirmation_token_user_id_users_id_fk": { + "name": "confirmation_token_user_id_users_id_fk", + "tableFrom": "confirmation_token", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "confirmation_token_token_unique": { + "name": "confirmation_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + } + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "start_date_time": { + "name": "start_date_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date_time": { + "name": "end_date_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_latitude": { + "name": "geo_latitude", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_longitude": { + "name": "geo_longitude", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_address_json": { + "name": "geo_address_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_descriptive_name": { + "name": "address_descriptive_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_url": { + "name": "meeting_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sanity_event_id": { + "name": "sanity_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_image_sanity_ref": { + "name": "banner_image_sanity_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_share_url": { + "name": "public_share_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image": { + "name": "logo_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "preview_image": { + "name": "preview_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "banner_image": { + "name": "banner_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mobile_banner_image": { + "name": "mobile_banner_image", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_logo_image_images_id_fk": { + "name": "events_logo_image_images_id_fk", + "tableFrom": "events", + "tableTo": "images", + "columnsFrom": ["logo_image"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_preview_image_images_id_fk": { + "name": "events_preview_image_images_id_fk", + "tableFrom": "events", + "tableTo": "images", + "columnsFrom": ["preview_image"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_banner_image_images_id_fk": { + "name": "events_banner_image_images_id_fk", + "tableFrom": "events", + "tableTo": "images", + "columnsFrom": ["banner_image"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_mobile_banner_image_images_id_fk": { + "name": "events_mobile_banner_image_images_id_fk", + "tableFrom": "events", + "tableTo": "images", + "columnsFrom": ["mobile_banner_image"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_name_unique": { + "name": "events_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.events_communities": { + "name": "events_communities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payment_success_redirect_url": { + "name": "payment_success_redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_cancel_redirect_url": { + "name": "payment_cancel_redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_communities_event_id_events_id_fk": { + "name": "events_communities_event_id_events_id_fk", + "tableFrom": "events_communities", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_communities_community_id_communities_id_fk": { + "name": "events_communities_community_id_communities_id_fk", + "tableFrom": "events_communities", + "tableTo": "communities", + "columnsFrom": ["community_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "events_communities_event_id_community_id_pk": { + "name": "events_communities_event_id_community_id_pk", + "columns": ["event_id", "community_id"] + } + }, + "uniqueConstraints": { + "events_communities_id_unique": { + "name": "events_communities_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.events_tags": { + "name": "events_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_tags_event_id_events_id_fk": { + "name": "events_tags_event_id_events_id_fk", + "tableFrom": "events_tags", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_tags_tag_id_tags_id_fk": { + "name": "events_tags_tag_id_tags_id_fk", + "tableFrom": "events_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "events_tags_event_id_tag_id_pk": { + "name": "events_tags_event_id_tag_id_pk", + "columns": ["event_id", "tag_id"] + } + }, + "uniqueConstraints": { + "events_tags_id_unique": { + "name": "events_tags_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.events_users": { + "name": "events_users", + "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": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "events_users_event_id_events_id_fk": { + "name": "events_users_event_id_events_id_fk", + "tableFrom": "events_users", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_users_user_id_users_id_fk": { + "name": "events_users_user_id_users_id_fk", + "tableFrom": "events_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.galleries": { + "name": "galleries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "galleries_event_id_events_id_fk": { + "name": "galleries_event_id_events_id_fk", + "tableFrom": "galleries", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "galleries_id_unique": { + "name": "galleries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "galleries_slug_unique": { + "name": "galleries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + } + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hosting": { + "name": "hosting", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gallery_id": { + "name": "gallery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "images_tags_index": { + "name": "images_tags_index", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "images_gallery_id_galleries_id_fk": { + "name": "images_gallery_id_galleries_id_fk", + "tableFrom": "images", + "tableTo": "galleries", + "columnsFrom": ["gallery_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "images_id_unique": { + "name": "images_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.payments_logs": { + "name": "payments_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_product_reference": { + "name": "external_product_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_amount": { + "name": "transaction_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "external_creation_date": { + "name": "external_creation_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "currency_id": { + "name": "currency_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_response_blob": { + "name": "original_response_blob", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_logs_external_id_platform_unique": { + "name": "payments_logs_external_id_platform_unique", + "nullsNotDistinct": false, + "columns": ["external_id", "platform"] + } + } + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency_id": { + "name": "currency_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "prices_currency_id_allowed_currencies_id_fk": { + "name": "prices_currency_id_allowed_currencies_id_fk", + "tableFrom": "prices", + "tableTo": "allowed_currencies", + "columnsFrom": ["currency_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.purchase_orders": { + "name": "purchase_orders", + "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 + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_uuid_key": { + "name": "idempotency_uuid_key", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_platform": { + "name": "payment_platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_price": { + "name": "total_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "currency_id": { + "name": "currency_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payment_platform_payment_link": { + "name": "payment_platform_payment_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_platform_expiration_date": { + "name": "payment_platform_expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "payment_platform_reference_id": { + "name": "payment_platform_reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_platform_status": { + "name": "payment_platform_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_platform_metadata": { + "name": "payment_platform_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "purchase_order_payment_status": { + "name": "purchase_order_payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "purchase_orders_user_id_users_id_fk": { + "name": "purchase_orders_user_id_users_id_fk", + "tableFrom": "purchase_orders", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "purchase_orders_currency_id_allowed_currencies_id_fk": { + "name": "purchase_orders_currency_id_allowed_currencies_id_fk", + "tableFrom": "purchase_orders", + "tableTo": "allowed_currencies", + "columnsFrom": ["currency_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "purchase_orders_public_id_unique": { + "name": "purchase_orders_public_id_unique", + "nullsNotDistinct": false, + "columns": ["public_id"] + }, + "purchase_orders_idempotency_uuid_key_unique": { + "name": "purchase_orders_idempotency_uuid_key_unique", + "nullsNotDistinct": false, + "columns": ["idempotency_uuid_key"] + } + } + }, + "public.salaries": { + "name": "salaries", + "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 + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "currency_code": { + "name": "currency_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_seniority_and_role_id": { + "name": "work_seniority_and_role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "work_email_id": { + "name": "work_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "years_of_experience": { + "name": "years_of_experience", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gender_other_text": { + "name": "gender_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_of_employment": { + "name": "type_of_employment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_metodology": { + "name": "work_metodology", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "salaries_user_id_users_id_fk": { + "name": "salaries_user_id_users_id_fk", + "tableFrom": "salaries", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "salaries_company_id_companies_id_fk": { + "name": "salaries_company_id_companies_id_fk", + "tableFrom": "salaries", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "salaries_work_seniority_and_role_id_work_seniority_and_role_id_fk": { + "name": "salaries_work_seniority_and_role_id_work_seniority_and_role_id_fk", + "tableFrom": "salaries", + "tableTo": "work_seniority_and_role", + "columnsFrom": ["work_seniority_and_role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "salaries_work_email_id_work_email_id_fk": { + "name": "salaries_work_email_id_work_email_id_fk", + "tableFrom": "salaries", + "tableTo": "work_email", + "columnsFrom": ["work_email_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.schedule": { + "name": "schedule", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_event_id_events_id_fk": { + "name": "schedule_event_id_events_id_fk", + "tableFrom": "schedule", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schedule_id_unique": { + "name": "schedule_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schedule_id": { + "name": "schedule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_schedule_id_schedule_id_fk": { + "name": "sessions_schedule_id_schedule_id_fk", + "tableFrom": "sessions", + "tableTo": "schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_id_unique": { + "name": "sessions_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.session_to_speakers": { + "name": "session_to_speakers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "speaker_id": { + "name": "speaker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_to_speakers_session_id_sessions_id_fk": { + "name": "session_to_speakers_session_id_sessions_id_fk", + "tableFrom": "session_to_speakers", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "session_to_speakers_speaker_id_speakers_id_fk": { + "name": "session_to_speakers_speaker_id_speakers_id_fk", + "tableFrom": "session_to_speakers", + "tableTo": "speakers", + "columnsFrom": ["speaker_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_to_speakers_id_unique": { + "name": "session_to_speakers_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.speakers": { + "name": "speakers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rol": { + "name": "rol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "speakers_event_id_events_id_fk": { + "name": "speakers_event_id_events_id_fk", + "tableFrom": "speakers", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speakers_id_unique": { + "name": "speakers_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.tags_communities": { + "name": "tags_communities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tags_communities_tag_id_tags_id_fk": { + "name": "tags_communities_tag_id_tags_id_fk", + "tableFrom": "tags_communities", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tags_communities_community_id_communities_id_fk": { + "name": "tags_communities_community_id_communities_id_fk", + "tableFrom": "tags_communities", + "tableTo": "communities", + "columnsFrom": ["community_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tags_communities_tag_id_community_id_pk": { + "name": "tags_communities_tag_id_community_id_pk", + "columns": ["tag_id", "community_id"] + } + }, + "uniqueConstraints": { + "tags_communities_id_unique": { + "name": "tags_communities_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "team_status": { + "name": "team_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting_resolution'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "teams_event_id_events_id_fk": { + "name": "teams_event_id_events_id_fk", + "tableFrom": "teams", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tickets_prices": { + "name": "tickets_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tickets_prices_ticket_id_tickets_id_fk": { + "name": "tickets_prices_ticket_id_tickets_id_fk", + "tableFrom": "tickets_prices", + "tableTo": "tickets", + "columnsFrom": ["ticket_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_prices_price_id_prices_id_fk": { + "name": "tickets_prices_price_id_prices_id_fk", + "tableFrom": "tickets_prices", + "tableTo": "prices", + "columnsFrom": ["price_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "external_link": { + "name": "external_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_tickets_per_user": { + "name": "max_tickets_per_user", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_link": { + "name": "image_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "start_date_time": { + "name": "start_date_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date_time": { + "name": "end_date_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "requires_approval": { + "name": "requires_approval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_unlimited": { + "name": "is_unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mercado_pago_product_id": { + "name": "mercado_pago_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tickets_event_id_index": { + "name": "tickets_event_id_index", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tickets_event_id_events_id_fk": { + "name": "tickets_event_id_events_id_fk", + "tableFrom": "tickets", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tickets_name_unique": { + "name": "tickets_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastName": { + "name": "lastName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "gender_other_text": { + "name": "gender_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isSuperAdmin": { + "name": "isSuperAdmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicMetadata": { + "name": "publicMetadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_index": { + "name": "users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_name_index": { + "name": "users_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "users_last_name_index": { + "name": "users_last_name_index", + "columns": [ + { + "expression": "lastName", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "users_username_index": { + "name": "users_username_index", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + } + }, + "public.users_communities": { + "name": "users_communities", + "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 + }, + "community_id": { + "name": "community_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_communities_user_id_users_id_fk": { + "name": "users_communities_user_id_users_id_fk", + "tableFrom": "users_communities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_communities_community_id_communities_id_fk": { + "name": "users_communities_community_id_communities_id_fk", + "tableFrom": "users_communities", + "tableTo": "communities", + "columnsFrom": ["community_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_data": { + "name": "user_data", + "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 + }, + "country_of_residence": { + "name": "country_of_residence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "works_in_organization": { + "name": "works_in_organization", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_in_organization": { + "name": "role_in_organization", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rut": { + "name": "rut", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "food_allergies": { + "name": "food_allergies", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emergency_phone_number": { + "name": "emergency_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_id_index": { + "name": "user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_data_user_id_users_id_fk": { + "name": "user_data_user_id_users_id_fk", + "tableFrom": "user_data", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users_tags": { + "name": "users_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_tags_tag_id_tags_id_fk": { + "name": "users_tags_tag_id_tags_id_fk", + "tableFrom": "users_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_tags_user_id_users_id_fk": { + "name": "users_tags_user_id_users_id_fk", + "tableFrom": "users_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_tags_tag_id_user_id_pk": { + "name": "users_tags_tag_id_user_id_pk", + "columns": ["tag_id", "user_id"] + } + }, + "uniqueConstraints": { + "users_tags_id_unique": { + "name": "users_tags_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.user_teams": { + "name": "user_teams", + "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 + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'leader'" + }, + "discipline": { + "name": "discipline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_participation_status": { + "name": "user_participation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting_resolution'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_teams_user_id_users_id_fk": { + "name": "user_teams_user_id_users_id_fk", + "tableFrom": "user_teams", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_teams_team_id_teams_id_fk": { + "name": "user_teams_team_id_teams_id_fk", + "tableFrom": "user_teams", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_ticket_gifts": { + "name": "user_ticket_gifts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_ticket_id": { + "name": "user_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "gifter_user_id": { + "name": "gifter_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "receiver_user_id": { + "name": "receiver_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "gift_message": { + "name": "gift_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_return": { + "name": "is_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_ticket_gifts_user_ticket_id_index": { + "name": "user_ticket_gifts_user_ticket_id_index", + "columns": [ + { + "expression": "user_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_ticket_gifts_gifter_user_id_index": { + "name": "user_ticket_gifts_gifter_user_id_index", + "columns": [ + { + "expression": "gifter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_ticket_gifts_receiver_user_id_index": { + "name": "user_ticket_gifts_receiver_user_id_index", + "columns": [ + { + "expression": "receiver_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_ticket_gifts_user_ticket_id_user_tickets_id_fk": { + "name": "user_ticket_gifts_user_ticket_id_user_tickets_id_fk", + "tableFrom": "user_ticket_gifts", + "tableTo": "user_tickets", + "columnsFrom": ["user_ticket_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_ticket_gifts_gifter_user_id_users_id_fk": { + "name": "user_ticket_gifts_gifter_user_id_users_id_fk", + "tableFrom": "user_ticket_gifts", + "tableTo": "users", + "columnsFrom": ["gifter_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_ticket_gifts_receiver_user_id_users_id_fk": { + "name": "user_ticket_gifts_receiver_user_id_users_id_fk", + "tableFrom": "user_ticket_gifts", + "tableTo": "users", + "columnsFrom": ["receiver_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_tickets": { + "name": "user_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ticket_template_id": { + "name": "ticket_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purchase_order_id": { + "name": "purchase_order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_status": { + "name": "approval_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "redemption_status": { + "name": "redemption_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_tickets_public_id_index": { + "name": "user_tickets_public_id_index", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tickets_ticket_template_id_index": { + "name": "user_tickets_ticket_template_id_index", + "columns": [ + { + "expression": "ticket_template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tickets_user_id_index": { + "name": "user_tickets_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tickets_approval_status_index": { + "name": "user_tickets_approval_status_index", + "columns": [ + { + "expression": "approval_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tickets_purchase_order_id_index": { + "name": "user_tickets_purchase_order_id_index", + "columns": [ + { + "expression": "purchase_order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tickets_user_id_users_id_fk": { + "name": "user_tickets_user_id_users_id_fk", + "tableFrom": "user_tickets", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_tickets_ticket_template_id_tickets_id_fk": { + "name": "user_tickets_ticket_template_id_tickets_id_fk", + "tableFrom": "user_tickets", + "tableTo": "tickets", + "columnsFrom": ["ticket_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_tickets_purchase_order_id_purchase_orders_id_fk": { + "name": "user_tickets_purchase_order_id_purchase_orders_id_fk", + "tableFrom": "user_tickets", + "tableTo": "purchase_orders", + "columnsFrom": ["purchase_order_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_tickets_email_logs": { + "name": "user_tickets_email_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_ticket_id": { + "name": "user_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_tickets_email_logs_email_type_index": { + "name": "user_tickets_email_logs_email_type_index", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tickets_email_logs_user_ticket_id_user_tickets_id_fk": { + "name": "user_tickets_email_logs_user_ticket_id_user_tickets_id_fk", + "tableFrom": "user_tickets_email_logs", + "tableTo": "user_tickets", + "columnsFrom": ["user_ticket_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_tickets_email_logs_user_id_users_id_fk": { + "name": "user_tickets_email_logs_user_id_users_id_fk", + "tableFrom": "user_tickets_email_logs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_tickets_email_logs_id_unique": { + "name": "user_tickets_email_logs_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.work_email": { + "name": "work_email", + "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 + }, + "work_email": { + "name": "work_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confirmation_token_id": { + "name": "confirmation_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "confirmation_date": { + "name": "confirmation_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "work_email_user_id_users_id_fk": { + "name": "work_email_user_id_users_id_fk", + "tableFrom": "work_email", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_email_confirmation_token_id_confirmation_token_id_fk": { + "name": "work_email_confirmation_token_id_confirmation_token_id_fk", + "tableFrom": "work_email", + "tableTo": "confirmation_token", + "columnsFrom": ["confirmation_token_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_email_company_id_companies_id_fk": { + "name": "work_email_company_id_companies_id_fk", + "tableFrom": "work_email", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.work_role": { + "name": "work_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.work_seniority": { + "name": "work_seniority", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.work_seniority_and_role": { + "name": "work_seniority_and_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "work_role_id": { + "name": "work_role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "work_seniority_id": { + "name": "work_seniority_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "work_seniority_and_role_work_role_id_work_role_id_fk": { + "name": "work_seniority_and_role_work_role_id_work_role_id_fk", + "tableFrom": "work_seniority_and_role", + "tableTo": "work_role", + "columnsFrom": ["work_role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_seniority_and_role_work_seniority_id_work_seniority_id_fk": { + "name": "work_seniority_and_role_work_seniority_id_work_seniority_id_fk", + "tableFrom": "work_seniority_and_role", + "tableTo": "work_seniority", + "columnsFrom": ["work_seniority_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 028822bd..9cc19103 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1725839035256, "tag": "0032_gorgeous_synch", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1728009672065, + "tag": "0033_careless_kang", + "breakpoints": true } ] } diff --git a/emails/templates/helpers/9punto5.tsx b/emails/templates/helpers/9punto5.tsx new file mode 100644 index 00000000..8d5e16e6 --- /dev/null +++ b/emails/templates/helpers/9punto5.tsx @@ -0,0 +1,75 @@ +import { + Body, + Container, + Head, + Html, + Img, + Section, + Tailwind, +} from "@react-email/components"; +import * as React from "react"; + +import { BebasNeueFont, RobotoFont } from "./fonts"; + +export const TicketTemplate9punto5 = ({ + children, +}: React.PropsWithChildren) => { + return ( + + + + + + + + +
+ 9punto5 Logo + + {children} + + 9punto5 Illustration +
+
+ + +
+ ); +}; diff --git a/emails/templates/tickets/purchase-order-successful/9punto5.tsx b/emails/templates/tickets/purchase-order-successful/9punto5.tsx index 78e8a060..752d6094 100644 --- a/emails/templates/tickets/purchase-order-successful/9punto5.tsx +++ b/emails/templates/tickets/purchase-order-successful/9punto5.tsx @@ -1,65 +1,9 @@ -import { - Body, - Container, - Head, - Html, - Img, - Section, - Text, - Tailwind, -} from "@react-email/components"; +import { Img, Text } from "@react-email/components"; import * as React from "react"; -import { BebasNeueFont, RobotoFont } from "emails/templates/helpers/fonts"; +import { TicketTemplate9punto5 } from "emails/templates/helpers/9punto5"; import { formatPrice } from "emails/templates/helpers/format-price"; -const TicketTemplate = ({ children }: React.PropsWithChildren) => { - return ( - - - - - - - {children} - - - ); -}; - type Props = { currencyCode: string; total: number; @@ -72,107 +16,93 @@ export const PurchaseOrderSuccessful9punto5 = ({ type = "EXPERIENCE", }: Props) => { return ( - - -
- 9punto5 Logo - - ¡Hola! - - Confirmamos la compra de tu entrada: - - - {type === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 |{" "} - {currencyCode} $ - {formatPrice(total, { mode: currencyCode as "CLP" | "USD" })} - + + ¡Hola! - {type === "EXPERIENCE" && ( - <> - GREEN CARPET - 7 NOV - 20:00 HORAS - - Evento estelar de bienvenida. Cocktail para compartir con la - comunidad y speakers. - - - - ACCESO A TALLERES - 7 NOV - - 14:30 y 16:30 HORAS - - Podrás participar en dos talleres que serán a las 14:30 y 16:30 - horas. Cupos limitados. - - - )} - - CONFERENCIA - 8 y 9 NOV - 9:30 – 18:00 HORAS + Confirmamos la compra de tu entrada: + + + {type === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 |{" "} + {currencyCode} $ + {formatPrice(total, { mode: currencyCode as "CLP" | "USD" })} + + + {type === "EXPERIENCE" && ( + <> + GREEN CARPET - 7 NOV + 20:00 HORAS - Más de 40 charlas para elegir y 3 auditorios en paralelo. + Evento estelar de bienvenida. Cocktail para compartir con la + comunidad y speakers. - Incluye kit de bienvenida 2024 y coffee breaks. - - - Además considera que pronto podrás agregar: + ACCESO A TALLERES - 7 NOV + 14:30 y 16:30 HORAS + + Podrás participar en dos talleres que serán a las 14:30 y 16:30 + horas. Cupos limitados. -
    -
  • Estadía para 3 noches en hotel Villa del Río
  • -
  • Almuerzo para los 3 días
  • -
- - Puedes ver tu entrada y novedades ingresando a tu perfil: - - - - - -
- - IR A MI PERFIL - -
- - Nos vemos en Valdivia, - - Equipo 9punto5 - - 9punto5 Illustration -
-
-
+ + )} + + CONFERENCIA - 8 y 9 NOV + 9:30 – 18:00 HORAS + + Más de 40 charlas para elegir y 3 auditorios en paralelo. + + + Incluye kit de bienvenida 2024 y coffee breaks. + + Además considera que pronto podrás agregar: + + + Puedes ver tu entrada y novedades ingresando a tu perfil: + + + + + +
+ + IR A MI PERFIL + +
+ + Nos vemos en Valdivia, + + Equipo 9punto5 + + 9punto5 Illustration + ); }; diff --git a/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx b/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx new file mode 100644 index 00000000..cb4b372a --- /dev/null +++ b/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx @@ -0,0 +1,49 @@ +import { Text } from "@react-email/components"; +import * as React from "react"; + +import { TicketTemplate9punto5 } from "emails/templates/helpers/9punto5"; + +type Props = { + recipientName: string; + recipientEmail: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; +}; + +export const TicketGiftAcceptedByReceiver9punto5 = ({ + recipientName = "Juan", + recipientEmail = "juan@example.com", + senderName = "Pedro", + ticketType = "CONFERENCE", +}: Props) => { + return ( + + ¡Hola {senderName}! + + + ¡Buenas noticias! {recipientName} ({recipientEmail}) ha aceptado tu + regalo de entrada para: + + + + {ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 + + + + {recipientName} ha confirmado su asistencia y ahora está oficialmente + registrado para el evento. + + + + Ahora {recipientName} podrá disfrutar de esta increíble experiencia. Tu + apoyo ayuda a fortalecer nuestra comunidad y hacer posible este evento. + + + ¡Nos vemos en Valdivia! + + Equipo 9punto5 + + ); +}; + +export default TicketGiftAcceptedByReceiver9punto5; diff --git a/emails/templates/tickets/ticket-gift-received/9punto5.tsx b/emails/templates/tickets/ticket-gift-received/9punto5.tsx new file mode 100644 index 00000000..e5198d6a --- /dev/null +++ b/emails/templates/tickets/ticket-gift-received/9punto5.tsx @@ -0,0 +1,134 @@ +import { Img, Text } from "@react-email/components"; +import { format, setDefaultOptions } from "date-fns"; +import { es } from "date-fns/locale"; +import * as React from "react"; + +import { TicketTemplate9punto5 } from "emails/templates/helpers/9punto5"; + +setDefaultOptions({ locale: es }); + +type Props = { + giftId: string; + recipientName: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; + giftMessage?: string | null; + expirationDate: Date; +}; + +export const TicketGiftReceived9punto5 = ({ + giftId, + recipientName = "Juan", + senderName = "Pedro", + ticketType = "CONFERENCE", + giftMessage = "Mensaje de regalo", + expirationDate, +}: Props) => { + return ( + + ¡Hola {recipientName}! + + + Tenemos una gran noticia para ti. {senderName} te ha + regalado una entrada para: + + + + {ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 + + + {giftMessage && ( + "{giftMessage}" + )} + + {ticketType === "EXPERIENCE" && ( + <> + GREEN CARPET - 7 NOV + 20:00 HORAS + + Evento estelar de bienvenida. Cocktail para compartir con la + comunidad y speakers. + + + ACCESO A TALLERES - 7 NOV + 14:30 y 16:30 HORAS + + Podrás participar en dos talleres que serán a las 14:30 y 16:30 + horas. Cupos limitados. + + + )} + + CONFERENCIA - 8 y 9 NOV + 9:30 – 18:00 HORAS + + Más de 40 charlas para elegir y 3 auditorios en paralelo. + + + Incluye kit de bienvenida 2024 y coffee breaks. + + Además, pronto podrás agregar: + + + + Para confirmar tu asistencia y ver los detalles de tu entrada, por favor + ingresa a tu perfil: + + + + + + +
+ + ACEPTAR REGALO + +
+ + + Importante: Tienes hasta el{" "} + {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} + para aceptar el regalo. + + + ¡Nos vemos en Valdivia! + + Equipo 9punto5 + + 9punto5 Illustration +
+ ); +}; + +export default TicketGiftReceived9punto5; diff --git a/emails/templates/tickets/ticket-gift-sent/9punto5.tsx b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx new file mode 100644 index 00000000..78aedb44 --- /dev/null +++ b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx @@ -0,0 +1,65 @@ +import { Text } from "@react-email/components"; +import { format, setDefaultOptions } from "date-fns"; +import { es } from "date-fns/locale"; +import * as React from "react"; + +import { TicketTemplate9punto5 } from "emails/templates/helpers/9punto5"; + +setDefaultOptions({ locale: es }); + +type Props = { + recipientName: string; + recipientEmail: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; + giftMessage?: string | null; + expirationDate: Date; +}; + +export const TicketGiftSent9punto5 = ({ + recipientName = "Juan", + recipientEmail = "pedro@example.com", + senderName = "Pedro", + ticketType = "CONFERENCE", + giftMessage = "Mensaje de regalo", + expirationDate, +}: Props) => { + return ( + + ¡Hola {senderName}! + + + Tu regalo de entrada para {recipientName} ({recipientEmail}) ha sido + enviado con éxito. Aquí están los detalles: + + + + {ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 + + + {giftMessage && ( + + Tu mensaje de regalo: "{giftMessage}" + + )} + + + Hemos notificado a {recipientName} sobre este regalo y le hemos + proporcionado un enlace para confirmar su asistencia ingresando a{" "} + https://9punto5.cl + + + + {recipientName} tendrá hasta el{" "} + {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} + para aceptar el regalo. + + + ¡Gracias por tu generoso regalo! + + Equipo 9punto5 + + ); +}; + +export default TicketGiftSent9punto5; diff --git a/src/datasources/db/tickets.ts b/src/datasources/db/tickets.ts index 48968b8e..7a705692 100644 --- a/src/datasources/db/tickets.ts +++ b/src/datasources/db/tickets.ts @@ -1,6 +1,7 @@ import { relations, sql } from "drizzle-orm"; import { boolean, + index, integer, pgTable, text, @@ -19,39 +20,45 @@ export const ticketStatusEnum = ["active", "inactive"] as const; export const ticketVisibilityEnum = ["public", "private", "unlisted"] as const; // TICKETS-TABLE -export const ticketsSchema = pgTable("tickets", { - id: uuid("id").primaryKey().notNull().defaultRandom(), - name: text("name").notNull().unique(), - description: text("description"), - status: text("status", { enum: ticketStatusEnum }) - .notNull() - .default("inactive"), - tags: text("tags") - .$type() - .array() - .notNull() - .default(sql`ARRAY[]::text[]`), - externalLink: text("external_link"), - maxTicketsPerUser: integer("max_tickets_per_user"), - imageLink: text("image_link"), - visibility: text("visibility", { - enum: ticketVisibilityEnum, - }) - .notNull() - .default("unlisted"), - startDateTime: timestamp("start_date_time").notNull(), - endDateTime: timestamp("end_date_time"), - requiresApproval: boolean("requires_approval").notNull().default(false), - quantity: integer("quantity"), - isUnlimited: boolean("is_unlimited").notNull().default(false), - isFree: boolean("is_free").notNull(), - eventId: uuid("event_id") - .references(() => eventsSchema.id) - .notNull(), - stripeProductId: text("stripe_product_id"), - mercadoPagoProductId: text("mercado_pago_product_id"), - ...createdAndUpdatedAtFields, -}); +export const ticketsSchema = pgTable( + "tickets", + { + id: uuid("id").primaryKey().notNull().defaultRandom(), + name: text("name").notNull().unique(), + description: text("description"), + status: text("status", { enum: ticketStatusEnum }) + .notNull() + .default("inactive"), + tags: text("tags") + .$type() + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + externalLink: text("external_link"), + maxTicketsPerUser: integer("max_tickets_per_user"), + imageLink: text("image_link"), + visibility: text("visibility", { + enum: ticketVisibilityEnum, + }) + .notNull() + .default("unlisted"), + startDateTime: timestamp("start_date_time").notNull(), + endDateTime: timestamp("end_date_time"), + requiresApproval: boolean("requires_approval").notNull().default(false), + quantity: integer("quantity"), + isUnlimited: boolean("is_unlimited").notNull().default(false), + isFree: boolean("is_free").notNull(), + eventId: uuid("event_id") + .references(() => eventsSchema.id) + .notNull(), + stripeProductId: text("stripe_product_id"), + mercadoPagoProductId: text("mercado_pago_product_id"), + ...createdAndUpdatedAtFields, + }, + (table) => ({ + eventIdIndex: index("tickets_event_id_index").on(table.eventId), + }), +); export const ticketRelations = relations(ticketsSchema, ({ one, many }) => ({ event: one(eventsSchema, { diff --git a/src/datasources/db/userTickets.ts b/src/datasources/db/userTickets.ts index 201453e3..b6048150 100644 --- a/src/datasources/db/userTickets.ts +++ b/src/datasources/db/userTickets.ts @@ -1,6 +1,14 @@ import { relations } from "drizzle-orm"; -import { index, pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { + pgTable, + uuid, + text, + timestamp, + index, + boolean, +} from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; import { purchaseOrdersSchema, ticketsSchema, usersSchema } from "./schema"; import { createdAndUpdatedAtFields } from "./shared"; @@ -17,18 +25,30 @@ export const userTicketsApprovalStatusEnum = [ export const userTicketsRedemptionStatusEnum = ["redeemed", "pending"] as const; +export enum UserTicketGiftStatus { + Pending = "pending", + Accepted = "accepted", + Rejected = "rejected", + Cancelled = "cancelled", + Expired = "expired", +} + // USER-TICKETS-TABLE export const userTicketsSchema = pgTable( "user_tickets", { id: uuid("id").primaryKey().notNull().defaultRandom(), publicId: uuid("public_id").notNull().defaultRandom(), - userId: uuid("user_id").references(() => usersSchema.id), + userId: uuid("user_id") + .references(() => usersSchema.id) + .notNull(), ticketTemplateId: uuid("ticket_template_id") .references(() => ticketsSchema.id) .notNull(), purchaseOrderId: uuid("purchase_order_id") - .references(() => purchaseOrdersSchema.id) + .references(() => purchaseOrdersSchema.id, { + onDelete: "cascade", + }) .notNull(), approvalStatus: text("approval_status", { enum: userTicketsApprovalStatusEnum, @@ -44,23 +64,98 @@ export const userTicketsSchema = pgTable( }, (table) => ({ publicIdIndex: index("user_tickets_public_id_index").on(table.publicId), + ticketTemplateIdIndex: index("user_tickets_ticket_template_id_index").on( + table.ticketTemplateId, + ), + userIdIndex: index("user_tickets_user_id_index").on(table.userId), + approvalStatusIndex: index("user_tickets_approval_status_index").on( + table.approvalStatus, + ), + purchaseOrderIdIndex: index("user_tickets_purchase_order_id_index").on( + table.purchaseOrderId, + ), }), ); -export const userTicketsRelations = relations(userTicketsSchema, ({ one }) => ({ - ticketTemplate: one(ticketsSchema, { - fields: [userTicketsSchema.ticketTemplateId], - references: [ticketsSchema.id], +export const userTicketGiftsSchema = pgTable( + "user_ticket_gifts", + { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userTicketId: uuid("user_ticket_id") + .references(() => userTicketsSchema.id) + .notNull(), + gifterUserId: uuid("gifter_user_id") + .references(() => usersSchema.id) + .notNull(), + receiverUserId: uuid("receiver_user_id") + .references(() => usersSchema.id) + .notNull(), + status: text("status", { + enum: [ + UserTicketGiftStatus.Pending, + UserTicketGiftStatus.Accepted, + UserTicketGiftStatus.Rejected, + UserTicketGiftStatus.Cancelled, + UserTicketGiftStatus.Expired, + ], + }) + .default(UserTicketGiftStatus.Pending) + .notNull(), + giftMessage: text("gift_message"), + expirationDate: timestamp("expiration_date").notNull(), + isReturn: boolean("is_return").default(false).notNull(), + ...createdAndUpdatedAtFields, + }, + (table) => ({ + userTicketIdIndex: index("user_ticket_gifts_user_ticket_id_index").on( + table.userTicketId, + ), + gifterUserIdIndex: index("user_ticket_gifts_gifter_user_id_index").on( + table.gifterUserId, + ), + receiverUserIdIndex: index("user_ticket_gifts_receiver_user_id_index").on( + table.receiverUserId, + ), }), - purchaseOrdersSchema: one(purchaseOrdersSchema, { - fields: [userTicketsSchema.purchaseOrderId], - references: [purchaseOrdersSchema.id], +); + +// Relations +export const userTicketsRelations = relations( + userTicketsSchema, + ({ one, many }) => ({ + ticketTemplate: one(ticketsSchema, { + fields: [userTicketsSchema.ticketTemplateId], + references: [ticketsSchema.id], + }), + purchaseOrdersSchema: one(purchaseOrdersSchema, { + fields: [userTicketsSchema.purchaseOrderId], + references: [purchaseOrdersSchema.id], + }), + user: one(usersSchema, { + fields: [userTicketsSchema.userId], + references: [usersSchema.id], + }), + giftAttempts: many(userTicketGiftsSchema), }), - user: one(usersSchema, { - fields: [userTicketsSchema.userId], - references: [usersSchema.id], +); + +export const userTicketGiftsRelations = relations( + userTicketGiftsSchema, + ({ one }) => ({ + userTicket: one(userTicketsSchema, { + fields: [userTicketGiftsSchema.userTicketId], + references: [userTicketsSchema.id], + }), + gifterUser: one(usersSchema, { + fields: [userTicketGiftsSchema.gifterUserId], + references: [usersSchema.id], + }), + receiverUser: one(usersSchema, { + fields: [userTicketGiftsSchema.receiverUserId], + references: [usersSchema.id], + }), }), -})); +); export const selectUserTicketsSchema = createSelectSchema(userTicketsSchema); @@ -69,3 +164,25 @@ export const insertUserTicketsSchema = createInsertSchema(userTicketsSchema); export const approveUserTicketsSchema = selectUserTicketsSchema.pick({ approvalStatus: true, }); + +export type SelectUserTicketSchema = z.infer; + +export type InsertUserTicketSchema = z.infer; + +export type ApproveUserTicketsSchema = z.infer; + +export const selectUserTicketGiftSchema = createSelectSchema( + userTicketGiftsSchema, +); + +export type SelectUserTicketGiftSchema = z.infer< + typeof selectUserTicketGiftSchema +>; + +export const insertUserTicketGiftSchema = createInsertSchema( + userTicketGiftsSchema, +); + +export type InsertUserTicketGiftSchema = z.infer< + typeof insertUserTicketGiftSchema +>; diff --git a/src/datasources/db/users.ts b/src/datasources/db/users.ts index fefc7411..87093df6 100644 --- a/src/datasources/db/users.ts +++ b/src/datasources/db/users.ts @@ -88,6 +88,8 @@ export const selectUsersSchema = createSelectSchema(usersSchema); export const insertUsersSchema = createInsertSchema(usersSchema); +export type InsertUserSchema = z.infer; + export const updateUsersSchema = insertUsersSchema .pick({ name: true, diff --git a/src/datasources/helpers/index.ts b/src/datasources/helpers/index.ts index 35f6dbe2..a488d31f 100644 --- a/src/datasources/helpers/index.ts +++ b/src/datasources/helpers/index.ts @@ -1,5 +1,9 @@ const unMinutoEnMilisegundos = 60000; +export const someDaysIntoTheFuture = (days: number) => { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +}; + export const someMinutesIntoTheFuture = (minutes: number) => { return new Date(Date.now() + minutes * unMinutoEnMilisegundos); }; diff --git a/src/generated/schema.gql b/src/generated/schema.gql index 25445299..f5bc99f0 100644 --- a/src/generated/schema.gql +++ b/src/generated/schema.gql @@ -161,6 +161,7 @@ type Event { mobileBannerImage: Image name: String! previewImage: Image + publicShareURL: String schedules: [Schedule!]! speakers: [Speaker!]! startDateTime: DateTime! @@ -291,6 +292,25 @@ input GeneratePaymentLinkInput { currencyId: String! } +enum GiftAttemptStatus { + Accepted + Cancelled + Expired + Pending + Rejected +} + +input GiftInfoInput { + email: String! + message: String! + name: String! +} + +type GiftTicketUserInfo { + email: String! + name: String +} + input GiftTicketsToUserInput { allowMultipleTicketsPerUsers: Boolean! autoApproveTickets: Boolean! @@ -315,7 +335,7 @@ enum ImageHostingEnum { } type Mutation { - acceptGiftedTicket(userTicketId: String!): UserTicket! + acceptGiftedTicket(giftId: String!): UserTicket! """ Accept the user's invitation to a team @@ -348,7 +368,7 @@ type Mutation { checkPurchaseOrderStatus(input: CheckForPurchaseOrderInput!): PurchaseOrder! """ - Attempt to claim a certain ammount of tickets + Attempt to claim and/or gift tickets """ claimUserTicket(input: TicketClaimInput!): RedeemUserTicketResponse! @@ -669,12 +689,14 @@ type PurchaseOrder { id: ID! paymentLink: String paymentPlatform: String + publicId: String purchasePaymentStatus: PurchaseOrderPaymentStatusEnum status: PurchaseOrderStatusEnum tickets: [UserTicket!]! } input PurchaseOrderInput { + giftInfo: [GiftInfoInput!]! quantity: Int! ticketId: String! } @@ -746,6 +768,16 @@ type Query { input: PaginatedInputMyPurchaseOrdersInput! ): PaginatedPurchaseOrder! + """ + Get a list of user ticket gifts received by the current user + """ + myReceivedTicketGifts: [UserTicketGift!]! + + """ + Get a list of user ticket gifts sent by the current user + """ + mySentTicketGifts: [UserTicketGift!]! + """ Get a list of tickets for the current user """ @@ -1231,6 +1263,7 @@ Representation of a User ticket type UserTicket { approvalStatus: TicketApprovalStatus! createdAt: DateTime! + giftAttempts: [UserTicketGift!]! id: ID! paymentStatus: PurchaseOrderPaymentStatusEnum publicId: String! @@ -1240,6 +1273,18 @@ type UserTicket { user: User } +""" +Representation of a user ticket gift +""" +type UserTicketGift { + expirationDate: DateTime! + gifter: GiftTicketUserInfo! + id: ID! + receiver: GiftTicketUserInfo! + status: GiftAttemptStatus! + userTicket: UserTicket! +} + """ Representation of a user in a team """ diff --git a/src/generated/types.ts b/src/generated/types.ts index 15e1d5dc..f15e6d1d 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -177,6 +177,7 @@ export type Event = { mobileBannerImage?: Maybe; name: Scalars["String"]["output"]; previewImage?: Maybe; + publicShareURL?: Maybe; schedules: Array; speakers: Array; startDateTime: Scalars["DateTime"]["output"]; @@ -308,6 +309,26 @@ export type GeneratePaymentLinkInput = { currencyId: Scalars["String"]["input"]; }; +export enum GiftAttemptStatus { + Accepted = "Accepted", + Cancelled = "Cancelled", + Expired = "Expired", + Pending = "Pending", + Rejected = "Rejected", +} + +export type GiftInfoInput = { + email: Scalars["String"]["input"]; + message: Scalars["String"]["input"]; + name: Scalars["String"]["input"]; +}; + +export type GiftTicketUserInfo = { + __typename?: "GiftTicketUserInfo"; + email: Scalars["String"]["output"]; + name?: Maybe; +}; + export type GiftTicketsToUserInput = { allowMultipleTicketsPerUsers: Scalars["Boolean"]["input"]; autoApproveTickets: Scalars["Boolean"]["input"]; @@ -345,7 +366,7 @@ export type Mutation = { cancelUserTicket: UserTicket; /** Check the status of a purchase order */ checkPurchaseOrderStatus: PurchaseOrder; - /** Attempt to claim a certain ammount of tickets */ + /** Attempt to claim and/or gift tickets */ claimUserTicket: RedeemUserTicketResponse; /** Create an community */ createCommunity: Community; @@ -398,7 +419,7 @@ export type Mutation = { }; export type MutationAcceptGiftedTicketArgs = { - userTicketId: Scalars["String"]["input"]; + giftId: Scalars["String"]["input"]; }; export type MutationAcceptTeamInvitationArgs = { @@ -709,12 +730,14 @@ export type PurchaseOrder = { id: Scalars["ID"]["output"]; paymentLink?: Maybe; paymentPlatform?: Maybe; + publicId?: Maybe; purchasePaymentStatus?: Maybe; status?: Maybe; tickets: Array; }; export type PurchaseOrderInput = { + giftInfo: Array; quantity: Scalars["Int"]["input"]; ticketId: Scalars["String"]["input"]; }; @@ -753,6 +776,10 @@ export type Query = { me: User; /** Get a list of purchase orders for the authenticated user */ myPurchaseOrders: PaginatedPurchaseOrder; + /** Get a list of user ticket gifts received by the current user */ + myReceivedTicketGifts: Array; + /** Get a list of user ticket gifts sent by the current user */ + mySentTicketGifts: Array; /** Get a list of tickets for the current user */ myTickets: PaginatedUserTicket; /** Get public event attendance info */ @@ -1252,6 +1279,7 @@ export type UserTicket = { __typename?: "UserTicket"; approvalStatus: TicketApprovalStatus; createdAt: Scalars["DateTime"]["output"]; + giftAttempts: Array; id: Scalars["ID"]["output"]; paymentStatus?: Maybe; publicId: Scalars["String"]["output"]; @@ -1261,6 +1289,17 @@ export type UserTicket = { user?: Maybe; }; +/** Representation of a user ticket gift */ +export type UserTicketGift = { + __typename?: "UserTicketGift"; + expirationDate: Scalars["DateTime"]["output"]; + gifter: GiftTicketUserInfo; + id: Scalars["ID"]["output"]; + receiver: GiftTicketUserInfo; + status: GiftAttemptStatus; + userTicket: UserTicket; +}; + /** Representation of a user in a team */ export type UserWithStatusRef = { __typename?: "UserWithStatusRef"; diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index f16f36df..4e611749 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -4,6 +4,7 @@ import { AsyncReturnType } from "type-fest"; import { ORM_TYPE } from "~/datasources/db"; import { + InsertUserTicketGiftSchema, USER, puchaseOrderPaymentStatusEnum, purchaseOrderStatusEnum, @@ -11,8 +12,10 @@ import { selectPurchaseOrdersSchema, selectTicketSchema, selectUserTicketsSchema, + userTicketGiftsSchema, userTicketsSchema, } from "~/datasources/db/schema"; +import { someDaysIntoTheFuture } from "~/datasources/helpers"; import { createMercadoPagoPayment, getMercadoPagoPayment, @@ -593,31 +596,6 @@ export const syncPurchaseOrderPaymentStatus = async ({ username: true, }, }, - userTickets: { - columns: { - id: true, - publicId: true, - }, - with: { - ticketTemplate: { - columns: { - id: true, - tags: true, - }, - with: { - event: { - columns: { - name: true, - addressDescriptiveName: true, - address: true, - startDateTime: true, - endDateTime: true, - }, - }, - }, - }, - }, - }, }, }) .then(async (res) => { @@ -651,6 +629,21 @@ export const syncPurchaseOrderPaymentStatus = async ({ }, }, }, + giftAttempts: { + columns: { + id: true, + giftMessage: true, + }, + with: { + receiverUser: { + columns: { + email: true, + name: true, + username: true, + }, + }, + }, + }, }, }); @@ -723,11 +716,63 @@ export const syncPurchaseOrderPaymentStatus = async ({ } if (poPaymentStatus === "paid") { - await DB.update(userTicketsSchema) - .set({ - approvalStatus: "approved", - }) - .where(eq(userTicketsSchema.purchaseOrderId, purchaseOrderId)); + for (const userTicket of purchaseOrder.userTickets) { + const hasGifts = userTicket.giftAttempts.length > 0; + + if (hasGifts) { + await DB.update(userTicketsSchema) + .set({ + approvalStatus: "gifted", + }) + .where(eq(userTicketsSchema.id, userTicket.id)); + } else { + await DB.update(userTicketsSchema) + .set({ + approvalStatus: "approved", + }) + .where(eq(userTicketsSchema.id, userTicket.id)); + } + + const expirationDate = someDaysIntoTheFuture(7); + + for (const giftAttempt of userTicket.giftAttempts) { + await transactionalEmailService.sendTicketGiftReceived({ + giftMessage: giftAttempt.giftMessage, + recipientEmail: giftAttempt.receiverUser.email, + recipientName: giftAttempt.receiverUser.name ?? "", + senderName: purchaseOrder.user.name ?? "", + ticketType: userTicket.ticketTemplate.tags.includes("conference") + ? "CONFERENCE" + : "EXPERIENCE", + giftId: giftAttempt.id, + expirationDate: expirationDate, + }); + + await transactionalEmailService.sendTicketGiftSent({ + giftMessage: giftAttempt.giftMessage, + recipientEmail: giftAttempt.receiverUser.email, + recipientName: giftAttempt.receiverUser.name ?? "", + senderName: purchaseOrder.user.name ?? "", + ticketType: userTicket.ticketTemplate.tags.includes("conference") + ? "CONFERENCE" + : "EXPERIENCE", + expirationDate: expirationDate, + }); + } + + const updateGiftValues: Partial = { + expirationDate: expirationDate, + }; + + await DB.update(userTicketGiftsSchema) + .set(updateGiftValues) + .where( + inArray( + userTicketGiftsSchema.id, + userTicket.giftAttempts.map((ga) => ga.id), + ), + ); + } await sendPurchaseOrderSuccessfulEmail({ transactionalEmailService, diff --git a/src/schema/shared/refs.ts b/src/schema/shared/refs.ts index 6c84157c..bff54f33 100644 --- a/src/schema/shared/refs.ts +++ b/src/schema/shared/refs.ts @@ -16,6 +16,7 @@ import { selectWorkSenioritySchema, selectPaymentLogsSchema, selectUserDataSchema, + SelectUserTicketGiftSchema, } from "~/datasources/db/schema"; import { SanityAsset, SanityEvent } from "~/datasources/sanity/types"; @@ -115,3 +116,6 @@ export const ConsolidatedPaymentLogEntryRef = builder.objectRef<{ platform: string; currencyId: string; }>("ConsolidatedPaymentLogEntry"); + +export const UserTicketGiftRef = + builder.objectRef("UserTicketGift"); diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index 0847d0e1..89fb1c2e 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -2,12 +2,13 @@ import { inArray } from "drizzle-orm"; import { ORM_TYPE } from "~/datasources/db"; import { TeamStatusEnum } from "~/datasources/db/teams"; -import { USER } from "~/datasources/db/users"; +import { InsertUserSchema, USER, usersSchema } from "~/datasources/db/users"; import { UserParticipationStatusEnum } from "~/datasources/db/userTeams"; import { approveUserTicketsSchema, userTicketsSchema, } from "~/datasources/db/userTickets"; +import { getUsername } from "~/datasources/queries/utils/createUsername"; import { applicationError, ServiceErrors } from "~/errors"; import { Logger } from "~/logging"; import { eventsFetcher } from "~/schema/events/eventsFetcher"; @@ -32,12 +33,21 @@ export const assertCanStartTicketClaimingForEvent = async ({ logger: Logger; }) => { const ticketIds = Object.keys(purchaseOrderByTickets); - const events = await eventsFetcher.searchEvents({ - DB, - search: { - ticketIds, - }, - }); + const [events, tickets] = await Promise.all([ + eventsFetcher.searchEvents({ + DB, + search: { + ticketIds, + }, + }), + + ticketsFetcher.searchTickets({ + DB, + search: { + ticketIds, + }, + }), + ]); if (events.length > 1) { throw applicationError( @@ -73,13 +83,6 @@ export const assertCanStartTicketClaimingForEvent = async ({ ); } - const tickets = await ticketsFetcher.searchTickets({ - DB, - search: { - ticketIds, - }, - }); - if (tickets.length !== ticketIds.length) { throw applicationError( "Not all tickets found for event", @@ -285,3 +288,71 @@ const bulkApproveUserTickets = async ({ return updated; }; + +export const getUsersFromPurchaseGiftsInfo = async ({ + DB, + giftsInfo, +}: { + DB: ORM_TYPE; + giftsInfo: { + name: string; + email: string; + }[]; +}) => { + // Fetch existing users in a single query + const existingUsers = await DB.query.usersSchema.findMany({ + where: (users, { inArray }) => + inArray( + users.email, + giftsInfo.map((gi) => gi.email), + ), + columns: { id: true, email: true, name: true }, + }); + + const emailToUser = new Map< + string, + { id: string; name: string | null; email: string } + >( + existingUsers.map((u) => [ + u.email, + { + id: u.id, + name: u.name, + email: u.email, + }, + ]), + ); + + // Prepare new users to be inserted + const newUserInserts = giftsInfo + .filter((recipient) => !emailToUser.has(recipient.email)) + .map((recipient) => { + const next: InsertUserSchema = { + email: recipient.email, + name: recipient.name, + username: getUsername(recipient.email), + }; + + return next; + }); + + if (newUserInserts.length > 0) { + const insertedUsers = await DB.insert(usersSchema) + .values(newUserInserts) + .returning({ + id: usersSchema.id, + email: usersSchema.email, + name: usersSchema.name, + }); + + insertedUsers.forEach((user) => + emailToUser.set(user.email, { + id: user.id, + name: user.name, + email: user.email, + }), + ); + } + + return emailToUser; +}; diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index 707b0e44..cb325cbf 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -1,11 +1,14 @@ -import { eq } from "drizzle-orm"; +import { and, count, eq, inArray } from "drizzle-orm"; import { GraphQLError } from "graphql"; import { builder } from "~/builder"; import { - insertUserTicketsSchema, + InsertUserTicketSchema, + UserTicketGiftStatus, + InsertUserTicketGiftSchema, selectPurchaseOrdersSchema, selectUserTicketsSchema, + userTicketGiftsSchema, userTicketsSchema, } from "~/datasources/db/schema"; import { applicationError, ServiceErrors } from "~/errors"; @@ -18,6 +21,7 @@ import { isValidUUID } from "~/schema/shared/helpers"; import { UserTicketRef } from "~/schema/shared/refs"; import { assertCanStartTicketClaimingForEvent, + getUsersFromPurchaseGiftsInfo, validateUserDataAndApproveUserTickets, } from "~/schema/userTickets/helpers"; import { @@ -29,6 +33,26 @@ import { import { RedeemUserTicketError } from "./types"; import { createPaymentIntent } from "../purchaseOrder/actions"; +type GiftInfoInput = { + email: string; + name: string; + message: string | null; +}; + +const GiftInfoInput = builder.inputType("GiftInfoInput", { + fields: (t) => ({ + email: t.string({ + required: true, + }), + name: t.string({ + required: true, + }), + message: t.string({ + required: true, + }), + }), +}); + const PurchaseOrderInput = builder.inputType("PurchaseOrderInput", { fields: (t) => ({ ticketId: t.string({ @@ -37,6 +61,10 @@ const PurchaseOrderInput = builder.inputType("PurchaseOrderInput", { quantity: t.int({ required: true, }), + giftInfo: t.field({ + type: [GiftInfoInput], + required: true, + }), }), }); @@ -256,13 +284,10 @@ builder.mutationField("redeemUserTicket", (t) => builder.mutationField("claimUserTicket", (t) => t.field({ - description: "Attempt to claim a certain ammount of tickets", + description: "Attempt to claim and/or gift tickets", type: RedeemUserTicketResponse, args: { - input: t.arg({ - type: TicketClaimInput, - required: true, - }), + input: t.arg({ type: TicketClaimInput, required: true }), }, authz: { rules: ["IsAuthenticated"], @@ -299,11 +324,13 @@ builder.mutationField("claimUserTicket", (t) => ); } + // Aggregate purchase order items by ticket ID const purchaseOrderByTickets: Record< string, { ticketId: string; quantity: number; + giftInfo: GiftInfoInput[]; } > = {}; @@ -328,10 +355,35 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets[item.ticketId] = { ticketId: item.ticketId, quantity: 0, + giftInfo: [], }; } purchaseOrderByTickets[item.ticketId].quantity += item.quantity; + + purchaseOrderByTickets[item.ticketId].giftInfo.push(...item.giftInfo); + } + + for (const ticket of purchaseOrder) { + if (ticket.giftInfo.length > ticket.quantity) { + return { + error: true as const, + errorMessage: + "No se puede regalar más tickets de los que se han comprado", + }; + } + + const isGiftingToSelf = ticket.giftInfo.some( + (gift) => + gift.email.toLowerCase().trim() === USER.email.toLowerCase().trim(), + ); + + if (isGiftingToSelf) { + return { + error: true as const, + errorMessage: "Cannot gift to yourself", + }; + } } try { @@ -343,22 +395,22 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets, logger, }); - const createdPurchaseOrder = await createInitialPurchaseOrder({ + + const ticketTemplatesIds = Object.keys(purchaseOrderByTickets); + + const emailsToUsersData = await getUsersFromPurchaseGiftsInfo({ DB: trx, - userId: USER.id, - logger, + giftsInfo: purchaseOrder.flatMap((p) => p.giftInfo), }); - let claimedTickets: Array = - []; - - // TODO: Measure and consider parallelizing this. - for (const { quantity, ticketId } of Object.values( - purchaseOrderByTickets, - )) { - // We pull the ticket template to see if it exists. - const ticketTemplate = await trx.query.ticketsSchema.findFirst({ - where: (t, { eq }) => eq(t.id, ticketId), + const [createdPurchaseOrder, ticketTemplates] = await Promise.all([ + createInitialPurchaseOrder({ + DB: trx, + userId: USER.id, + logger, + }), + trx.query.ticketsSchema.findMany({ + where: (t, { inArray }) => inArray(t.id, ticketTemplatesIds), with: { event: true, ticketsPrices: { @@ -367,27 +419,33 @@ builder.mutationField("claimUserTicket", (t) => }, }, }, - }); - - // If the ticket template does not exist, we throw an error. - if (!ticketTemplate) { - throw applicationError( - `Ticket template with id ${ticketId} not found`, - ServiceErrors.NOT_FOUND, - logger, - ); - } + }), + ]); + + const notFoundTicketTemplatesIds = ticketTemplatesIds.filter( + (ticketId) => !ticketTemplates.find((t) => t.id === ticketId), + ); + + if (notFoundTicketTemplatesIds.length > 0) { + throw applicationError( + `Tickets with ids ${notFoundTicketTemplatesIds.join( + ", ", + )} not found`, + ServiceErrors.NOT_FOUND, + logger, + ); + } - const requiresPayment = - ticketTemplate.ticketsPrices && - ticketTemplate.ticketsPrices.length > 0 && - ticketTemplate.ticketsPrices.some( - (tp) => - tp?.price?.price_in_cents !== null && - tp?.price?.price_in_cents > 0, - ); + const claimedTickets: InsertUserTicketSchema[] = []; + // const giftAttempts: InsertUserTicketGiftSchema[] = []; + // Process each ticket template + for (const ticketTemplate of ticketTemplates) { const { event } = ticketTemplate; + const quantityToPurchase = + purchaseOrderByTickets[ticketTemplate.id].quantity; + const giftInfoForTicket = + purchaseOrderByTickets[ticketTemplate.id].giftInfo; // If the event is not active, we throw an error. if (event.status === "inactive") { @@ -398,92 +456,157 @@ builder.mutationField("claimUserTicket", (t) => ); } - // If the ticket template has a quantity field, means there's a - // limit to the amount of tickets that can be created. So we check - // if we have enough tickets to fulfill the purchase order. - if (ticketTemplate.quantity) { - // We pull the tickets that are already reserved or in-process to - // see if we have enough to fulfill the purchase order - const tickets = await trx.query.userTicketsSchema.findMany({ - where: (uts, { eq, and, notInArray }) => - and( - eq(uts.ticketTemplateId, ticketId), - notInArray(uts.approvalStatus, ["rejected", "cancelled"]), - ), - columns: { - id: true, - }, - }); + const isApproved = + ticketTemplate.isFree && !ticketTemplate.requiresApproval; + + for (let i = 0; i < quantityToPurchase; i++) { + const isGift = i < giftInfoForTicket.length; + const giftInfo = isGift ? giftInfoForTicket[i] : null; + const receiverUser = giftInfo + ? emailsToUsersData.get(giftInfo.email) + : null; + + if (isGift && !giftInfo) { + throw applicationError( + `Gift info is required for ticket ${i + 1}`, + ServiceErrors.INVALID_ARGUMENT, + logger, + ); + } - // If we would be going over the limit of tickets, we throw an - // error. - if (tickets.length + quantity > ticketTemplate.quantity) { - throw new Error( - `Not enough tickets for ticket template with id ${ticketId}`, + if (!receiverUser && giftInfo) { + throw applicationError( + `User for email ${giftInfo.email} not found`, + ServiceErrors.NOT_FOUND, + logger, ); } + + const newTicket: InsertUserTicketSchema = { + userId: USER.id, + purchaseOrderId: createdPurchaseOrder.id, + ticketTemplateId: ticketTemplate.id, + approvalStatus: isApproved ? "approved" : "pending", + }; + + claimedTickets.push(newTicket); } + } - const isApproved = - ticketTemplate.isFree && !ticketTemplate.requiresApproval; + logger.info(`Creating ${claimedTickets.length} user tickets`); + + // Bulk insert claimed tickets + const createdUserTickets = await trx + .insert(userTicketsSchema) + .values(claimedTickets) + .returning(); + + // Create a map of ticketTemplateId to created userTickets + const ticketTemplateToUserTickets = createdUserTickets.reduce( + (acc, ticket) => { + if (!acc[ticket.ticketTemplateId]) { + acc[ticket.ticketTemplateId] = []; + } - // If no errors were thrown, we can proceed to reserve the - // tickets. - const newTickets = new Array(quantity) - .fill(false) - .map(() => { - const result = insertUserTicketsSchema.safeParse({ - userId: USER.id, - purchaseOrderId: createdPurchaseOrder.id, - ticketTemplateId: ticketTemplate.id, - paymentStatus: requiresPayment ? "unpaid" : "not_required", - approvalStatus: isApproved ? "approved" : "pending", + acc[ticket.ticketTemplateId].push(ticket); + + return acc; + }, + {} as Record, + ); + + // Prepare gift attempts + const giftAttempts: InsertUserTicketGiftSchema[] = []; + + for (const item of purchaseOrder) { + const userTickets = + ticketTemplateToUserTickets[item.ticketId] || []; + const giftInfo = purchaseOrderByTickets[item.ticketId].giftInfo; + + giftInfo.forEach((giftInfo, index) => { + const userTicket = userTickets[index]; + const receiverUser = emailsToUsersData.get(giftInfo.email); + + if (receiverUser) { + giftAttempts.push({ + userTicketId: userTicket.id, + gifterUserId: USER.id, + receiverUserId: receiverUser.id, + status: UserTicketGiftStatus.Pending, + giftMessage: giftInfo.message || null, + // Temporary, this will be updated + // when the payment is done + expirationDate: new Date(), + isReturn: false, }); + } else { + throw applicationError( + `User for email ${giftInfo.email} not found`, + ServiceErrors.INTERNAL_SERVER_ERROR, + logger, + ); + } + }); + } - if (result.success) { - return result.data; - } + // Insert gift attempts if any + if (giftAttempts.length > 0) { + await trx.insert(userTicketGiftsSchema).values(giftAttempts); + } - logger.error("Could not parse user ticket", result.error); - }) - .filter(Boolean); + // Bulk query for existing ticket counts + const finalTicketsCount = await trx + .select({ + ticketTemplateId: userTicketsSchema.ticketTemplateId, + count: count(userTicketsSchema.id), + }) + .from(userTicketsSchema) + .where( + and( + inArray( + userTicketsSchema.ticketTemplateId, + ticketTemplates.map((t) => t.id), + ), + inArray(userTicketsSchema.approvalStatus, [ + "approved", + "pending", + "gifted", + "not_required", + "gift_accepted", + ]), + ), + ) + .groupBy(userTicketsSchema.ticketTemplateId); + + for (const ticketTemplate of ticketTemplates) { + const existingCount = + finalTicketsCount.find( + (count) => count.ticketTemplateId === ticketTemplate.id, + )?.count || 0; + + const limitAlreadyReached = ticketTemplate.quantity + ? existingCount > ticketTemplate.quantity + : false; logger.info( - `Creating ${newTickets.length} user tickets for ticket template with id ${ticketId}`, - { newTickets }, + `Ticket template with id ${ + ticketTemplate.id + } has ${existingCount} tickets ${ + limitAlreadyReached ? "and has reached its limit" : "" + } + `, ); - if (newTickets.length === 0) { - throw new Error("Could not create user tickets"); - } - - const createdUserTickets = await trx - .insert(userTicketsSchema) - .values(newTickets) - .returning() - .execute(); - // if the ticket has a quantity field, we do a last check to see // if we have enough gone over the limit of tickets. - const finalTickets = await trx.query.userTicketsSchema.findMany({ - where: (uts, { eq, and, inArray }) => - and( - eq(uts.ticketTemplateId, ticketId), - inArray(uts.approvalStatus, ["approved", "pending"]), - ), - }); - - if (ticketTemplate.quantity) { - if (finalTickets.length > ticketTemplate.quantity) { - throw new Error( - `We have gone over the limit of tickets for ticket template with id ${ticketId}`, - ); - } + if (limitAlreadyReached) { + throw new Error( + `We have gone over the limit of tickets for ticket template with id ${ticketTemplate.id}`, + ); } - - claimedTickets = [...claimedTickets, ...createdUserTickets]; } + // Fetch the created purchase order const foundPurchaseOrder = await trx.query.purchaseOrdersSchema.findFirst({ where: (po, { eq }) => eq(po.id, createdPurchaseOrder.id), @@ -496,6 +619,7 @@ builder.mutationField("claimUserTicket", (t) => const selectedPurchaseOrder = selectPurchaseOrdersSchema.parse(foundPurchaseOrder); + // Generate payment link if requested if (generatePaymentLink) { logger.info("Extracting redirect URLs for purchase order"); const { paymentSuccessRedirectURL, paymentCancelRedirectURL } = @@ -516,17 +640,17 @@ builder.mutationField("claimUserTicket", (t) => currencyId: generatePaymentLink.currencyId, logger, }); - const tickets = await trx.query.userTicketsSchema.findMany({ - where: (uts, { inArray }) => inArray(uts.id, ticketsIds), - }); return { - selectedPurchaseOrder: purchaseOrder, - claimedTickets: tickets, + purchaseOrder, + ticketsIds, }; } - return { selectedPurchaseOrder, claimedTickets }; + return { + purchaseOrder: selectedPurchaseOrder, + ticketsIds: createdUserTickets.map((ticket) => ticket.id), + }; } catch (e) { logger.error((e as Error).message); @@ -542,17 +666,11 @@ builder.mutationField("claimUserTicket", (t) => } }); - const { claimedTickets, selectedPurchaseOrder } = transactionResults; - const ticketsIds = claimedTickets.flatMap((t) => (t.id ? [t.id] : [])); - - return { - purchaseOrder: selectedPurchaseOrder, - ticketsIds, - }; + return transactionResults; } catch (e: unknown) { - if (transactionError) { - logger.error("Error claiming usertickets", transactionError); + logger.error("Error claiming user tickets", e); + if (transactionError) { return { error: true as const, errorMessage: (transactionError as GraphQLError).message, @@ -571,42 +689,95 @@ builder.mutationField("acceptGiftedTicket", (t) => t.field({ type: UserTicketRef, args: { - userTicketId: t.arg.string({ + giftId: t.arg.string({ required: true, }), }, authz: { rules: ["IsAuthenticated"], }, - resolve: async (root, { userTicketId }, { DB, USER }) => { + resolve: async (root, { giftId }, { DB, USER, RPC_SERVICE_EMAIL }) => { if (!USER) { throw new GraphQLError("User not found"); } - // find the ticket for the user - const ticket = await DB.query.userTicketsSchema.findFirst({ + // find the ticket gift + const ticketGift = await DB.query.userTicketGiftsSchema.findFirst({ where: (t, { eq, and }) => - and(eq(t.id, userTicketId), eq(t.userId, USER.id)), + and(eq(t.id, giftId), eq(t.receiverUserId, USER.id)), + columns: { + id: true, + status: true, + expirationDate: true, + userTicketId: true, + gifterUserId: true, + }, + with: { + gifterUser: { + columns: { + name: true, + email: true, + username: true, + }, + }, + userTicket: { + with: { + ticketTemplate: { + columns: { + tags: true, + }, + }, + }, + }, + }, }); - if (!ticket) { + if (!ticketGift) { throw new GraphQLError("Could not find ticket to accept"); } - if (ticket.approvalStatus !== "gifted") { + if (ticketGift.status !== UserTicketGiftStatus.Pending) { throw new GraphQLError("Ticket is not a gifted ticket"); } - const updatedTicket = ( - await DB.update(userTicketsSchema) + if (ticketGift.expirationDate <= new Date()) { + await DB.update(userTicketGiftsSchema) .set({ - approvalStatus: "approved", + status: UserTicketGiftStatus.Expired, }) - .where(eq(userTicketsSchema.id, userTicketId)) - .returning() - )?.[0]; + .where(eq(userTicketGiftsSchema.id, ticketGift.id)); + + throw new GraphQLError("Gift attempt has expired"); + } + + const updatedTicket = await DB.update(userTicketsSchema) + .set({ + approvalStatus: "gift_accepted", + userId: USER.id, + }) + .where(eq(userTicketsSchema.id, ticketGift.userTicketId)) + .returning() + .then((t) => t?.[0]); + + await DB.update(userTicketGiftsSchema) + .set({ + status: UserTicketGiftStatus.Accepted, + }) + .where(eq(userTicketGiftsSchema.id, ticketGift.id)); + + await RPC_SERVICE_EMAIL.sendTicketGiftAcceptedByReceiver({ + recipientName: USER.name ?? USER.username, + recipientEmail: USER.email, + senderName: + ticketGift.gifterUser.name ?? ticketGift.gifterUser.username, + ticketType: ticketGift.userTicket.ticketTemplate.tags.includes( + "conference", + ) + ? "CONFERENCE" + : "EXPERIENCE", + }); - return selectUserTicketsSchema.parse(updatedTicket); + return updatedTicket; }, }), ); diff --git a/src/schema/userTickets/queries.ts b/src/schema/userTickets/queries.ts index 08380a4a..65b27006 100644 --- a/src/schema/userTickets/queries.ts +++ b/src/schema/userTickets/queries.ts @@ -9,7 +9,11 @@ import { createPaginationInputType, createPaginationObjectType, } from "~/schema/pagination/types"; -import { PublicUserTicketRef, UserTicketRef } from "~/schema/shared/refs"; +import { + PublicUserTicketRef, + UserTicketGiftRef, + UserTicketRef, +} from "~/schema/shared/refs"; import { userTicketFetcher } from "~/schema/userTickets/userTicketFetcher"; import { @@ -104,6 +108,42 @@ builder.queryFields((t) => ({ }; }, }), + myReceivedTicketGifts: t.field({ + description: "Get a list of user ticket gifts received by the current user", + type: [UserTicketGiftRef], + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async (root, args, { DB, USER }) => { + if (!USER) { + throw new Error("User not found"); + } + + const results = await DB.query.userTicketGiftsSchema.findMany({ + where: (utg, { eq }) => eq(utg.receiverUserId, USER.id), + }); + + return results; + }, + }), + mySentTicketGifts: t.field({ + description: "Get a list of user ticket gifts sent by the current user", + type: [UserTicketGiftRef], + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async (root, args, { DB, USER }) => { + if (!USER) { + throw new Error("User not found"); + } + + const results = await DB.query.userTicketGiftsSchema.findMany({ + where: (utg, { eq }) => eq(utg.gifterUserId, USER.id), + }); + + return results; + }, + }), })); const FindUserTicketSearchInput = builder.inputType( diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts index 0ed0c531..513fd302 100644 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts +++ b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts @@ -7,7 +7,7 @@ import type * as Types from '../../../../generated/types'; import type { JsonObject } from "type-fest"; import gql from 'graphql-tag'; export type AcceptGiftedTicketMutationVariables = Types.Exact<{ - userTicketId: Types.Scalars['String']['input']; + giftId: Types.Scalars['String']['input']; }>; @@ -15,8 +15,8 @@ export type AcceptGiftedTicketMutation = { __typename?: 'Mutation', acceptGifted export const AcceptGiftedTicket = gql` - mutation AcceptGiftedTicket($userTicketId: String!) { - acceptGiftedTicket(userTicketId: $userTicketId) { + mutation AcceptGiftedTicket($giftId: String!) { + acceptGiftedTicket(giftId: $giftId) { id paymentStatus approvalStatus diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql index 2f71bb7b..c120bebd 100644 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql +++ b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql @@ -1,5 +1,5 @@ -mutation AcceptGiftedTicket($userTicketId: String!) { - acceptGiftedTicket(userTicketId: $userTicketId) { +mutation AcceptGiftedTicket($giftId: String!) { + acceptGiftedTicket(giftId: $giftId) { id paymentStatus approvalStatus diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts index c16343d6..c4c3d838 100644 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts +++ b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts @@ -1,7 +1,10 @@ -import { faker } from "@faker-js/faker"; import { it, describe, assert } from "vitest"; -import { userTicketsApprovalStatusEnum } from "~/datasources/db/userTickets"; +import { + UserTicketGiftStatus, + userTicketsApprovalStatusEnum, +} from "~/datasources/db/userTickets"; +import { someDaysIntoTheFuture } from "~/datasources/helpers"; import { executeGraphqlOperationAsUser, insertCommunity, @@ -11,6 +14,7 @@ import { insertTicket, insertTicketTemplate, insertUser, + insertUserTicketGift, } from "~/tests/fixtures"; import { @@ -29,25 +33,36 @@ const prepareTickets = async ( eventId: event1.id, communityId: community1.id, }); - const user1 = await insertUser(); + const gifterUser = await insertUser(); + const receiverUser = await insertUser(); const ticketTemplate1 = await insertTicketTemplate({ eventId: event1.id, }); const purchaseOrder = await insertPurchaseOrder(); const ticket1 = await insertTicket({ ticketTemplateId: ticketTemplate1.id, - userId: user1.id, + userId: gifterUser.id, purchaseOrderId: purchaseOrder.id, approvalStatus: status, }); + const ticketGift1 = await insertUserTicketGift({ + userTicketId: ticket1.id, + gifterUserId: gifterUser.id, + receiverUserId: receiverUser.id, + status: + status === "gifted" + ? UserTicketGiftStatus.Pending + : UserTicketGiftStatus.Accepted, + expirationDate: someDaysIntoTheFuture(1), + }); - return { ticket: ticket1, user: user1 }; + return { ticket: ticket1, gifterUser, receiverUser, ticketGift: ticketGift1 }; }; -describe("Redeem user ticket", () => { +describe("Accept user ticket gift", () => { describe("Should work", () => { it("If ticket is in a gifted status and user is ticket owner", async () => { - const { ticket, user } = await prepareTickets(); + const { receiverUser, ticketGift } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables @@ -55,24 +70,24 @@ describe("Redeem user ticket", () => { { document: AcceptGiftedTicket, variables: { - userTicketId: ticket.id, + giftId: ticketGift.id, }, }, - user, + receiverUser, ); assert.equal(response.errors, undefined); assert.equal( response.data?.acceptGiftedTicket?.approvalStatus, - "approved", + "gift_accepted", ); }); }); describe("Should throw an error", () => { it("if user is not owner", async () => { - const { ticket } = await prepareTickets(); + const { ticketGift } = await prepareTickets(); const otherUser = await insertUser(); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, @@ -81,7 +96,7 @@ describe("Redeem user ticket", () => { { document: AcceptGiftedTicket, variables: { - userTicketId: ticket.id, + giftId: ticketGift.id, }, }, otherUser, @@ -94,7 +109,7 @@ describe("Redeem user ticket", () => { }); it("if ticket does not exist", async () => { - const { user } = await prepareTickets(); + const { receiverUser } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables @@ -102,20 +117,18 @@ describe("Redeem user ticket", () => { { document: AcceptGiftedTicket, variables: { - userTicketId: faker.string.uuid(), + giftId: "non-existent-id", }, }, - user, + receiverUser, ); - assert.equal( - response.errors?.[0].message, - "Could not find ticket to accept", - ); + assert.equal(response.errors?.[0].message, "Unexpected error."); }); it("If tickets is not in a gifted state", async () => { - const { ticket, user } = await prepareTickets("gift_accepted"); + const { receiverUser, ticketGift } = + await prepareTickets("gift_accepted"); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables @@ -123,10 +136,10 @@ describe("Redeem user ticket", () => { { document: AcceptGiftedTicket, variables: { - userTicketId: ticket.id, + giftId: ticketGift.id, }, }, - user, + receiverUser, ); assert.equal( diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts index 98ba22ff..b8f89384 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts @@ -11,7 +11,7 @@ export type ClaimUserTicketMutationVariables = Types.Exact<{ }>; -export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; +export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus, giftAttempts: Array<{ __typename?: 'UserTicketGift', id: string, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, receiver: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null } }> }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; export const ClaimUserTicket = gql` @@ -25,6 +25,17 @@ export const ClaimUserTicket = gql` paymentStatus approvalStatus redemptionStatus + giftAttempts { + id + gifter { + email + name + } + receiver { + email + name + } + } } } ... on RedeemUserTicketError { diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql index 291115b8..917b51ef 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql @@ -8,6 +8,17 @@ mutation ClaimUserTicket($input: TicketClaimInput!) { paymentStatus approvalStatus redemptionStatus + giftAttempts { + id + gifter { + email + name + } + receiver { + email + name + } + } } } ... on RedeemUserTicketError { diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts index 68666eb0..ddc062e1 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts @@ -102,10 +102,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -144,10 +146,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -186,10 +190,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -228,10 +234,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -250,8 +258,8 @@ describe("Claim a user ticket", () => { }); }); - describe("Should prevent claiming when going over the limit", () => { - it("For a MEMBER user", async () => { + describe("Should handle quantity limits", () => { + it("Should not allow claiming more tickets than the max per user", async () => { const maxTicketsPerUser = 2; const event = await insertEvent({ status: "active", @@ -284,10 +292,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -310,21 +320,26 @@ describe("Claim a user ticket", () => { ); } }); - }); - describe("Should fail to create user tickets for a ticket in a waitlist state", () => { - it("For a MEMBER user", async () => { - const event = await insertEvent(); - const ticketTemplate = await insertTicketTemplate({ - tags: ["waitlist"], - eventId: event.id, + it("Should not allow claiming more tickets than available", async () => { + const createdEvent = await insertEvent({ + status: "active", }); - - const { user } = await createCommunityEventUserAndTicketTemplate({ - event, - ticketTemplate, + const createdTicketTemplate = await insertTicketTemplate({ + eventId: createdEvent.id, + quantity: 5, }); + const { community, user, ticketTemplate } = + await createCommunityEventUserAndTicketTemplate({ + event: createdEvent, + ticketTemplate: createdTicketTemplate, + }); + await insertUserToCommunity({ + communityId: community.id, + userId: user.id, + role: "member", + }); const response = await executeGraphqlOperationAsUser< ClaimUserTicketMutation, ClaimUserTicketMutationVariables @@ -336,7 +351,13 @@ describe("Claim a user ticket", () => { purchaseOrder: [ { ticketId: ticketTemplate.id, - quantity: 2, + quantity: 10, + giftInfo: [], + }, + { + ticketId: ticketTemplate.id, + quantity: 1, + giftInfo: [], }, ], }, @@ -357,27 +378,80 @@ describe("Claim a user ticket", () => { ) { assert.equal( response.data?.claimUserTicket.errorMessage, - `Ticket ${ticketTemplate.id} is a waitlist ticket. Cannot claim waitlist tickets`, + `We have gone over the limit of tickets for ticket template with id ${ticketTemplate.id}`, ); } }); + + it("Should allow claiming up to the available quantity", async () => { + const { community, user, ticketTemplate } = + await createCommunityEventUserAndTicketTemplate({ + ticketTemplate: await insertTicketTemplate({ + quantity: 5, + isFree: true, + isUnlimited: false, + }), + }); + + await insertUserToCommunity({ + communityId: community.id, + userId: user.id, + role: "member", + }); + + const response = await executeGraphqlOperationAsUser< + ClaimUserTicketMutation, + ClaimUserTicketMutationVariables + >( + { + document: ClaimUserTicket, + variables: { + input: { + purchaseOrder: [ + { + ticketId: ticketTemplate.id, + quantity: 5, + giftInfo: [], + }, + ], + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.claimUserTicket?.__typename, "PurchaseOrder"); + + if (response.data?.claimUserTicket?.__typename === "PurchaseOrder") { + assert.equal(response.data.claimUserTicket.tickets.length, 5); + } + }); }); - describe("Should NOT allow claiming", () => { - it("If the event is Inactive", async () => { + describe("Should handle gifting scenarios", () => { + it("Should handle gifting scenarios", async () => { const createdEvent = await insertEvent({ - status: "inactive", + status: "active", }); - const { community, user, ticketTemplate, event } = + const createdTicketTemplate = await insertTicketTemplate({ + eventId: createdEvent.id, + quantity: 10, + }); + const { community, user, ticketTemplate } = await createCommunityEventUserAndTicketTemplate({ event: createdEvent, + ticketTemplate: createdTicketTemplate, }); + const giftRecipient = await insertUser(); await insertUserToCommunity({ communityId: community.id, userId: user.id, role: "member", }); + const response = await executeGraphqlOperationAsUser< ClaimUserTicketMutation, ClaimUserTicketMutationVariables @@ -390,10 +464,132 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, + giftInfo: [ + { + name: "John Doe", + email: giftRecipient.email, + message: "Enjoy the event!", + }, + ], }, + ], + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.claimUserTicket?.__typename, "PurchaseOrder"); + + if (response.data?.claimUserTicket?.__typename === "PurchaseOrder") { + assert.equal(response.data.claimUserTicket.tickets.length, 2); + + assert.equal( + response.data.claimUserTicket.tickets[0].giftAttempts.length, + 1, + ); + + assert.equal( + response.data.claimUserTicket.tickets[0].giftAttempts[0].receiver + .email, + giftRecipient.email, + ); + } + }); + + it("Should not allow gifting to self", async () => { + const createdEvent = await insertEvent({ + status: "active", + }); + const createdTicketTemplate = await insertTicketTemplate({ + eventId: createdEvent.id, + quantity: 10, + }); + const { community, user, ticketTemplate } = + await createCommunityEventUserAndTicketTemplate({ + event: createdEvent, + ticketTemplate: createdTicketTemplate, + }); + + await insertUserToCommunity({ + communityId: community.id, + userId: user.id, + role: "member", + }); + + const response = await executeGraphqlOperationAsUser< + ClaimUserTicketMutation, + ClaimUserTicketMutationVariables + >( + { + document: ClaimUserTicket, + variables: { + input: { + purchaseOrder: [ { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [ + { + name: "Para mi", + email: user.email, + message: "Self-gift", + }, + ], + }, + ], + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal( + response.data?.claimUserTicket?.__typename, + "RedeemUserTicketError", + ); + + if ( + response.data?.claimUserTicket?.__typename === "RedeemUserTicketError" + ) { + assert.equal( + response.data.claimUserTicket.errorMessage, + "Cannot gift to yourself", + ); + } + }); + }); + + describe("Should fail to create user tickets for a ticket in a waitlist state", () => { + it("For a MEMBER user", async () => { + const event = await insertEvent(); + const ticketTemplate = await insertTicketTemplate({ + tags: ["waitlist"], + eventId: event.id, + }); + + const { user } = await createCommunityEventUserAndTicketTemplate({ + event, + ticketTemplate, + }); + + const response = await executeGraphqlOperationAsUser< + ClaimUserTicketMutation, + ClaimUserTicketMutationVariables + >( + { + document: ClaimUserTicket, + variables: { + input: { + purchaseOrder: [ + { + ticketId: ticketTemplate.id, + quantity: 2, + giftInfo: [], }, ], }, @@ -414,23 +610,20 @@ describe("Claim a user ticket", () => { ) { assert.equal( response.data?.claimUserTicket.errorMessage, - `Event ${event.id} is not active. Cannot claim tickets for an inactive event.`, + `Ticket ${ticketTemplate.id} is a waitlist ticket. Cannot claim waitlist tickets`, ); } }); + }); - it("If we would be going over ticket quantity", async () => { + describe("Should NOT allow claiming", () => { + it("if the event is Inactive", async () => { const createdEvent = await insertEvent({ - status: "active", - }); - const createdTicketTemplate = await insertTicketTemplate({ - eventId: createdEvent.id, - quantity: 5, + status: "inactive", }); - const { community, user, ticketTemplate } = + const { community, user, ticketTemplate, event } = await createCommunityEventUserAndTicketTemplate({ event: createdEvent, - ticketTemplate: createdTicketTemplate, }); await insertUserToCommunity({ @@ -449,11 +642,13 @@ describe("Claim a user ticket", () => { purchaseOrder: [ { ticketId: ticketTemplate.id, - quantity: 10, + quantity: 2, + giftInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, + giftInfo: [], }, ], }, @@ -474,7 +669,7 @@ describe("Claim a user ticket", () => { ) { assert.equal( response.data?.claimUserTicket.errorMessage, - `Not enough tickets for ticket template with id ${ticketTemplate.id}`, + `Event ${event.id} is not active. Cannot claim tickets for an inactive event.`, ); } }); diff --git a/src/schema/userTickets/types.ts b/src/schema/userTickets/types.ts index 3b089cf6..f73c5dc9 100644 --- a/src/schema/userTickets/types.ts +++ b/src/schema/userTickets/types.ts @@ -4,6 +4,8 @@ import { puchaseOrderPaymentStatusEnum, userTicketsRedemptionStatusEnum, selectUsersSchema, + UserTicketGiftStatus, + selectUserTicketsSchema, } from "~/datasources/db/schema"; import { PurchaseOrderLoadable, @@ -13,6 +15,7 @@ import { UserRef, UserTicketRef, PublicUserTicketRef, + UserTicketGiftRef, } from "~/schema/shared/refs"; import { TicketLoadable } from "~/schema/ticket/types"; import { UserLoadable } from "~/schema/user/types"; @@ -105,6 +108,28 @@ builder.objectType(UserTicketRef, { nullable: false, resolve: (root) => new Date(root.createdAt), }), + giftAttempts: t.field({ + type: [UserTicketGiftRef], + resolve: async (root, args, context) => { + const canRequest = + context.USER?.id === root.userId || context.USER?.isSuperAdmin; + + if (!canRequest) { + return []; + } + + if (!root.userId) { + return []; + } + + const userTicketGifts = + await context.DB.query.userTicketGiftsSchema.findMany({ + where: (utg, { eq }) => eq(utg.userTicketId, root.id), + }); + + return userTicketGifts; + }, + }), }), }); @@ -190,3 +215,78 @@ export const RedeemUserTicketError = builder.objectType( }), }, ); + +export const GiftAttemptStatusEnum = builder.enumType(UserTicketGiftStatus, { + name: "GiftAttemptStatus", +}); + +const GiftTicketUserInfo = builder.objectRef<{ + email: string; + name: string | null; +}>("GiftTicketUserInfo"); + +builder.objectType(GiftTicketUserInfo, { + fields: (t) => ({ + email: t.exposeString("email"), + name: t.exposeString("name", { + nullable: true, + }), + }), +}); + +builder.objectType(UserTicketGiftRef, { + description: "Representation of a user ticket gift", + fields: (t) => ({ + id: t.exposeID("id"), + gifter: t.field({ + type: GiftTicketUserInfo, + resolve: async (root, args, { DB }) => { + const user = await DB.query.usersSchema.findFirst({ + where: (u, { eq }) => eq(u.id, root.gifterUserId), + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + email: user.email, + name: user.name, + }; + }, + }), + receiver: t.field({ + type: GiftTicketUserInfo, + resolve: async (root, args, { DB }) => { + const user = await DB.query.usersSchema.findFirst({ + where: (u, { eq }) => eq(u.id, root.receiverUserId), + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + email: user.email, + name: user.name, + }; + }, + }), + status: t.expose("status", { type: GiftAttemptStatusEnum }), + expirationDate: t.expose("expirationDate", { type: "DateTime" }), + userTicket: t.field({ + type: UserTicketRef, + resolve: async (root, args, { DB }) => { + const userTicket = await DB.query.userTicketsSchema.findFirst({ + where: (ut, { eq }) => eq(ut.id, root.userTicketId), + }); + + if (!userTicket) { + throw new Error("User ticket not found"); + } + + return selectUserTicketsSchema.parse(userTicket); + }, + }), + }), +}); diff --git a/src/schema/waitlist/tests/fetchWaitlist.test.ts b/src/schema/waitlist/tests/fetchWaitlist.test.ts index 654e4e58..0cb8c2b3 100644 --- a/src/schema/waitlist/tests/fetchWaitlist.test.ts +++ b/src/schema/waitlist/tests/fetchWaitlist.test.ts @@ -34,6 +34,7 @@ describe("Should get my ticket for a waitlist", () => { ticketTemplateId: ticket.id, purchaseOrderId: purchaseOrder.id, approvalStatus: "pending", + userId: user1.id, }); await insertEventToCommunity({ diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index cadb1898..2259b267 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -40,6 +40,7 @@ import { insertTicketSchema, insertUserDataSchema, insertUserTeamsSchema, + insertUserTicketGiftSchema, insertUserTicketsSchema, insertUsersSchema, insertUsersToCommunitiesSchema, @@ -67,6 +68,7 @@ import { selectTicketSchema, selectUserDataSchema, selectUserTeamsSchema, + selectUserTicketGiftSchema, selectUserTicketsSchema, selectUsersSchema, selectUsersToCommunitiesSchema, @@ -80,6 +82,7 @@ import { ticketsSchema, userDataSchema, userTeamsSchema, + userTicketGiftsSchema, userTicketsSchema, usersSchema, usersTagsSchema, @@ -513,11 +516,12 @@ export const insertTicket = async ( > & { id?: string; purchaseOrderId?: string; + userId: string; }, ) => { const possibleInput = { id: partialInput?.id ?? faker.string.uuid(), - userId: partialInput?.userId, + userId: partialInput?.userId ?? (await insertUser()).id, purchaseOrderId: partialInput?.purchaseOrderId ?? (await insertPurchaseOrder()).id, ticketTemplateId: @@ -815,6 +819,27 @@ export const insertSalary = async ( ); }; +export const insertUserTicketGift = async ( + partialInput: z.infer, +) => { + const possibleInput = { + id: partialInput?.id ?? faker.string.uuid(), + userTicketId: partialInput?.userTicketId, + gifterUserId: partialInput?.gifterUserId, + receiverUserId: partialInput?.receiverUserId, + expirationDate: partialInput?.expirationDate, + status: partialInput?.status, + ...CRUDDates(partialInput), + } satisfies z.infer; + + return insertOne( + insertUserTicketGiftSchema, + selectUserTicketGiftSchema, + userTicketGiftsSchema, + possibleInput, + ); +}; + export const toISODate = ( date: T, ): T extends Date ? string : null => { diff --git a/src/tests/fixtures/mocks.ts b/src/tests/fixtures/mocks.ts index 54c34ace..5371569a 100644 --- a/src/tests/fixtures/mocks.ts +++ b/src/tests/fixtures/mocks.ts @@ -23,4 +23,7 @@ export const MOCKED_RPC_SERVICE_EMAIL = { sendConfirmationWaitlistRejected: vitest.fn(), bulkSendEventTicketInvitations: vitest.fn(), bulkSendUserQRTicketEmail: vitest.fn(), + sendTicketGiftAcceptedByReceiver: vitest.fn(), + sendTicketGiftReceived: vitest.fn(), + sendTicketGiftSent: vitest.fn(), } satisfies MockedService; diff --git a/workers/transactional_email_service/index.tsx b/workers/transactional_email_service/index.tsx index 58ba7f64..c6ac4b2e 100644 --- a/workers/transactional_email_service/index.tsx +++ b/workers/transactional_email_service/index.tsx @@ -4,6 +4,9 @@ import * as React from "react"; import { Resend } from "resend"; import { JSConfCLTicketConfirmation } from "emails/templates/tickets/purchase-order-successful/jsconfcl"; +import { TicketGiftAcceptedByReceiver9punto5 } from "emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5"; +import { TicketGiftReceived9punto5 } from "emails/templates/tickets/ticket-gift-received/9punto5"; +import { TicketGiftSent9punto5 } from "emails/templates/tickets/ticket-gift-sent/9punto5"; import { ResendEmailArgs, sendTransactionalHTMLEmail, @@ -414,4 +417,146 @@ export default class EmailService extends WorkerEntrypoint { await sendTransactionalHTMLEmail(this.resend, this.logger, resendArgs); } + + async sendTicketGiftReceived({ + giftId, + recipientName, + senderName, + ticketType, + giftMessage, + recipientEmail, + expirationDate, + }: { + giftId: string; + recipientName: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; + giftMessage: string | null; + recipientEmail: string; + expirationDate: Date; + }) { + this.logger.info(`About to send TicketGiftReceived`, { + giftId, + }); + + await sendTransactionalHTMLEmail(this.resend, this.logger, { + htmlContent: render( + , + ), + to: [ + { + name: recipientName, + email: recipientEmail, + }, + ], + from: { + name: "9punto5", + email: "tickets@updates.9punto5.cl", + }, + replyTo: "tickets@9punto5.cl", + subject: `Tienes un regalo de ${senderName} para ${ + ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" + } 9.5`, + }); + } + + async sendTicketGiftSent({ + recipientName, + recipientEmail, + senderName, + ticketType, + giftMessage, + expirationDate, + }: { + recipientName: string; + recipientEmail: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; + giftMessage: string | null; + expirationDate: Date; + }) { + this.logger.info(`About to send TicketGiftSent`, { + recipientName, + recipientEmail, + senderName, + ticketType, + giftMessage, + }); + + await sendTransactionalHTMLEmail(this.resend, this.logger, { + htmlContent: render( + , + ), + to: [ + { + name: recipientName, + email: recipientEmail, + }, + ], + from: { + name: "9punto5", + email: "tickets@updates.9punto5.cl", + }, + subject: `Tienes un regalo de ${senderName} para ${ + ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" + } 9.5`, + }); + } + + async sendTicketGiftAcceptedByReceiver({ + recipientName, + recipientEmail, + senderName, + ticketType, + }: { + recipientName: string; + recipientEmail: string; + senderName: string; + ticketType: "CONFERENCE" | "EXPERIENCE"; + }) { + this.logger.info(`About to send TicketGiftAcceptedByReceiver`, { + recipientName, + recipientEmail, + senderName, + ticketType, + }); + + await sendTransactionalHTMLEmail(this.resend, this.logger, { + htmlContent: render( + , + ), + to: [ + { + name: recipientName, + email: recipientEmail, + }, + ], + from: { + name: "9punto5", + email: "tickets@updates.9punto5.cl", + }, + subject: `${recipientName} aceptó tu regalo para ${ + ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" + } 9.5`, + }); + } } From 8147d6273931998d70ed8c0f1a3855e47184631e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 4 Oct 2024 19:15:07 -0300 Subject: [PATCH 02/19] feat: update ticket gift emails copy --- .../ticket-gift-accepted-by-receiver/9punto5.tsx | 2 +- .../tickets/ticket-gift-received/9punto5.tsx | 12 +++--------- .../tickets/ticket-gift-sent/9punto5.tsx | 15 ++++++++------- workers/transactional_email_service/index.tsx | 8 ++++---- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx b/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx index cb4b372a..9d4d6555 100644 --- a/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx +++ b/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx @@ -22,7 +22,7 @@ export const TicketGiftAcceptedByReceiver9punto5 = ({ ¡Buenas noticias! {recipientName} ({recipientEmail}) ha aceptado tu - regalo de entrada para: + entrada para: diff --git a/emails/templates/tickets/ticket-gift-received/9punto5.tsx b/emails/templates/tickets/ticket-gift-received/9punto5.tsx index e5198d6a..660e526d 100644 --- a/emails/templates/tickets/ticket-gift-received/9punto5.tsx +++ b/emails/templates/tickets/ticket-gift-received/9punto5.tsx @@ -30,7 +30,7 @@ export const TicketGiftReceived9punto5 = ({ Tenemos una gran noticia para ti. {senderName} te ha - regalado una entrada para: + enviado una entrada para: @@ -106,7 +106,7 @@ export const TicketGiftReceived9punto5 = ({ padding: "10px 20px", }} > - ACEPTAR REGALO + ACEPTAR ENTRADA @@ -115,18 +115,12 @@ export const TicketGiftReceived9punto5 = ({ Importante: Tienes hasta el{" "} {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} - para aceptar el regalo. + para aceptar la entrada. ¡Nos vemos en Valdivia! Equipo 9punto5 - - 9punto5 Illustration ); }; diff --git a/emails/templates/tickets/ticket-gift-sent/9punto5.tsx b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx index 78aedb44..2dda1c63 100644 --- a/emails/templates/tickets/ticket-gift-sent/9punto5.tsx +++ b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx @@ -29,8 +29,8 @@ export const TicketGiftSent9punto5 = ({ ¡Hola {senderName}! - Tu regalo de entrada para {recipientName} ({recipientEmail}) ha sido - enviado con éxito. Aquí están los detalles: + Tu entrada para {recipientName} ({recipientEmail}) ha sido enviada con + éxito. Aquí están los detalles: @@ -44,18 +44,19 @@ export const TicketGiftSent9punto5 = ({ )} - Hemos notificado a {recipientName} sobre este regalo y le hemos - proporcionado un enlace para confirmar su asistencia ingresando a{" "} - https://9punto5.cl + Hemos notificado a {recipientName} y le hemos proporcionado un enlace + para confirmar su asistencia ingresando a https://9punto5.cl {recipientName} tendrá hasta el{" "} {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} - para aceptar el regalo. + para aceptar la invitación. - ¡Gracias por tu generoso regalo! + + Si tienes alguna pregunta, no dudes en contactarnos. + Equipo 9punto5 diff --git a/workers/transactional_email_service/index.tsx b/workers/transactional_email_service/index.tsx index c6ac4b2e..d1c1d3f5 100644 --- a/workers/transactional_email_service/index.tsx +++ b/workers/transactional_email_service/index.tsx @@ -461,7 +461,7 @@ export default class EmailService extends WorkerEntrypoint { email: "tickets@updates.9punto5.cl", }, replyTo: "tickets@9punto5.cl", - subject: `Tienes un regalo de ${senderName} para ${ + subject: `Te han enviado una entrada para ${ ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" } 9.5`, }); @@ -511,9 +511,9 @@ export default class EmailService extends WorkerEntrypoint { name: "9punto5", email: "tickets@updates.9punto5.cl", }, - subject: `Tienes un regalo de ${senderName} para ${ + subject: `La entrada ${ ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" - } 9.5`, + } 9.5 para ${recipientName} ha sido enviada`, }); } @@ -554,7 +554,7 @@ export default class EmailService extends WorkerEntrypoint { name: "9punto5", email: "tickets@updates.9punto5.cl", }, - subject: `${recipientName} aceptó tu regalo para ${ + subject: `${recipientName} aceptó tu entrada ${ ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" } 9.5`, }); From 486398680b56bfca6478f0d4870d55dafe490362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 16:59:05 -0300 Subject: [PATCH 03/19] fix: email copy --- emails/templates/tickets/ticket-gift-received/9punto5.tsx | 6 ++++-- emails/templates/tickets/ticket-gift-sent/9punto5.tsx | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/emails/templates/tickets/ticket-gift-received/9punto5.tsx b/emails/templates/tickets/ticket-gift-received/9punto5.tsx index 660e526d..64a852b0 100644 --- a/emails/templates/tickets/ticket-gift-received/9punto5.tsx +++ b/emails/templates/tickets/ticket-gift-received/9punto5.tsx @@ -1,4 +1,4 @@ -import { Img, Text } from "@react-email/components"; +import { Text } from "@react-email/components"; import { format, setDefaultOptions } from "date-fns"; import { es } from "date-fns/locale"; import * as React from "react"; @@ -114,7 +114,9 @@ export const TicketGiftReceived9punto5 = ({ Importante: Tienes hasta el{" "} - {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} + + {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")} hs + {" "} para aceptar la entrada. diff --git a/emails/templates/tickets/ticket-gift-sent/9punto5.tsx b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx index 2dda1c63..a02763dc 100644 --- a/emails/templates/tickets/ticket-gift-sent/9punto5.tsx +++ b/emails/templates/tickets/ticket-gift-sent/9punto5.tsx @@ -50,7 +50,9 @@ export const TicketGiftSent9punto5 = ({ {recipientName} tendrá hasta el{" "} - {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")}{" "} + + {format(expirationDate, "dd 'de' MMMM 'a las' HH:mm")} hs + {" "} para aceptar la invitación. From bdbfb5d0c5dc44e6c3b34a252f72e94273f35581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 17:32:06 -0300 Subject: [PATCH 04/19] feat: add giftMyTicketToUser mutation, refactor code for better organization --- .../migrations/0033_milky_madame_masque.sql | 49 +++++++ drizzle/migrations/meta/0033_snapshot.json | 18 +-- drizzle/migrations/meta/_journal.json | 4 +- src/datasources/db/userTickets.ts | 10 +- src/datasources/helpers/index.ts | 4 - src/generated/schema.gql | 7 +- src/generated/types.ts | 12 +- src/schema/purchaseOrder/actions.tsx | 29 ++--- src/schema/userTickets/helpers.ts | 26 +++- src/schema/userTickets/mutations.ts | 122 +++++++++++++++--- src/schema/userTickets/queries.ts | 2 +- .../acceptGiftedTicket.test.ts | 27 ++-- .../claimUserTicket.generated.ts | 4 +- .../tests/claimUserTicket/claimUserTicket.gql | 2 +- .../claimUserTicket/claimUserTicket.test.ts | 2 +- src/schema/userTickets/types.ts | 4 +- src/tests/fixtures/index.ts | 2 +- src/tests/fixtures/mocks.ts | 5 +- workers/transactional_email_service/index.tsx | 92 +++++-------- 19 files changed, 272 insertions(+), 149 deletions(-) create mode 100644 drizzle/migrations/0033_milky_madame_masque.sql diff --git a/drizzle/migrations/0033_milky_madame_masque.sql b/drizzle/migrations/0033_milky_madame_masque.sql new file mode 100644 index 00000000..fa8d11a1 --- /dev/null +++ b/drizzle/migrations/0033_milky_madame_masque.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS "user_ticket_gifts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_ticket_id" uuid NOT NULL, + "gifter_user_id" uuid NOT NULL, + "recipient_user_id" uuid NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "gift_message" text, + "expiration_date" timestamp NOT NULL, + "is_return" boolean DEFAULT false NOT NULL, + "created_at" timestamp (6) DEFAULT now() NOT NULL, + "updated_at" timestamp (6), + "deleted_at" timestamp (6) +); +--> statement-breakpoint +ALTER TABLE "user_tickets" DROP CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk"; +--> statement-breakpoint +ALTER TABLE "user_tickets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_user_ticket_id_user_tickets_id_fk" FOREIGN KEY ("user_ticket_id") REFERENCES "public"."user_tickets"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_gifter_user_id_users_id_fk" FOREIGN KEY ("gifter_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_user_ticket_id_index" ON "user_ticket_gifts" USING btree ("user_ticket_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_gifter_user_id_index" ON "user_ticket_gifts" USING btree ("gifter_user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_gifts_recipient_user_id_index" ON "user_ticket_gifts" USING btree ("recipient_user_id");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_tickets" ADD CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk" FOREIGN KEY ("purchase_order_id") REFERENCES "public"."purchase_orders"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tickets_event_id_index" ON "tickets" USING btree ("event_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_ticket_template_id_index" ON "user_tickets" USING btree ("ticket_template_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_user_id_index" ON "user_tickets" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_approval_status_index" ON "user_tickets" USING btree ("approval_status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_tickets_purchase_order_id_index" ON "user_tickets" USING btree ("purchase_order_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0033_snapshot.json b/drizzle/migrations/meta/0033_snapshot.json index a6d370a4..10afe132 100644 --- a/drizzle/migrations/meta/0033_snapshot.json +++ b/drizzle/migrations/meta/0033_snapshot.json @@ -1,5 +1,5 @@ { - "id": "89c491ed-5ad0-49e4-beec-29bccbfa1610", + "id": "eac2fe9c-8c28-4ede-b95e-3a826d24f977", "prevId": "27492e6d-592d-42b1-9998-27c5736ead28", "version": "7", "dialect": "postgresql", @@ -2721,8 +2721,8 @@ "primaryKey": false, "notNull": true }, - "receiver_user_id": { - "name": "receiver_user_id", + "recipient_user_id": { + "name": "recipient_user_id", "type": "uuid", "primaryKey": false, "notNull": true @@ -2804,11 +2804,11 @@ "method": "btree", "with": {} }, - "user_ticket_gifts_receiver_user_id_index": { - "name": "user_ticket_gifts_receiver_user_id_index", + "user_ticket_gifts_recipient_user_id_index": { + "name": "user_ticket_gifts_recipient_user_id_index", "columns": [ { - "expression": "receiver_user_id", + "expression": "recipient_user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2839,11 +2839,11 @@ "onDelete": "no action", "onUpdate": "no action" }, - "user_ticket_gifts_receiver_user_id_users_id_fk": { - "name": "user_ticket_gifts_receiver_user_id_users_id_fk", + "user_ticket_gifts_recipient_user_id_users_id_fk": { + "name": "user_ticket_gifts_recipient_user_id_users_id_fk", "tableFrom": "user_ticket_gifts", "tableTo": "users", - "columnsFrom": ["receiver_user_id"], + "columnsFrom": ["recipient_user_id"], "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 9cc19103..c43f0e7c 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -236,8 +236,8 @@ { "idx": 33, "version": "7", - "when": 1728009672065, - "tag": "0033_careless_kang", + "when": 1728332843659, + "tag": "0033_milky_madame_masque", "breakpoints": true } ] diff --git a/src/datasources/db/userTickets.ts b/src/datasources/db/userTickets.ts index b6048150..99223c85 100644 --- a/src/datasources/db/userTickets.ts +++ b/src/datasources/db/userTickets.ts @@ -87,7 +87,7 @@ export const userTicketGiftsSchema = pgTable( gifterUserId: uuid("gifter_user_id") .references(() => usersSchema.id) .notNull(), - receiverUserId: uuid("receiver_user_id") + recipientUserId: uuid("recipient_user_id") .references(() => usersSchema.id) .notNull(), status: text("status", { @@ -113,8 +113,8 @@ export const userTicketGiftsSchema = pgTable( gifterUserIdIndex: index("user_ticket_gifts_gifter_user_id_index").on( table.gifterUserId, ), - receiverUserIdIndex: index("user_ticket_gifts_receiver_user_id_index").on( - table.receiverUserId, + recipientUserIdIndex: index("user_ticket_gifts_recipient_user_id_index").on( + table.recipientUserId, ), }), ); @@ -150,8 +150,8 @@ export const userTicketGiftsRelations = relations( fields: [userTicketGiftsSchema.gifterUserId], references: [usersSchema.id], }), - receiverUser: one(usersSchema, { - fields: [userTicketGiftsSchema.receiverUserId], + recipientUser: one(usersSchema, { + fields: [userTicketGiftsSchema.recipientUserId], references: [usersSchema.id], }), }), diff --git a/src/datasources/helpers/index.ts b/src/datasources/helpers/index.ts index a488d31f..35f6dbe2 100644 --- a/src/datasources/helpers/index.ts +++ b/src/datasources/helpers/index.ts @@ -1,9 +1,5 @@ const unMinutoEnMilisegundos = 60000; -export const someDaysIntoTheFuture = (days: number) => { - return new Date(Date.now() + days * 24 * 60 * 60 * 1000); -}; - export const someMinutesIntoTheFuture = (minutes: number) => { return new Date(Date.now() + minutes * unMinutoEnMilisegundos); }; diff --git a/src/generated/schema.gql b/src/generated/schema.gql index f5bc99f0..8dec9bb0 100644 --- a/src/generated/schema.gql +++ b/src/generated/schema.gql @@ -302,7 +302,7 @@ enum GiftAttemptStatus { input GiftInfoInput { email: String! - message: String! + message: String name: String! } @@ -431,6 +431,7 @@ type Mutation { Enqueue images to import """ enqueueGoogleAlbumImport(input: EnqueueGoogleAlbumImportInput!): Boolean! + giftMyTicketToUser(input: GiftInfoInput!, ticketId: String!): UserTicketGift! """ Gift tickets to users, allowing multiple tickets per user, and conditionally notify them @@ -696,7 +697,7 @@ type PurchaseOrder { } input PurchaseOrderInput { - giftInfo: [GiftInfoInput!]! + giftInfo: [GiftInfoInput!] quantity: Int! ticketId: String! } @@ -1280,7 +1281,7 @@ type UserTicketGift { expirationDate: DateTime! gifter: GiftTicketUserInfo! id: ID! - receiver: GiftTicketUserInfo! + recipient: GiftTicketUserInfo! status: GiftAttemptStatus! userTicket: UserTicket! } diff --git a/src/generated/types.ts b/src/generated/types.ts index f15e6d1d..a7f2224c 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -319,7 +319,7 @@ export enum GiftAttemptStatus { export type GiftInfoInput = { email: Scalars["String"]["input"]; - message: Scalars["String"]["input"]; + message?: InputMaybe; name: Scalars["String"]["input"]; }; @@ -392,6 +392,7 @@ export type Mutation = { editTicket: Ticket; /** Enqueue images to import */ enqueueGoogleAlbumImport: Scalars["Boolean"]["output"]; + giftMyTicketToUser: UserTicketGift; /** Gift tickets to users, allowing multiple tickets per user, and conditionally notify them */ giftTicketsToUsers: Array; /** Create a purchase order */ @@ -498,6 +499,11 @@ export type MutationEnqueueGoogleAlbumImportArgs = { input: EnqueueGoogleAlbumImportInput; }; +export type MutationGiftMyTicketToUserArgs = { + input: GiftInfoInput; + ticketId: Scalars["String"]["input"]; +}; + export type MutationGiftTicketsToUsersArgs = { input: GiftTicketsToUserInput; }; @@ -737,7 +743,7 @@ export type PurchaseOrder = { }; export type PurchaseOrderInput = { - giftInfo: Array; + giftInfo?: InputMaybe>; quantity: Scalars["Int"]["input"]; ticketId: Scalars["String"]["input"]; }; @@ -1295,7 +1301,7 @@ export type UserTicketGift = { expirationDate: Scalars["DateTime"]["output"]; gifter: GiftTicketUserInfo; id: Scalars["ID"]["output"]; - receiver: GiftTicketUserInfo; + recipient: GiftTicketUserInfo; status: GiftAttemptStatus; userTicket: UserTicket; }; diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index 4e611749..2d1fec6b 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -15,7 +15,6 @@ import { userTicketGiftsSchema, userTicketsSchema, } from "~/datasources/db/schema"; -import { someDaysIntoTheFuture } from "~/datasources/helpers"; import { createMercadoPagoPayment, getMercadoPagoPayment, @@ -29,6 +28,8 @@ import { Logger } from "~/logging"; import { ensureProductsAreCreated } from "~/schema/ticket/helpers"; import { Context } from "~/types"; +import { getExpirationDateForGift } from "../userTickets/helpers"; + const fetchPurchaseOrderInformation = async ( purchaseOrderId: string, DB: ORM_TYPE, @@ -635,7 +636,7 @@ export const syncPurchaseOrderPaymentStatus = async ({ giftMessage: true, }, with: { - receiverUser: { + recipientUser: { columns: { email: true, name: true, @@ -733,30 +734,18 @@ export const syncPurchaseOrderPaymentStatus = async ({ .where(eq(userTicketsSchema.id, userTicket.id)); } - const expirationDate = someDaysIntoTheFuture(7); + const expirationDate = getExpirationDateForGift(); for (const giftAttempt of userTicket.giftAttempts) { - await transactionalEmailService.sendTicketGiftReceived({ + await transactionalEmailService.sendGiftTicketConfirmations({ giftMessage: giftAttempt.giftMessage, - recipientEmail: giftAttempt.receiverUser.email, - recipientName: giftAttempt.receiverUser.name ?? "", + recipientEmail: giftAttempt.recipientUser.email, + recipientName: giftAttempt.recipientUser.name ?? "", senderName: purchaseOrder.user.name ?? "", - ticketType: userTicket.ticketTemplate.tags.includes("conference") - ? "CONFERENCE" - : "EXPERIENCE", + ticketTags: userTicket.ticketTemplate.tags, giftId: giftAttempt.id, expirationDate: expirationDate, - }); - - await transactionalEmailService.sendTicketGiftSent({ - giftMessage: giftAttempt.giftMessage, - recipientEmail: giftAttempt.receiverUser.email, - recipientName: giftAttempt.receiverUser.name ?? "", - senderName: purchaseOrder.user.name ?? "", - ticketType: userTicket.ticketTemplate.tags.includes("conference") - ? "CONFERENCE" - : "EXPERIENCE", - expirationDate: expirationDate, + senderEmail: purchaseOrder.user.email, }); } diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index 89fb1c2e..2778138e 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -1,3 +1,4 @@ +import { addDays, endOfDay } from "date-fns"; import { inArray } from "drizzle-orm"; import { ORM_TYPE } from "~/datasources/db"; @@ -289,7 +290,7 @@ const bulkApproveUserTickets = async ({ return updated; }; -export const getUsersFromPurchaseGiftsInfo = async ({ +export const getOrCreateGiftRecipients = async ({ DB, giftsInfo, }: { @@ -306,12 +307,17 @@ export const getUsersFromPurchaseGiftsInfo = async ({ users.email, giftsInfo.map((gi) => gi.email), ), - columns: { id: true, email: true, name: true }, + columns: { id: true, email: true, name: true, username: true }, }); const emailToUser = new Map< string, - { id: string; name: string | null; email: string } + { + id: string; + name: string | null; + email: string; + username: string; + } >( existingUsers.map((u) => [ u.email, @@ -319,6 +325,7 @@ export const getUsersFromPurchaseGiftsInfo = async ({ id: u.id, name: u.name, email: u.email, + username: u.username, }, ]), ); @@ -343,6 +350,7 @@ export const getUsersFromPurchaseGiftsInfo = async ({ id: usersSchema.id, email: usersSchema.email, name: usersSchema.name, + username: usersSchema.username, }); insertedUsers.forEach((user) => @@ -350,9 +358,21 @@ export const getUsersFromPurchaseGiftsInfo = async ({ id: user.id, name: user.name, email: user.email, + username: user.username, }), ); } return emailToUser; }; + +/** + * Returns the expiration date for a gift. + * The expiration date is at least 7 days from the current date with the end at 23:59:59 of the 7th day. + */ +export const getExpirationDateForGift = () => { + const minDays = 7; + const expirationDate = endOfDay(addDays(new Date(), minDays)); + + return expirationDate; +}; diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index cb325cbf..0387bd9a 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -18,10 +18,11 @@ import { } from "~/schema/purchaseOrder/helpers"; import { PurchaseOrderRef } from "~/schema/purchaseOrder/types"; import { isValidUUID } from "~/schema/shared/helpers"; -import { UserTicketRef } from "~/schema/shared/refs"; +import { UserTicketGiftRef, UserTicketRef } from "~/schema/shared/refs"; import { assertCanStartTicketClaimingForEvent, - getUsersFromPurchaseGiftsInfo, + getExpirationDateForGift, + getOrCreateGiftRecipients, validateUserDataAndApproveUserTickets, } from "~/schema/userTickets/helpers"; import { @@ -48,7 +49,7 @@ const GiftInfoInput = builder.inputType("GiftInfoInput", { required: true, }), message: t.string({ - required: true, + required: false, }), }), }); @@ -63,7 +64,7 @@ const PurchaseOrderInput = builder.inputType("PurchaseOrderInput", { }), giftInfo: t.field({ type: [GiftInfoInput], - required: true, + required: false, }), }), }); @@ -361,10 +362,16 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets[item.ticketId].quantity += item.quantity; - purchaseOrderByTickets[item.ticketId].giftInfo.push(...item.giftInfo); + purchaseOrderByTickets[item.ticketId].giftInfo.push( + ...(item.giftInfo as GiftInfoInput[] | []), + ); } for (const ticket of purchaseOrder) { + if (!ticket.giftInfo) { + continue; + } + if (ticket.giftInfo.length > ticket.quantity) { return { error: true as const, @@ -398,9 +405,9 @@ builder.mutationField("claimUserTicket", (t) => const ticketTemplatesIds = Object.keys(purchaseOrderByTickets); - const emailsToUsersData = await getUsersFromPurchaseGiftsInfo({ + const emailsToUsersData = await getOrCreateGiftRecipients({ DB: trx, - giftsInfo: purchaseOrder.flatMap((p) => p.giftInfo), + giftsInfo: purchaseOrder.flatMap((p) => p.giftInfo || []), }); const [createdPurchaseOrder, ticketTemplates] = await Promise.all([ @@ -462,7 +469,7 @@ builder.mutationField("claimUserTicket", (t) => for (let i = 0; i < quantityToPurchase; i++) { const isGift = i < giftInfoForTicket.length; const giftInfo = isGift ? giftInfoForTicket[i] : null; - const receiverUser = giftInfo + const recipientUser = giftInfo ? emailsToUsersData.get(giftInfo.email) : null; @@ -474,7 +481,7 @@ builder.mutationField("claimUserTicket", (t) => ); } - if (!receiverUser && giftInfo) { + if (!recipientUser && giftInfo) { throw applicationError( `User for email ${giftInfo.email} not found`, ServiceErrors.NOT_FOUND, @@ -525,13 +532,13 @@ builder.mutationField("claimUserTicket", (t) => giftInfo.forEach((giftInfo, index) => { const userTicket = userTickets[index]; - const receiverUser = emailsToUsersData.get(giftInfo.email); + const recipientUser = emailsToUsersData.get(giftInfo.email); - if (receiverUser) { + if (recipientUser) { giftAttempts.push({ userTicketId: userTicket.id, gifterUserId: USER.id, - receiverUserId: receiverUser.id, + recipientUserId: recipientUser.id, status: UserTicketGiftStatus.Pending, giftMessage: giftInfo.message || null, // Temporary, this will be updated @@ -685,6 +692,87 @@ builder.mutationField("claimUserTicket", (t) => }), ); +builder.mutationField("giftMyTicketToUser", (t) => + t.field({ + type: UserTicketGiftRef, + args: { + ticketId: t.arg.string({ required: true }), + input: t.arg({ type: GiftInfoInput, required: true }), + }, + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async ( + root, + { ticketId, input }, + { DB, USER, RPC_SERVICE_EMAIL }, + ) => { + if (!USER) { + throw new GraphQLError("User not found"); + } + + const { email, name, message } = input; + + const userTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq, and }) => + and(eq(t.id, ticketId), eq(t.userId, USER.id)), + with: { + ticketTemplate: { + columns: { + tags: true, + }, + }, + }, + }); + + if (!userTicket) { + throw new GraphQLError("Ticket not found"); + } + + const recipientUser = await getOrCreateGiftRecipients({ + DB: DB, + giftsInfo: [{ email, name }], + }).then((result) => { + if (!result) { + return null; + } + + return result.get(email); + }); + + if (!recipientUser) { + throw new GraphQLError("Receiver user not found"); + } + + const userTicketGift: InsertUserTicketGiftSchema = { + userTicketId: userTicket.id, + gifterUserId: USER.id, + recipientUserId: recipientUser.id, + status: UserTicketGiftStatus.Pending, + expirationDate: getExpirationDateForGift(), + giftMessage: message ?? null, + }; + + const createdUserTicketGift = await DB.insert(userTicketGiftsSchema) + .values(userTicketGift) + .returning(); + + await RPC_SERVICE_EMAIL.sendGiftTicketConfirmations({ + giftId: createdUserTicketGift[0].id, + giftMessage: userTicketGift.giftMessage ?? null, + expirationDate: userTicketGift.expirationDate, + recipientName: recipientUser.name ?? recipientUser.username, + recipientEmail: recipientUser.email, + senderName: USER.name ?? USER.username, + ticketTags: userTicket.ticketTemplate.tags, + senderEmail: USER.email, + }); + + return createdUserTicketGift[0]; + }, + }), +); + builder.mutationField("acceptGiftedTicket", (t) => t.field({ type: UserTicketRef, @@ -704,7 +792,7 @@ builder.mutationField("acceptGiftedTicket", (t) => // find the ticket gift const ticketGift = await DB.query.userTicketGiftsSchema.findFirst({ where: (t, { eq, and }) => - and(eq(t.id, giftId), eq(t.receiverUserId, USER.id)), + and(eq(t.id, giftId), eq(t.recipientUserId, USER.id)), columns: { id: true, status: true, @@ -765,16 +853,12 @@ builder.mutationField("acceptGiftedTicket", (t) => }) .where(eq(userTicketGiftsSchema.id, ticketGift.id)); - await RPC_SERVICE_EMAIL.sendTicketGiftAcceptedByReceiver({ + await RPC_SERVICE_EMAIL.sendGiftAcceptanceNotificationToGifter({ recipientName: USER.name ?? USER.username, recipientEmail: USER.email, senderName: ticketGift.gifterUser.name ?? ticketGift.gifterUser.username, - ticketType: ticketGift.userTicket.ticketTemplate.tags.includes( - "conference", - ) - ? "CONFERENCE" - : "EXPERIENCE", + ticketTags: ticketGift.userTicket.ticketTemplate.tags, }); return updatedTicket; diff --git a/src/schema/userTickets/queries.ts b/src/schema/userTickets/queries.ts index 65b27006..ecf0455b 100644 --- a/src/schema/userTickets/queries.ts +++ b/src/schema/userTickets/queries.ts @@ -120,7 +120,7 @@ builder.queryFields((t) => ({ } const results = await DB.query.userTicketGiftsSchema.findMany({ - where: (utg, { eq }) => eq(utg.receiverUserId, USER.id), + where: (utg, { eq }) => eq(utg.recipientUserId, USER.id), }); return results; diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts index c4c3d838..d10e7e1f 100644 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts +++ b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts @@ -4,7 +4,6 @@ import { UserTicketGiftStatus, userTicketsApprovalStatusEnum, } from "~/datasources/db/userTickets"; -import { someDaysIntoTheFuture } from "~/datasources/helpers"; import { executeGraphqlOperationAsUser, insertCommunity, @@ -22,6 +21,7 @@ import { AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables, } from "./acceptGiftedTicket.generated"; +import { getExpirationDateForGift } from "../../helpers"; const prepareTickets = async ( status: (typeof userTicketsApprovalStatusEnum)[number] = "gifted", @@ -34,7 +34,7 @@ const prepareTickets = async ( communityId: community1.id, }); const gifterUser = await insertUser(); - const receiverUser = await insertUser(); + const recipientUser = await insertUser(); const ticketTemplate1 = await insertTicketTemplate({ eventId: event1.id, }); @@ -48,21 +48,26 @@ const prepareTickets = async ( const ticketGift1 = await insertUserTicketGift({ userTicketId: ticket1.id, gifterUserId: gifterUser.id, - receiverUserId: receiverUser.id, + recipientUserId: recipientUser.id, status: status === "gifted" ? UserTicketGiftStatus.Pending : UserTicketGiftStatus.Accepted, - expirationDate: someDaysIntoTheFuture(1), + expirationDate: getExpirationDateForGift(), }); - return { ticket: ticket1, gifterUser, receiverUser, ticketGift: ticketGift1 }; + return { + ticket: ticket1, + gifterUser, + recipientUser, + ticketGift: ticketGift1, + }; }; describe("Accept user ticket gift", () => { describe("Should work", () => { it("If ticket is in a gifted status and user is ticket owner", async () => { - const { receiverUser, ticketGift } = await prepareTickets(); + const { recipientUser, ticketGift } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables @@ -73,7 +78,7 @@ describe("Accept user ticket gift", () => { giftId: ticketGift.id, }, }, - receiverUser, + recipientUser, ); assert.equal(response.errors, undefined); @@ -109,7 +114,7 @@ describe("Accept user ticket gift", () => { }); it("if ticket does not exist", async () => { - const { receiverUser } = await prepareTickets(); + const { recipientUser } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, AcceptGiftedTicketMutationVariables @@ -120,14 +125,14 @@ describe("Accept user ticket gift", () => { giftId: "non-existent-id", }, }, - receiverUser, + recipientUser, ); assert.equal(response.errors?.[0].message, "Unexpected error."); }); it("If tickets is not in a gifted state", async () => { - const { receiverUser, ticketGift } = + const { recipientUser, ticketGift } = await prepareTickets("gift_accepted"); const response = await executeGraphqlOperationAsUser< AcceptGiftedTicketMutation, @@ -139,7 +144,7 @@ describe("Accept user ticket gift", () => { giftId: ticketGift.id, }, }, - receiverUser, + recipientUser, ); assert.equal( diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts index b8f89384..797e946c 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts @@ -11,7 +11,7 @@ export type ClaimUserTicketMutationVariables = Types.Exact<{ }>; -export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus, giftAttempts: Array<{ __typename?: 'UserTicketGift', id: string, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, receiver: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null } }> }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; +export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus, giftAttempts: Array<{ __typename?: 'UserTicketGift', id: string, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, recipient: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null } }> }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; export const ClaimUserTicket = gql` @@ -31,7 +31,7 @@ export const ClaimUserTicket = gql` email name } - receiver { + recipient { email name } diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql index 917b51ef..b98e8b25 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql @@ -14,7 +14,7 @@ mutation ClaimUserTicket($input: TicketClaimInput!) { email name } - receiver { + recipient { email name } diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts index ddc062e1..02201722 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts @@ -492,7 +492,7 @@ describe("Claim a user ticket", () => { ); assert.equal( - response.data.claimUserTicket.tickets[0].giftAttempts[0].receiver + response.data.claimUserTicket.tickets[0].giftAttempts[0].recipient .email, giftRecipient.email, ); diff --git a/src/schema/userTickets/types.ts b/src/schema/userTickets/types.ts index f73c5dc9..0b373405 100644 --- a/src/schema/userTickets/types.ts +++ b/src/schema/userTickets/types.ts @@ -255,11 +255,11 @@ builder.objectType(UserTicketGiftRef, { }; }, }), - receiver: t.field({ + recipient: t.field({ type: GiftTicketUserInfo, resolve: async (root, args, { DB }) => { const user = await DB.query.usersSchema.findFirst({ - where: (u, { eq }) => eq(u.id, root.receiverUserId), + where: (u, { eq }) => eq(u.id, root.recipientUserId), }); if (!user) { diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index 2259b267..b6c046b5 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -826,7 +826,7 @@ export const insertUserTicketGift = async ( id: partialInput?.id ?? faker.string.uuid(), userTicketId: partialInput?.userTicketId, gifterUserId: partialInput?.gifterUserId, - receiverUserId: partialInput?.receiverUserId, + recipientUserId: partialInput?.recipientUserId, expirationDate: partialInput?.expirationDate, status: partialInput?.status, ...CRUDDates(partialInput), diff --git a/src/tests/fixtures/mocks.ts b/src/tests/fixtures/mocks.ts index 5371569a..afed4b60 100644 --- a/src/tests/fixtures/mocks.ts +++ b/src/tests/fixtures/mocks.ts @@ -23,7 +23,6 @@ export const MOCKED_RPC_SERVICE_EMAIL = { sendConfirmationWaitlistRejected: vitest.fn(), bulkSendEventTicketInvitations: vitest.fn(), bulkSendUserQRTicketEmail: vitest.fn(), - sendTicketGiftAcceptedByReceiver: vitest.fn(), - sendTicketGiftReceived: vitest.fn(), - sendTicketGiftSent: vitest.fn(), + sendGiftAcceptanceNotificationToGifter: vitest.fn(), + sendGiftTicketConfirmations: vitest.fn(), } satisfies MockedService; diff --git a/workers/transactional_email_service/index.tsx b/workers/transactional_email_service/index.tsx index d1c1d3f5..6e73a649 100644 --- a/workers/transactional_email_service/index.tsx +++ b/workers/transactional_email_service/index.tsx @@ -32,6 +32,10 @@ type ReceiverType = { const DEFAULT_CLOUDFLARE_LOGO_URL = "https://imagedelivery.net/dqFoxiedZNoncKJ9uqxz0g/6cdd148e-b931-4b7a-f983-d75d388aff00"; +const get9unto5TicketType = (ticketTags: string[]) => { + return ticketTags.includes("conferencia_95") ? "CONFERENCE" : "EXPERIENCE"; +}; + export default class EmailService extends WorkerEntrypoint { logger = createLogger("EmailService"); @@ -124,16 +128,14 @@ export default class EmailService extends WorkerEntrypoint { throw new Error(`No ticket template found for user ticket`); } + const ticketType = get9unto5TicketType(firstTicketTemplate.tags); + await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( , ), to: [ @@ -418,27 +420,34 @@ export default class EmailService extends WorkerEntrypoint { await sendTransactionalHTMLEmail(this.resend, this.logger, resendArgs); } - async sendTicketGiftReceived({ + async sendGiftTicketConfirmations({ giftId, recipientName, + recipientEmail, senderName, - ticketType, + senderEmail, + ticketTags, giftMessage, - recipientEmail, expirationDate, }: { giftId: string; recipientName: string; + recipientEmail: string; senderName: string; - ticketType: "CONFERENCE" | "EXPERIENCE"; + senderEmail: string; + ticketTags: string[]; giftMessage: string | null; - recipientEmail: string; expirationDate: Date; }) { - this.logger.info(`About to send TicketGiftReceived`, { + this.logger.info(`Sending gift ticket notifications`, { giftId, + recipientEmail, + senderEmail, }); + const ticketType = get9unto5TicketType(ticketTags); + + // Send email to gift recipient await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( { expirationDate={expirationDate} />, ), - to: [ - { - name: recipientName, - email: recipientEmail, - }, - ], - from: { - name: "9punto5", - email: "tickets@updates.9punto5.cl", - }, + to: [{ name: recipientName, email: recipientEmail }], + from: { name: "9punto5", email: "tickets@updates.9punto5.cl" }, replyTo: "tickets@9punto5.cl", subject: `Te han enviado una entrada para ${ ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" } 9.5`, }); - } - - async sendTicketGiftSent({ - recipientName, - recipientEmail, - senderName, - ticketType, - giftMessage, - expirationDate, - }: { - recipientName: string; - recipientEmail: string; - senderName: string; - ticketType: "CONFERENCE" | "EXPERIENCE"; - giftMessage: string | null; - expirationDate: Date; - }) { - this.logger.info(`About to send TicketGiftSent`, { - recipientName, - recipientEmail, - senderName, - ticketType, - giftMessage, - }); + // Send confirmation email to gift sender await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( { expirationDate={expirationDate} />, ), - to: [ - { - name: recipientName, - email: recipientEmail, - }, - ], - from: { - name: "9punto5", - email: "tickets@updates.9punto5.cl", - }, + to: [{ name: senderName, email: senderEmail }], + from: { name: "9punto5", email: "tickets@updates.9punto5.cl" }, subject: `La entrada ${ ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA" } 9.5 para ${recipientName} ha sido enviada`, }); + + this.logger.info(`Gift ticket notifications sent successfully`, { giftId }); } - async sendTicketGiftAcceptedByReceiver({ + async sendGiftAcceptanceNotificationToGifter({ recipientName, recipientEmail, senderName, - ticketType, + ticketTags, }: { recipientName: string; recipientEmail: string; senderName: string; - ticketType: "CONFERENCE" | "EXPERIENCE"; + ticketTags: string[]; }) { this.logger.info(`About to send TicketGiftAcceptedByReceiver`, { recipientName, recipientEmail, senderName, - ticketType, + ticketTags, }); + const ticketType = get9unto5TicketType(ticketTags); + await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( Date: Mon, 7 Oct 2024 17:34:02 -0300 Subject: [PATCH 05/19] fix: remove duplicated image in 9punto5 template --- .../templates/tickets/purchase-order-successful/9punto5.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/emails/templates/tickets/purchase-order-successful/9punto5.tsx b/emails/templates/tickets/purchase-order-successful/9punto5.tsx index 752d6094..631d2e94 100644 --- a/emails/templates/tickets/purchase-order-successful/9punto5.tsx +++ b/emails/templates/tickets/purchase-order-successful/9punto5.tsx @@ -96,12 +96,6 @@ export const PurchaseOrderSuccessful9punto5 = ({ Nos vemos en Valdivia, Equipo 9punto5 - - 9punto5 Illustration ); }; From 319cae3868f37b9604de5acfd1384dc3e803ba7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 17:40:25 -0300 Subject: [PATCH 06/19] fix: name or username on gift emails --- src/schema/purchaseOrder/actions.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index 2d1fec6b..e98a4baa 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -740,8 +740,10 @@ export const syncPurchaseOrderPaymentStatus = async ({ await transactionalEmailService.sendGiftTicketConfirmations({ giftMessage: giftAttempt.giftMessage, recipientEmail: giftAttempt.recipientUser.email, - recipientName: giftAttempt.recipientUser.name ?? "", - senderName: purchaseOrder.user.name ?? "", + recipientName: + giftAttempt.recipientUser.name ?? + giftAttempt.recipientUser.username, + senderName: purchaseOrder.user.name ?? purchaseOrder.user.username, ticketTags: userTicket.ticketTemplate.tags, giftId: giftAttempt.id, expirationDate: expirationDate, From f389aab048a0279cbfdb18f45592b1c7c06585ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 17:58:59 -0300 Subject: [PATCH 07/19] feat: clean emails --- src/authn/index.ts | 3 +- src/datasources/queries/users.ts | 3 +- src/schema/user/userHelpers.ts | 8 ++- src/schema/userTickets/helpers.ts | 101 +++++++++++----------------- src/schema/userTickets/mutations.ts | 26 ++++--- 5 files changed, 65 insertions(+), 76 deletions(-) diff --git a/src/authn/index.ts b/src/authn/index.ts index 58a8e6c5..e946e98c 100644 --- a/src/authn/index.ts +++ b/src/authn/index.ts @@ -10,6 +10,7 @@ import { import { getUsername } from "~/datasources/queries/utils/createUsername"; import { unauthorizedError } from "~/errors"; import { Logger } from "~/logging"; +import { cleanEmail } from "~/schema/user/userHelpers"; // Obtener el token de autorización de la solicitud, ya sea del encabezado de // autorización o de la cookie "community-os-access-token" @@ -125,7 +126,7 @@ export const upsertUserFromRequest = async ({ const { avatar_url, name, user_name, email_verified, sub, picture } = payload.user_metadata; const profileInfo = insertUsersSchema.safeParse({ - email: payload.email.toLowerCase(), + email: cleanEmail(payload.email), isEmailVerified: email_verified, imageUrl: avatar_url ? avatar_url : picture ? picture : "", externalId: sub, diff --git a/src/datasources/queries/users.ts b/src/datasources/queries/users.ts index 39a98a09..b9bffd04 100644 --- a/src/datasources/queries/users.ts +++ b/src/datasources/queries/users.ts @@ -10,6 +10,7 @@ import { } from "~/datasources/db/schema"; import { getUsername } from "~/datasources/queries/utils/createUsername"; import { Logger } from "~/logging"; +import { cleanEmail } from "~/schema/user/userHelpers"; export const findUserByID = async (db: ORM_TYPE, id: string) => { const result = await db.query.usersSchema.findFirst({ @@ -32,7 +33,7 @@ export const upsertUserProfileInfo = async ( const { email, imageUrl, isEmailVerified, publicMetadata } = parsedProfileInfo; - const lowercaseEmail = email.trim().toLowerCase(); + const lowercaseEmail = cleanEmail(email); const upsertData: z.infer = { imageUrl, diff --git a/src/schema/user/userHelpers.ts b/src/schema/user/userHelpers.ts index 0be9f38e..6f2a1076 100644 --- a/src/schema/user/userHelpers.ts +++ b/src/schema/user/userHelpers.ts @@ -10,6 +10,10 @@ import { import { Logger } from "~/logging"; import { usersFetcher } from "~/schema/user/userFetcher"; +export const cleanEmail = (email: string) => { + return email.trim().toLowerCase(); +}; + export const createUserIfNotExists = async ({ DB, logger, @@ -24,7 +28,7 @@ export const createUserIfNotExists = async ({ const result = await usersFetcher.searchUsers({ DB, search: { - email: email.trim().toLowerCase(), + email: cleanEmail(email), }, }); @@ -40,7 +44,7 @@ export const createUserIfNotExists = async ({ if (key === "id") { return; } else if (key === "email") { - cleanedUserFields[key] = email.trim().toLowerCase(); + cleanedUserFields[key] = cleanEmail(value as string); } else if (key && value) { cleanedUserFields[key] = value; } diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index 2778138e..00df7123 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -290,78 +290,57 @@ const bulkApproveUserTickets = async ({ return updated; }; -export const getOrCreateGiftRecipients = async ({ - DB, - giftsInfo, -}: { +type GetOrCreateGiftRecipientsOptions = { DB: ORM_TYPE; - giftsInfo: { - name: string; + giftRecipients: { email: string; + name: string; }[]; -}) => { - // Fetch existing users in a single query - const existingUsers = await DB.query.usersSchema.findMany({ - where: (users, { inArray }) => - inArray( - users.email, - giftsInfo.map((gi) => gi.email), - ), - columns: { id: true, email: true, name: true, username: true }, - }); +}; - const emailToUser = new Map< - string, - { - id: string; - name: string | null; - email: string; - username: string; - } - >( - existingUsers.map((u) => [ - u.email, - { - id: u.id, - name: u.name, - email: u.email, - username: u.username, - }, - ]), - ); +type GetOrCreateGiftRecipientsItem = { + id: string; + email: string; + name: string | null; + username: string; +}; - // Prepare new users to be inserted - const newUserInserts = giftsInfo - .filter((recipient) => !emailToUser.has(recipient.email)) - .map((recipient) => { - const next: InsertUserSchema = { +export const getOrCreateGiftRecipients = async ({ + DB, + giftRecipients, +}: GetOrCreateGiftRecipientsOptions): Promise< + Map +> => { + // Insert users that don't exist + // We use onConflictDoNothing to avoid errors + // if the user already exists or if is being created by another process + await DB.insert(usersSchema) + .values( + giftRecipients.map((recipient) => ({ email: recipient.email, name: recipient.name, username: getUsername(recipient.email), - }; - - return next; + })), + ) + .onConflictDoNothing() + .returning({ + id: usersSchema.id, + email: usersSchema.email, + name: usersSchema.name, + username: usersSchema.username, }); - if (newUserInserts.length > 0) { - const insertedUsers = await DB.insert(usersSchema) - .values(newUserInserts) - .returning({ - id: usersSchema.id, - email: usersSchema.email, - name: usersSchema.name, - username: usersSchema.username, - }); + const users = await DB.query.usersSchema.findMany({ + where: (t, { inArray }) => + inArray( + t.email, + giftRecipients.map((r) => r.email), + ), + }); - insertedUsers.forEach((user) => - emailToUser.set(user.email, { - id: user.id, - name: user.name, - email: user.email, - username: user.username, - }), - ); - } + const emailToUser = new Map( + users.map((user) => [user.email, user]), + ); return emailToUser; }; diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index 0387bd9a..c2b3ef87 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -33,6 +33,7 @@ import { import { RedeemUserTicketError } from "./types"; import { createPaymentIntent } from "../purchaseOrder/actions"; +import { cleanEmail } from "../user/userHelpers"; type GiftInfoInput = { email: string; @@ -363,16 +364,17 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets[item.ticketId].quantity += item.quantity; purchaseOrderByTickets[item.ticketId].giftInfo.push( - ...(item.giftInfo as GiftInfoInput[] | []), + ...(item.giftInfo as GiftInfoInput[] | []).map((gift) => ({ + ...gift, + email: cleanEmail(gift.email), + })), ); } for (const ticket of purchaseOrder) { - if (!ticket.giftInfo) { - continue; - } + const order = purchaseOrderByTickets[ticket.ticketId]; - if (ticket.giftInfo.length > ticket.quantity) { + if (order.giftInfo.length > ticket.quantity) { return { error: true as const, errorMessage: @@ -380,9 +382,8 @@ builder.mutationField("claimUserTicket", (t) => }; } - const isGiftingToSelf = ticket.giftInfo.some( - (gift) => - gift.email.toLowerCase().trim() === USER.email.toLowerCase().trim(), + const isGiftingToSelf = order.giftInfo.some( + (gift) => gift.email === USER.email, ); if (isGiftingToSelf) { @@ -407,7 +408,9 @@ builder.mutationField("claimUserTicket", (t) => const emailsToUsersData = await getOrCreateGiftRecipients({ DB: trx, - giftsInfo: purchaseOrder.flatMap((p) => p.giftInfo || []), + giftRecipients: purchaseOrder.flatMap((p) => { + return purchaseOrderByTickets[p.ticketId].giftInfo; + }), }); const [createdPurchaseOrder, ticketTemplates] = await Promise.all([ @@ -712,6 +715,7 @@ builder.mutationField("giftMyTicketToUser", (t) => } const { email, name, message } = input; + const cleanedEmail = cleanEmail(email); const userTicket = await DB.query.userTicketsSchema.findFirst({ where: (t, { eq, and }) => @@ -731,13 +735,13 @@ builder.mutationField("giftMyTicketToUser", (t) => const recipientUser = await getOrCreateGiftRecipients({ DB: DB, - giftsInfo: [{ email, name }], + giftRecipients: [{ email: cleanedEmail, name }], }).then((result) => { if (!result) { return null; } - return result.get(email); + return result.get(cleanedEmail); }); if (!recipientUser) { From 49cd842c48e35fff4c927808405c41e210ffabbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 18:06:14 -0300 Subject: [PATCH 08/19] feat: flexible "myTicketGifts" query with filters --- src/schema/userTickets/queries.ts | 46 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/schema/userTickets/queries.ts b/src/schema/userTickets/queries.ts index ecf0455b..b0c06b8a 100644 --- a/src/schema/userTickets/queries.ts +++ b/src/schema/userTickets/queries.ts @@ -22,6 +22,10 @@ import { TicketRedemptionStatus, } from "./types"; +const SearchTicketGiftTypeEnum = builder.enumType("TicketGiftType", { + values: ["SENT", "RECEIVED", "ALL"] as const, +}); + const MyTicketsSearchValues = builder.inputType("MyTicketsSearchValues", { fields: (t) => ({ eventId: t.field({ @@ -108,9 +112,16 @@ builder.queryFields((t) => ({ }; }, }), - myReceivedTicketGifts: t.field({ - description: "Get a list of user ticket gifts received by the current user", + myTicketGifts: t.field({ + description: + "Get a list of user ticket gifts sent or received by the current user", type: [UserTicketGiftRef], + args: { + type: t.arg({ + type: SearchTicketGiftTypeEnum, + defaultValue: "ALL", + }), + }, authz: { rules: ["IsAuthenticated"], }, @@ -120,25 +131,22 @@ builder.queryFields((t) => ({ } const results = await DB.query.userTicketGiftsSchema.findMany({ - where: (utg, { eq }) => eq(utg.recipientUserId, USER.id), - }); + where: (utg, { eq, or }) => { + if (args.type === "ALL") { + return or( + eq(utg.gifterUserId, USER.id), + eq(utg.recipientUserId, USER.id), + ); + } - return results; - }, - }), - mySentTicketGifts: t.field({ - description: "Get a list of user ticket gifts sent by the current user", - type: [UserTicketGiftRef], - authz: { - rules: ["IsAuthenticated"], - }, - resolve: async (root, args, { DB, USER }) => { - if (!USER) { - throw new Error("User not found"); - } + if (args.type === "SENT") { + return eq(utg.gifterUserId, USER.id); + } - const results = await DB.query.userTicketGiftsSchema.findMany({ - where: (utg, { eq }) => eq(utg.gifterUserId, USER.id), + if (args.type === "RECEIVED") { + return eq(utg.recipientUserId, USER.id); + } + }, }); return results; From 9f82ac2a08acef4d5a46f2da1332f8f3cbd2e0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 18:33:37 -0300 Subject: [PATCH 09/19] fix: if/else on syncPurchaseOrderPaymentStatus for tickets with vs without gifts --- src/schema/purchaseOrder/actions.tsx | 65 ++++++++++++++-------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index e98a4baa..acdc3010 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -718,14 +718,43 @@ export const syncPurchaseOrderPaymentStatus = async ({ if (poPaymentStatus === "paid") { for (const userTicket of purchaseOrder.userTickets) { - const hasGifts = userTicket.giftAttempts.length > 0; - - if (hasGifts) { + if (userTicket.giftAttempts.length > 0) { await DB.update(userTicketsSchema) .set({ approvalStatus: "gifted", }) .where(eq(userTicketsSchema.id, userTicket.id)); + + const expirationDate = getExpirationDateForGift(); + + for (const giftAttempt of userTicket.giftAttempts) { + await transactionalEmailService.sendGiftTicketConfirmations({ + giftMessage: giftAttempt.giftMessage, + recipientEmail: giftAttempt.recipientUser.email, + recipientName: + giftAttempt.recipientUser.name ?? + giftAttempt.recipientUser.username, + senderName: + purchaseOrder.user.name ?? purchaseOrder.user.username, + ticketTags: userTicket.ticketTemplate.tags, + giftId: giftAttempt.id, + expirationDate: expirationDate, + senderEmail: purchaseOrder.user.email, + }); + } + + const updateGiftValues: Partial = { + expirationDate: expirationDate, + }; + + await DB.update(userTicketGiftsSchema) + .set(updateGiftValues) + .where( + inArray( + userTicketGiftsSchema.id, + userTicket.giftAttempts.map((ga) => ga.id), + ), + ); } else { await DB.update(userTicketsSchema) .set({ @@ -733,36 +762,6 @@ export const syncPurchaseOrderPaymentStatus = async ({ }) .where(eq(userTicketsSchema.id, userTicket.id)); } - - const expirationDate = getExpirationDateForGift(); - - for (const giftAttempt of userTicket.giftAttempts) { - await transactionalEmailService.sendGiftTicketConfirmations({ - giftMessage: giftAttempt.giftMessage, - recipientEmail: giftAttempt.recipientUser.email, - recipientName: - giftAttempt.recipientUser.name ?? - giftAttempt.recipientUser.username, - senderName: purchaseOrder.user.name ?? purchaseOrder.user.username, - ticketTags: userTicket.ticketTemplate.tags, - giftId: giftAttempt.id, - expirationDate: expirationDate, - senderEmail: purchaseOrder.user.email, - }); - } - - const updateGiftValues: Partial = { - expirationDate: expirationDate, - }; - - await DB.update(userTicketGiftsSchema) - .set(updateGiftValues) - .where( - inArray( - userTicketGiftsSchema.id, - userTicket.giftAttempts.map((ga) => ga.id), - ), - ); } await sendPurchaseOrderSuccessfulEmail({ From 32ee0da2bee116aadbbbfbb3a7fe1f87eaf59213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 18:33:49 -0300 Subject: [PATCH 10/19] fix: orders without gifts --- src/schema/userTickets/helpers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index 00df7123..ea9fe3a9 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -311,6 +311,10 @@ export const getOrCreateGiftRecipients = async ({ }: GetOrCreateGiftRecipientsOptions): Promise< Map > => { + if (giftRecipients.length === 0) { + return new Map(); + } + // Insert users that don't exist // We use onConflictDoNothing to avoid errors // if the user already exists or if is being created by another process From 54fa75dd9e393d4a250a13f46e08f4e01832ac3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 18:34:23 -0300 Subject: [PATCH 11/19] feat: on fixtures insert users with clean emails --- src/tests/fixtures/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index b6c046b5..f67d1f77 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -105,6 +105,7 @@ import { } from "~/generated/types"; import { createLogger } from "~/logging"; import { schema } from "~/schema"; +import { cleanEmail } from "~/schema/user/userHelpers"; import { getTestDB } from "~/tests/fixtures/databaseHelper"; import { MOCKED_RPC_SERVICE_EMAIL } from "~/tests/fixtures/mocks"; import { Context } from "~/types"; @@ -261,7 +262,7 @@ export const insertUser = async ( externalId: partialInput?.externalId ?? faker.string.uuid(), username: partialInput?.username ?? faker.internet.userName(), bio: partialInput?.bio ?? faker.lorem.paragraph(), - email: partialInput?.email ?? faker.internet.email(), + email: cleanEmail(partialInput?.email ?? faker.internet.email()), name: partialInput?.name, isSuperAdmin: partialInput?.isSuperAdmin, isEmailVerified: partialInput?.isEmailVerified, From be6b98fd9bac898070d4241b98739be385d07a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 19:10:21 -0300 Subject: [PATCH 12/19] fix: nullable giftInfo --- src/schema/userTickets/mutations.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index c2b3ef87..79a401b9 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -364,10 +364,12 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets[item.ticketId].quantity += item.quantity; purchaseOrderByTickets[item.ticketId].giftInfo.push( - ...(item.giftInfo as GiftInfoInput[] | []).map((gift) => ({ - ...gift, - email: cleanEmail(gift.email), - })), + ...((item.giftInfo as GiftInfoInput[] | null | undefined) || []).map( + (gift) => ({ + ...gift, + email: cleanEmail(gift.email), + }), + ), ); } From 865ce765e6973ad518845f60be6a4fa7cb7b09ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 19:12:45 -0300 Subject: [PATCH 13/19] chore: remove commentary --- src/schema/userTickets/mutations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index 79a401b9..197b9023 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -449,7 +449,6 @@ builder.mutationField("claimUserTicket", (t) => } const claimedTickets: InsertUserTicketSchema[] = []; - // const giftAttempts: InsertUserTicketGiftSchema[] = []; // Process each ticket template for (const ticketTemplate of ticketTemplates) { From 3c13254c6ce3b1496725044ef4f7b71a648b494d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Mon, 7 Oct 2024 22:35:14 -0300 Subject: [PATCH 14/19] fix: add missing test for giftMyTicketToUser --- src/generated/schema.gql | 16 +- src/generated/types.ts | 17 +- src/schema/userTickets/mutations.ts | 35 +- .../giftMyTicketToUser.generated.ts | 39 ++ .../giftMyTicketToUser/giftMyTicketToUser.gql | 20 + .../giftMyTicketToUser.test.ts | 374 ++++++++++++++++++ src/schema/userTickets/types.ts | 9 +- 7 files changed, 493 insertions(+), 17 deletions(-) create mode 100644 src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts create mode 100644 src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql create mode 100644 src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts diff --git a/src/generated/schema.gql b/src/generated/schema.gql index 8dec9bb0..c66cf559 100644 --- a/src/generated/schema.gql +++ b/src/generated/schema.gql @@ -770,14 +770,9 @@ type Query { ): PaginatedPurchaseOrder! """ - Get a list of user ticket gifts received by the current user + Get a list of user ticket gifts sent or received by the current user """ - myReceivedTicketGifts: [UserTicketGift!]! - - """ - Get a list of user ticket gifts sent by the current user - """ - mySentTicketGifts: [UserTicketGift!]! + myTicketGifts(type: TicketGiftType = ALL): [UserTicketGift!]! """ Get a list of tickets for the current user @@ -1143,6 +1138,12 @@ input TicketEditInput { visibility: TicketTemplateVisibility } +enum TicketGiftType { + ALL + RECEIVED + SENT +} + enum TicketPaymentStatus { not_required paid @@ -1279,6 +1280,7 @@ Representation of a user ticket gift """ type UserTicketGift { expirationDate: DateTime! + giftMessage: String gifter: GiftTicketUserInfo! id: ID! recipient: GiftTicketUserInfo! diff --git a/src/generated/types.ts b/src/generated/types.ts index a7f2224c..3e24a78b 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -782,10 +782,8 @@ export type Query = { me: User; /** Get a list of purchase orders for the authenticated user */ myPurchaseOrders: PaginatedPurchaseOrder; - /** Get a list of user ticket gifts received by the current user */ - myReceivedTicketGifts: Array; - /** Get a list of user ticket gifts sent by the current user */ - mySentTicketGifts: Array; + /** Get a list of user ticket gifts sent or received by the current user */ + myTicketGifts: Array; /** Get a list of tickets for the current user */ myTickets: PaginatedUserTicket; /** Get public event attendance info */ @@ -858,6 +856,10 @@ export type QueryMyPurchaseOrdersArgs = { input: PaginatedInputMyPurchaseOrdersInput; }; +export type QueryMyTicketGiftsArgs = { + type?: InputMaybe; +}; + export type QueryMyTicketsArgs = { input: PaginatedInputMyTicketsSearchValues; }; @@ -1165,6 +1167,12 @@ export type TicketEditInput = { visibility?: InputMaybe; }; +export enum TicketGiftType { + All = "ALL", + Received = "RECEIVED", + Sent = "SENT", +} + export enum TicketPaymentStatus { NotRequired = "not_required", Paid = "paid", @@ -1299,6 +1307,7 @@ export type UserTicket = { export type UserTicketGift = { __typename?: "UserTicketGift"; expirationDate: Scalars["DateTime"]["output"]; + giftMessage?: Maybe; gifter: GiftTicketUserInfo; id: Scalars["ID"]["output"]; recipient: GiftTicketUserInfo; diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index 197b9023..39883a26 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -10,6 +10,7 @@ import { selectUserTicketsSchema, userTicketGiftsSchema, userTicketsSchema, + userTicketsApprovalStatusEnum, } from "~/datasources/db/schema"; import { applicationError, ServiceErrors } from "~/errors"; import { @@ -734,6 +735,13 @@ builder.mutationField("giftMyTicketToUser", (t) => throw new GraphQLError("Ticket not found"); } + const validApprovalStatus: (typeof userTicketsApprovalStatusEnum)[number][] = + ["approved", "not_required", "gift_accepted"]; + + if (!validApprovalStatus.includes(userTicket.approvalStatus)) { + throw new GraphQLError("Ticket is not giftable"); + } + const recipientUser = await getOrCreateGiftRecipients({ DB: DB, giftRecipients: [{ email: cleanedEmail, name }], @@ -758,12 +766,29 @@ builder.mutationField("giftMyTicketToUser", (t) => giftMessage: message ?? null, }; - const createdUserTicketGift = await DB.insert(userTicketGiftsSchema) - .values(userTicketGift) - .returning(); + const createdUserTicketGift = await DB.transaction(async (trx) => { + await trx + .update(userTicketsSchema) + .set({ + approvalStatus: "gifted", + userId: recipientUser.id, + }) + .where(eq(userTicketsSchema.id, userTicket.id)); + + const result = await trx + .insert(userTicketGiftsSchema) + .values(userTicketGift) + .returning(); + + return result[0]; + }); + + if (!createdUserTicketGift) { + throw new GraphQLError("Could not create user ticket gift"); + } await RPC_SERVICE_EMAIL.sendGiftTicketConfirmations({ - giftId: createdUserTicketGift[0].id, + giftId: createdUserTicketGift.id, giftMessage: userTicketGift.giftMessage ?? null, expirationDate: userTicketGift.expirationDate, recipientName: recipientUser.name ?? recipientUser.username, @@ -773,7 +798,7 @@ builder.mutationField("giftMyTicketToUser", (t) => senderEmail: USER.email, }); - return createdUserTicketGift[0]; + return createdUserTicketGift; }, }), ); diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts new file mode 100644 index 00000000..4052e003 --- /dev/null +++ b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts @@ -0,0 +1,39 @@ +/* eslint-disable */ +/* @ts-nocheck */ +/* prettier-ignore */ +/* This file is automatically generated using `npm run graphql:types` */ +import type * as Types from '../../../../generated/types'; + +import type { JsonObject } from "type-fest"; +import gql from 'graphql-tag'; +export type GiftMyTicketToUserMutationVariables = Types.Exact<{ + ticketId: Types.Scalars['String']['input']; + input: Types.GiftInfoInput; +}>; + + +export type GiftMyTicketToUserMutation = { __typename?: 'Mutation', giftMyTicketToUser: { __typename?: 'UserTicketGift', id: string, status: Types.GiftAttemptStatus, expirationDate: string, giftMessage: string | null, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, recipient: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, userTicket: { __typename?: 'UserTicket', id: string, approvalStatus: Types.TicketApprovalStatus } } }; + + +export const GiftMyTicketToUser = gql` + mutation GiftMyTicketToUser($ticketId: String!, $input: GiftInfoInput!) { + giftMyTicketToUser(ticketId: $ticketId, input: $input) { + id + status + expirationDate + giftMessage + gifter { + email + name + } + recipient { + email + name + } + userTicket { + id + approvalStatus + } + } +} + `; \ No newline at end of file diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql new file mode 100644 index 00000000..a15cb925 --- /dev/null +++ b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql @@ -0,0 +1,20 @@ +mutation GiftMyTicketToUser($ticketId: String!, $input: GiftInfoInput!) { + giftMyTicketToUser(ticketId: $ticketId, input: $input) { + id + status + expirationDate + giftMessage + gifter { + email + name + } + recipient { + email + name + } + userTicket { + id + approvalStatus + } + } +} diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts new file mode 100644 index 00000000..2ec4a631 --- /dev/null +++ b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts @@ -0,0 +1,374 @@ +import { addDays, differenceInSeconds, endOfDay } from "date-fns"; +import { AsyncReturnType } from "type-fest"; +import { v4 } from "uuid"; +import { assert, describe, it, expect } from "vitest"; + +import { + executeGraphqlOperationAsUser, + insertAllowedCurrency, + insertCommunity, + insertEvent, + insertEventToCommunity, + insertPrice, + insertTicketPrice, + insertTicketTemplate, + insertUser, + insertUserToCommunity, + insertTicket, +} from "~/tests/fixtures"; +import { getTestDB } from "~/tests/fixtures/databaseHelper"; + +import { + GiftMyTicketToUser, + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables, +} from "./giftMyTicketToUser.generated"; +import { getExpirationDateForGift } from "../../helpers"; + +const createTestSetup = async ({ + community, + event, + user, + ticketTemplate, + ticketPrice, + userTicket, +}: { + community?: AsyncReturnType; + event?: AsyncReturnType; + user?: AsyncReturnType; + ticketTemplate?: AsyncReturnType; + ticketPrice?: AsyncReturnType; + userTicket?: AsyncReturnType; +} = {}) => { + const createdCommunity = community ?? (await insertCommunity()); + const createdEvent = event ?? (await insertEvent({ status: "active" })); + + await insertEventToCommunity({ + eventId: createdEvent.id, + communityId: createdCommunity.id, + }); + + const createdUser = user ?? (await insertUser()); + + const createdTicketTemplate = + ticketTemplate ?? + (await insertTicketTemplate({ + eventId: createdEvent.id, + quantity: 100, + isFree: false, + isUnlimited: false, + })); + + const allowedCurrency = await insertAllowedCurrency({ + currency: "USD", + validPaymentMethods: "stripe", + }); + + const price = await insertPrice({ + price_in_cents: 100_00, + currencyId: allowedCurrency.id, + }); + + const createdTicketPrice = + ticketPrice ?? + (await insertTicketPrice({ + priceId: price.id, + ticketId: createdTicketTemplate.id, + })); + + const createdUserTicket = + userTicket ?? + (await insertTicket({ + userId: createdUser.id, + ticketTemplateId: createdTicketTemplate.id, + approvalStatus: "approved", + })); + + return { + community: createdCommunity, + event: createdEvent, + user: createdUser, + ticketTemplate: createdTicketTemplate, + ticketPrice: createdTicketPrice, + userTicket: createdUserTicket, + }; +}; + +describe("Gift My Ticket To User", () => { + describe("Successful gifting scenarios", () => { + it("Should successfully gift a ticket to an existing user", async () => { + const { user, userTicket } = await createTestSetup(); + const recipientUser = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: recipientUser.email, + name: "John Doe", + message: "Enjoy the event!", + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + + assert.equal(response.data?.giftMyTicketToUser.gifter.email, user.email); + + assert.equal( + response.data?.giftMyTicketToUser.recipient.email, + recipientUser.email, + ); + + // Verify database changes + const DB = await getTestDB(); + const updatedUserTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq }) => eq(t.id, userTicket.id), + }); + + assert.equal(updatedUserTicket?.approvalStatus, "gifted"); + + assert.equal(updatedUserTicket?.userId, recipientUser.id); + }); + + it("Should successfully gift a ticket to a non-existent user (creating a new user)", async () => { + const { user, userTicket } = await createTestSetup(); + const newEmail = "newuser@example.com"; + const newName = "New User"; + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: newEmail, + name: newName, + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + + assert.equal(response.data?.giftMyTicketToUser.gifter.email, user.email); + + assert.equal(response.data?.giftMyTicketToUser.recipient.email, newEmail); + + // Verify new user creation and database changes + const DB = await getTestDB(); + const newUser = await DB.query.usersSchema.findFirst({ + where: (u, { eq }) => eq(u.email, newEmail), + }); + + assert.notEqual(newUser, null); + + assert.equal(newUser?.name, newName); + + const updatedUserTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq }) => eq(t.id, userTicket.id), + }); + + assert.equal(updatedUserTicket?.approvalStatus, "gifted"); + + assert.equal(updatedUserTicket?.userId, newUser?.id); + }); + + it("Should handle gifting without a message", async () => { + const { user, userTicket } = await createTestSetup(); + const recipientUser = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: recipientUser.email, + name: "Jane Doe", + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + + assert.equal(response.data?.giftMyTicketToUser.giftMessage, null); + }); + }); + + describe("Error handling scenarios", () => { + it("Should throw an error when gifting a non-existent ticket", async () => { + const { user } = await createTestSetup(); + const recipientUser = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: "00000000-0000-4000-8000-000000000000", + input: { + email: recipientUser.email, + name: "John Doe", + }, + }, + }, + user, + ); + + assert.equal(response.errors?.[0].message, "Ticket not found"); + }); + + it("Should throw an error when gifting a ticket with invalid approval status", async () => { + const { user } = await createTestSetup(); + const recipientUser = await insertUser(); + const invalidTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: (await insertTicketTemplate()).id, + approvalStatus: "rejected", + }); + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: invalidTicket.id, + input: { + email: recipientUser.email, + name: "John Doe", + }, + }, + }, + user, + ); + + assert.equal(response.errors?.[0].message, "Ticket is not giftable"); + + // Verify that the ticket status hasn't changed + const DB = await getTestDB(); + const unchangedTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq }) => eq(t.id, invalidTicket.id), + }); + + assert.equal(unchangedTicket?.approvalStatus, "rejected"); + }); + }); + + describe("Edge cases", () => { + it("Should handle multiple gift attempts for the same ticket", async () => { + const { user, userTicket } = await createTestSetup(); + const recipientUser1 = await insertUser(); + const recipientUser2 = await insertUser(); + + const response1 = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: recipientUser1.email, + name: "Recipient 1", + }, + }, + }, + user, + ); + + assert.equal(response1.data?.giftMyTicketToUser.status, "Pending"); + + // Attempt to gift the same ticket again + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: recipientUser2.email, + name: "Recipient 2", + }, + }, + }, + user, + ); + + assert.equal(response.errors?.[0].message, "Ticket not found"); + + // Verify that the ticket is still associated with the first recipient + const DB = await getTestDB(); + const updatedUserTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq }) => eq(t.id, userTicket.id), + }); + + assert.equal(updatedUserTicket?.userId, recipientUser1.id); + }); + + it("Should verify the expiration date is set correctly", async () => { + const { user, userTicket } = await createTestSetup(); + const recipientUser = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + GiftMyTicketToUserMutation, + GiftMyTicketToUserMutationVariables + >( + { + document: GiftMyTicketToUser, + variables: { + ticketId: userTicket.id, + input: { + email: recipientUser.email, + name: "John Doe", + }, + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + const expectedExpirationDate = getExpirationDateForGift(); + const actualExpirationDate = new Date( + response.data?.giftMyTicketToUser.expirationDate ?? "", + ); + + const diffInSeconds = differenceInSeconds( + actualExpirationDate, + expectedExpirationDate, + ); + + assert.isBelow(diffInSeconds, 5); + }); + }); +}); diff --git a/src/schema/userTickets/types.ts b/src/schema/userTickets/types.ts index 0b373405..053da7e0 100644 --- a/src/schema/userTickets/types.ts +++ b/src/schema/userTickets/types.ts @@ -273,7 +273,14 @@ builder.objectType(UserTicketGiftRef, { }, }), status: t.expose("status", { type: GiftAttemptStatusEnum }), - expirationDate: t.expose("expirationDate", { type: "DateTime" }), + expirationDate: t.expose("expirationDate", { + type: "DateTime", + nullable: false, + }), + giftMessage: t.expose("giftMessage", { + type: "String", + nullable: true, + }), userTicket: t.field({ type: UserTicketRef, resolve: async (root, args, { DB }) => { From cc41ec180c12c79bf6dc9b73a21addec8afb9d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 11 Oct 2024 12:23:32 -0300 Subject: [PATCH 15/19] refactor(tickets): change user ticket gifts to transfers - Move userTicketGiftsSchema to separate file as userTicketTransfersSchema - Update schema, mutations, and queries to use "transfer" terminology - Consolidate and update migration files (0033) with new transfer schema - Adjust related code, tests, and email templates for transfer concept - Rename variables and update text to align with transfer terminology BREAKING CHANGE: APIs and database schema related to ticket gifting have been renamed and restructured to use "transfer" concept instead of "gift". --- drizzle/migrations/0033_careless_kang.sql | 49 --- ...me_masque.sql => 0033_thankful_orphan.sql} | 18 +- drizzle/migrations/meta/0033_snapshot.json | 310 ++++++++--------- drizzle/migrations/meta/_journal.json | 4 +- .../9punto5.tsx | 4 +- .../9punto5.tsx | 20 +- .../9punto5.tsx | 12 +- src/datasources/db/schema.ts | 2 + src/datasources/db/userTickets.ts | 96 +----- src/datasources/db/userTicketsTransfers.ts | 100 ++++++ src/generated/schema.gql | 80 ++--- src/generated/types.ts | 102 +++--- src/schema/index.ts | 3 + src/schema/purchaseOrder/actions.tsx | 43 +-- src/schema/shared/refs.ts | 6 +- src/schema/userTickets/helpers.ts | 70 ---- src/schema/userTickets/mutations.ts | 326 +++--------------- src/schema/userTickets/queries.ts | 50 +-- .../acceptGiftedTicket.generated.ts | 26 -- .../acceptGiftedTicket.gql | 8 - .../claimUserTicket.generated.ts | 6 +- .../tests/claimUserTicket/claimUserTicket.gql | 4 +- .../claimUserTicket/claimUserTicket.test.ts | 56 +-- .../giftMyTicketToUser.generated.ts | 39 --- .../giftMyTicketToUser/giftMyTicketToUser.gql | 20 -- src/schema/userTickets/types.ts | 96 +----- src/schema/userTicketsTransfers/helpers.ts | 75 ++++ src/schema/userTicketsTransfers/mutations.ts | 236 +++++++++++++ src/schema/userTicketsTransfers/queries.ts | 49 +++ .../acceptTransferredTicket.generated.ts | 26 ++ .../acceptTransferredTicket.gql | 8 + .../acceptTransferredTicket.test.ts} | 87 +++-- .../transferMyTicketToUser.generated.ts | 39 +++ .../transferMyTicketToUser.gql | 23 ++ .../transferMyTicketToUser.test.ts} | 115 +++--- src/schema/userTicketsTransfers/types.ts | 91 +++++ src/tests/fixtures/index.ts | 20 +- src/tests/fixtures/mocks.ts | 4 +- workers/transactional_email_service/index.tsx | 44 +-- 39 files changed, 1189 insertions(+), 1178 deletions(-) delete mode 100644 drizzle/migrations/0033_careless_kang.sql rename drizzle/migrations/{0033_milky_madame_masque.sql => 0033_thankful_orphan.sql} (60%) rename emails/templates/tickets/{ticket-gift-accepted-by-receiver => ticket-transfer-accepted-by-receiver}/9punto5.tsx (91%) rename emails/templates/tickets/{ticket-gift-received => ticket-transfer-received}/9punto5.tsx (89%) rename emails/templates/tickets/{ticket-gift-sent => ticket-transfer-sent}/9punto5.tsx (87%) create mode 100644 src/datasources/db/userTicketsTransfers.ts delete mode 100644 src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts delete mode 100644 src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql delete mode 100644 src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts delete mode 100644 src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql create mode 100644 src/schema/userTicketsTransfers/helpers.ts create mode 100644 src/schema/userTicketsTransfers/mutations.ts create mode 100644 src/schema/userTicketsTransfers/queries.ts create mode 100644 src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.generated.ts create mode 100644 src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.gql rename src/schema/{userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts => userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts} (53%) create mode 100644 src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.generated.ts create mode 100644 src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.gql rename src/schema/{userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts => userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts} (72%) create mode 100644 src/schema/userTicketsTransfers/types.ts diff --git a/drizzle/migrations/0033_careless_kang.sql b/drizzle/migrations/0033_careless_kang.sql deleted file mode 100644 index 9615008a..00000000 --- a/drizzle/migrations/0033_careless_kang.sql +++ /dev/null @@ -1,49 +0,0 @@ -CREATE TABLE IF NOT EXISTS "user_ticket_gifts" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_ticket_id" uuid NOT NULL, - "gifter_user_id" uuid NOT NULL, - "receiver_user_id" uuid NOT NULL, - "status" text DEFAULT 'pending' NOT NULL, - "gift_message" text, - "expiration_date" timestamp NOT NULL, - "is_return" boolean DEFAULT false NOT NULL, - "created_at" timestamp (6) DEFAULT now() NOT NULL, - "updated_at" timestamp (6), - "deleted_at" timestamp (6) -); ---> statement-breakpoint -ALTER TABLE "user_tickets" DROP CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk"; ---> statement-breakpoint -ALTER TABLE "user_tickets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_user_ticket_id_user_tickets_id_fk" FOREIGN KEY ("user_ticket_id") REFERENCES "public"."user_tickets"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_gifter_user_id_users_id_fk" FOREIGN KEY ("gifter_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_receiver_user_id_users_id_fk" FOREIGN KEY ("receiver_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_user_ticket_id_index" ON "user_ticket_gifts" USING btree ("user_ticket_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_gifter_user_id_index" ON "user_ticket_gifts" USING btree ("gifter_user_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_receiver_user_id_index" ON "user_ticket_gifts" USING btree ("receiver_user_id");--> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "user_tickets" ADD CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk" FOREIGN KEY ("purchase_order_id") REFERENCES "public"."purchase_orders"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "tickets_event_id_index" ON "tickets" USING btree ("event_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_tickets_ticket_template_id_index" ON "user_tickets" USING btree ("ticket_template_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_tickets_user_id_index" ON "user_tickets" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_tickets_approval_status_index" ON "user_tickets" USING btree ("approval_status");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_tickets_purchase_order_id_index" ON "user_tickets" USING btree ("purchase_order_id"); \ No newline at end of file diff --git a/drizzle/migrations/0033_milky_madame_masque.sql b/drizzle/migrations/0033_thankful_orphan.sql similarity index 60% rename from drizzle/migrations/0033_milky_madame_masque.sql rename to drizzle/migrations/0033_thankful_orphan.sql index fa8d11a1..810be7ed 100644 --- a/drizzle/migrations/0033_milky_madame_masque.sql +++ b/drizzle/migrations/0033_thankful_orphan.sql @@ -1,10 +1,10 @@ -CREATE TABLE IF NOT EXISTS "user_ticket_gifts" ( +CREATE TABLE IF NOT EXISTS "user_ticket_transfers" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_ticket_id" uuid NOT NULL, - "gifter_user_id" uuid NOT NULL, + "sender_user_id" uuid NOT NULL, "recipient_user_id" uuid NOT NULL, "status" text DEFAULT 'pending' NOT NULL, - "gift_message" text, + "transfer_message" text, "expiration_date" timestamp NOT NULL, "is_return" boolean DEFAULT false NOT NULL, "created_at" timestamp (6) DEFAULT now() NOT NULL, @@ -16,26 +16,26 @@ ALTER TABLE "user_tickets" DROP CONSTRAINT "user_tickets_purchase_order_id_purch --> statement-breakpoint ALTER TABLE "user_tickets" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_user_ticket_id_user_tickets_id_fk" FOREIGN KEY ("user_ticket_id") REFERENCES "public"."user_tickets"("id") ON DELETE no action ON UPDATE no action; + ALTER TABLE "user_ticket_transfers" ADD CONSTRAINT "user_ticket_transfers_user_ticket_id_user_tickets_id_fk" FOREIGN KEY ("user_ticket_id") REFERENCES "public"."user_tickets"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_gifter_user_id_users_id_fk" FOREIGN KEY ("gifter_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; + ALTER TABLE "user_ticket_transfers" ADD CONSTRAINT "user_ticket_transfers_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "user_ticket_gifts" ADD CONSTRAINT "user_ticket_gifts_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; + ALTER TABLE "user_ticket_transfers" ADD CONSTRAINT "user_ticket_transfers_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_user_ticket_id_index" ON "user_ticket_gifts" USING btree ("user_ticket_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_gifter_user_id_index" ON "user_ticket_gifts" USING btree ("gifter_user_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "user_ticket_gifts_recipient_user_id_index" ON "user_ticket_gifts" USING btree ("recipient_user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_transfers_user_ticket_id_index" ON "user_ticket_transfers" USING btree ("user_ticket_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_transfers_sender_user_id_index" ON "user_ticket_transfers" USING btree ("sender_user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_ticket_transfers_recipient_user_id_index" ON "user_ticket_transfers" USING btree ("recipient_user_id");--> statement-breakpoint DO $$ BEGIN ALTER TABLE "user_tickets" ADD CONSTRAINT "user_tickets_purchase_order_id_purchase_orders_id_fk" FOREIGN KEY ("purchase_order_id") REFERENCES "public"."purchase_orders"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION diff --git a/drizzle/migrations/meta/0033_snapshot.json b/drizzle/migrations/meta/0033_snapshot.json index 10afe132..39704c03 100644 --- a/drizzle/migrations/meta/0033_snapshot.json +++ b/drizzle/migrations/meta/0033_snapshot.json @@ -1,5 +1,5 @@ { - "id": "eac2fe9c-8c28-4ede-b95e-3a826d24f977", + "id": "367c89dd-b074-4fda-9aae-f94987912aa4", "prevId": "27492e6d-592d-42b1-9998-27c5736ead28", "version": "7", "dialect": "postgresql", @@ -2698,160 +2698,6 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.user_ticket_gifts": { - "name": "user_ticket_gifts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_ticket_id": { - "name": "user_ticket_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "gifter_user_id": { - "name": "gifter_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_user_id": { - "name": "recipient_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "gift_message": { - "name": "gift_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expiration_date": { - "name": "expiration_date", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "is_return": { - "name": "is_return", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6)", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6)", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp (6)", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "user_ticket_gifts_user_ticket_id_index": { - "name": "user_ticket_gifts_user_ticket_id_index", - "columns": [ - { - "expression": "user_ticket_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "user_ticket_gifts_gifter_user_id_index": { - "name": "user_ticket_gifts_gifter_user_id_index", - "columns": [ - { - "expression": "gifter_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "user_ticket_gifts_recipient_user_id_index": { - "name": "user_ticket_gifts_recipient_user_id_index", - "columns": [ - { - "expression": "recipient_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "user_ticket_gifts_user_ticket_id_user_tickets_id_fk": { - "name": "user_ticket_gifts_user_ticket_id_user_tickets_id_fk", - "tableFrom": "user_ticket_gifts", - "tableTo": "user_tickets", - "columnsFrom": ["user_ticket_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "user_ticket_gifts_gifter_user_id_users_id_fk": { - "name": "user_ticket_gifts_gifter_user_id_users_id_fk", - "tableFrom": "user_ticket_gifts", - "tableTo": "users", - "columnsFrom": ["gifter_user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "user_ticket_gifts_recipient_user_id_users_id_fk": { - "name": "user_ticket_gifts_recipient_user_id_users_id_fk", - "tableFrom": "user_ticket_gifts", - "tableTo": "users", - "columnsFrom": ["recipient_user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, "public.user_tickets": { "name": "user_tickets", "schema": "", @@ -3132,6 +2978,160 @@ } } }, + "public.user_ticket_transfers": { + "name": "user_ticket_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_ticket_id": { + "name": "user_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "transfer_message": { + "name": "transfer_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_return": { + "name": "is_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_ticket_transfers_user_ticket_id_index": { + "name": "user_ticket_transfers_user_ticket_id_index", + "columns": [ + { + "expression": "user_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_ticket_transfers_sender_user_id_index": { + "name": "user_ticket_transfers_sender_user_id_index", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_ticket_transfers_recipient_user_id_index": { + "name": "user_ticket_transfers_recipient_user_id_index", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_ticket_transfers_user_ticket_id_user_tickets_id_fk": { + "name": "user_ticket_transfers_user_ticket_id_user_tickets_id_fk", + "tableFrom": "user_ticket_transfers", + "tableTo": "user_tickets", + "columnsFrom": ["user_ticket_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_ticket_transfers_sender_user_id_users_id_fk": { + "name": "user_ticket_transfers_sender_user_id_users_id_fk", + "tableFrom": "user_ticket_transfers", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_ticket_transfers_recipient_user_id_users_id_fk": { + "name": "user_ticket_transfers_recipient_user_id_users_id_fk", + "tableFrom": "user_ticket_transfers", + "tableTo": "users", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, "public.work_email": { "name": "work_email", "schema": "", diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index c43f0e7c..1ea42e39 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -236,8 +236,8 @@ { "idx": 33, "version": "7", - "when": 1728332843659, - "tag": "0033_milky_madame_masque", + "when": 1728659831602, + "tag": "0033_thankful_orphan", "breakpoints": true } ] diff --git a/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx b/emails/templates/tickets/ticket-transfer-accepted-by-receiver/9punto5.tsx similarity index 91% rename from emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx rename to emails/templates/tickets/ticket-transfer-accepted-by-receiver/9punto5.tsx index 9d4d6555..f59806bc 100644 --- a/emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5.tsx +++ b/emails/templates/tickets/ticket-transfer-accepted-by-receiver/9punto5.tsx @@ -10,7 +10,7 @@ type Props = { ticketType: "CONFERENCE" | "EXPERIENCE"; }; -export const TicketGiftAcceptedByReceiver9punto5 = ({ +export const TicketTransferAcceptedByReceiver9punto5 = ({ recipientName = "Juan", recipientEmail = "juan@example.com", senderName = "Pedro", @@ -46,4 +46,4 @@ export const TicketGiftAcceptedByReceiver9punto5 = ({ ); }; -export default TicketGiftAcceptedByReceiver9punto5; +export default TicketTransferAcceptedByReceiver9punto5; diff --git a/emails/templates/tickets/ticket-gift-received/9punto5.tsx b/emails/templates/tickets/ticket-transfer-received/9punto5.tsx similarity index 89% rename from emails/templates/tickets/ticket-gift-received/9punto5.tsx rename to emails/templates/tickets/ticket-transfer-received/9punto5.tsx index 64a852b0..f171808b 100644 --- a/emails/templates/tickets/ticket-gift-received/9punto5.tsx +++ b/emails/templates/tickets/ticket-transfer-received/9punto5.tsx @@ -8,20 +8,20 @@ import { TicketTemplate9punto5 } from "emails/templates/helpers/9punto5"; setDefaultOptions({ locale: es }); type Props = { - giftId: string; + transferId: string; recipientName: string; senderName: string; ticketType: "CONFERENCE" | "EXPERIENCE"; - giftMessage?: string | null; + transferMessage?: string | null; expirationDate: Date; }; -export const TicketGiftReceived9punto5 = ({ - giftId, +export const TicketTransferReceived9punto5 = ({ + transferId, recipientName = "Juan", senderName = "Pedro", ticketType = "CONFERENCE", - giftMessage = "Mensaje de regalo", + transferMessage = "Mensaje de regalo", expirationDate, }: Props) => { return ( @@ -37,8 +37,8 @@ export const TicketGiftReceived9punto5 = ({ {ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 - {giftMessage && ( - "{giftMessage}" + {transferMessage && ( + "{transferMessage}" )} {ticketType === "EXPERIENCE" && ( @@ -89,8 +89,8 @@ export const TicketGiftReceived9punto5 = ({ { return ( @@ -37,9 +37,9 @@ export const TicketGiftSent9punto5 = ({ {ticketType === "CONFERENCE" ? "CONFERENCIA" : "EXPERIENCIA"} 9.5 - {giftMessage && ( + {transferMessage && ( - Tu mensaje de regalo: "{giftMessage}" + Tu mensaje de regalo: "{transferMessage}" )} @@ -65,4 +65,4 @@ export const TicketGiftSent9punto5 = ({ ); }; -export default TicketGiftSent9punto5; +export default TicketTransferSent9punto5; diff --git a/src/datasources/db/schema.ts b/src/datasources/db/schema.ts index e59c65c6..364e4579 100644 --- a/src/datasources/db/schema.ts +++ b/src/datasources/db/schema.ts @@ -58,6 +58,8 @@ export * from "~/datasources/db/userTickets"; export * from "~/datasources/db/userTicketsEmailLogs"; +export * from "~/datasources/db/userTicketsTransfers"; + export * from "~/datasources/db/workEmail"; export * from "~/datasources/db/workRole"; diff --git a/src/datasources/db/userTickets.ts b/src/datasources/db/userTickets.ts index 99223c85..312e0074 100644 --- a/src/datasources/db/userTickets.ts +++ b/src/datasources/db/userTickets.ts @@ -1,17 +1,11 @@ import { relations } from "drizzle-orm"; -import { - pgTable, - uuid, - text, - timestamp, - index, - boolean, -} from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, index } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; import { purchaseOrdersSchema, ticketsSchema, usersSchema } from "./schema"; import { createdAndUpdatedAtFields } from "./shared"; +import { userTicketTransfersSchema } from "./userTicketsTransfers"; export const userTicketsApprovalStatusEnum = [ "approved", @@ -25,14 +19,6 @@ export const userTicketsApprovalStatusEnum = [ export const userTicketsRedemptionStatusEnum = ["redeemed", "pending"] as const; -export enum UserTicketGiftStatus { - Pending = "pending", - Accepted = "accepted", - Rejected = "rejected", - Cancelled = "cancelled", - Expired = "expired", -} - // USER-TICKETS-TABLE export const userTicketsSchema = pgTable( "user_tickets", @@ -77,48 +63,6 @@ export const userTicketsSchema = pgTable( }), ); -export const userTicketGiftsSchema = pgTable( - "user_ticket_gifts", - { - id: uuid("id").primaryKey().notNull().defaultRandom(), - userTicketId: uuid("user_ticket_id") - .references(() => userTicketsSchema.id) - .notNull(), - gifterUserId: uuid("gifter_user_id") - .references(() => usersSchema.id) - .notNull(), - recipientUserId: uuid("recipient_user_id") - .references(() => usersSchema.id) - .notNull(), - status: text("status", { - enum: [ - UserTicketGiftStatus.Pending, - UserTicketGiftStatus.Accepted, - UserTicketGiftStatus.Rejected, - UserTicketGiftStatus.Cancelled, - UserTicketGiftStatus.Expired, - ], - }) - .default(UserTicketGiftStatus.Pending) - .notNull(), - giftMessage: text("gift_message"), - expirationDate: timestamp("expiration_date").notNull(), - isReturn: boolean("is_return").default(false).notNull(), - ...createdAndUpdatedAtFields, - }, - (table) => ({ - userTicketIdIndex: index("user_ticket_gifts_user_ticket_id_index").on( - table.userTicketId, - ), - gifterUserIdIndex: index("user_ticket_gifts_gifter_user_id_index").on( - table.gifterUserId, - ), - recipientUserIdIndex: index("user_ticket_gifts_recipient_user_id_index").on( - table.recipientUserId, - ), - }), -); - // Relations export const userTicketsRelations = relations( userTicketsSchema, @@ -135,25 +79,7 @@ export const userTicketsRelations = relations( fields: [userTicketsSchema.userId], references: [usersSchema.id], }), - giftAttempts: many(userTicketGiftsSchema), - }), -); - -export const userTicketGiftsRelations = relations( - userTicketGiftsSchema, - ({ one }) => ({ - userTicket: one(userTicketsSchema, { - fields: [userTicketGiftsSchema.userTicketId], - references: [userTicketsSchema.id], - }), - gifterUser: one(usersSchema, { - fields: [userTicketGiftsSchema.gifterUserId], - references: [usersSchema.id], - }), - recipientUser: one(usersSchema, { - fields: [userTicketGiftsSchema.recipientUserId], - references: [usersSchema.id], - }), + transferAttempts: many(userTicketTransfersSchema), }), ); @@ -170,19 +96,3 @@ export type SelectUserTicketSchema = z.infer; export type InsertUserTicketSchema = z.infer; export type ApproveUserTicketsSchema = z.infer; - -export const selectUserTicketGiftSchema = createSelectSchema( - userTicketGiftsSchema, -); - -export type SelectUserTicketGiftSchema = z.infer< - typeof selectUserTicketGiftSchema ->; - -export const insertUserTicketGiftSchema = createInsertSchema( - userTicketGiftsSchema, -); - -export type InsertUserTicketGiftSchema = z.infer< - typeof insertUserTicketGiftSchema ->; diff --git a/src/datasources/db/userTicketsTransfers.ts b/src/datasources/db/userTicketsTransfers.ts new file mode 100644 index 00000000..7744e922 --- /dev/null +++ b/src/datasources/db/userTicketsTransfers.ts @@ -0,0 +1,100 @@ +import { relations } from "drizzle-orm"; +import { + pgTable, + uuid, + text, + timestamp, + index, + boolean, +} from "drizzle-orm/pg-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; + +import { usersSchema, userTicketsSchema } from "./schema"; +import { createdAndUpdatedAtFields } from "./shared"; + +export enum UserTicketTransferStatus { + Pending = "pending", + Accepted = "accepted", + Rejected = "rejected", + Cancelled = "cancelled", + Expired = "expired", +} + +export const userTicketTransfersSchema = pgTable( + "user_ticket_transfers", + { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userTicketId: uuid("user_ticket_id") + .references(() => userTicketsSchema.id, { + onDelete: "cascade", + }) + .notNull(), + senderUserId: uuid("sender_user_id") + .references(() => usersSchema.id) + .notNull(), + recipientUserId: uuid("recipient_user_id") + .references(() => usersSchema.id) + .notNull(), + status: text("status", { + enum: [ + UserTicketTransferStatus.Pending, + UserTicketTransferStatus.Accepted, + UserTicketTransferStatus.Rejected, + UserTicketTransferStatus.Cancelled, + UserTicketTransferStatus.Expired, + ], + }) + .default(UserTicketTransferStatus.Pending) + .notNull(), + transferMessage: text("transfer_message"), + expirationDate: timestamp("expiration_date").notNull(), + isReturn: boolean("is_return").default(false).notNull(), + ...createdAndUpdatedAtFields, + }, + (table) => ({ + userTicketIdIndex: index("user_ticket_transfers_user_ticket_id_index").on( + table.userTicketId, + ), + senderUserIdIndex: index("user_ticket_transfers_sender_user_id_index").on( + table.senderUserId, + ), + recipientUserIdIndex: index( + "user_ticket_transfers_recipient_user_id_index", + ).on(table.recipientUserId), + }), +); + +export const userTicketTransfersRelations = relations( + userTicketTransfersSchema, + ({ one }) => ({ + userTicket: one(userTicketsSchema, { + fields: [userTicketTransfersSchema.userTicketId], + references: [userTicketsSchema.id], + }), + senderUser: one(usersSchema, { + fields: [userTicketTransfersSchema.senderUserId], + references: [usersSchema.id], + }), + recipientUser: one(usersSchema, { + fields: [userTicketTransfersSchema.recipientUserId], + references: [usersSchema.id], + }), + }), +); + +export const selectUserTicketTransferSchema = createSelectSchema( + userTicketTransfersSchema, +); + +export type SelectUserTicketTransferSchema = z.infer< + typeof selectUserTicketTransferSchema +>; + +export const insertUserTicketTransferSchema = createInsertSchema( + userTicketTransfersSchema, +); + +export type InsertUserTicketTransferSchema = z.infer< + typeof insertUserTicketTransferSchema +>; diff --git a/src/generated/schema.gql b/src/generated/schema.gql index c66cf559..7a5b7530 100644 --- a/src/generated/schema.gql +++ b/src/generated/schema.gql @@ -292,25 +292,6 @@ input GeneratePaymentLinkInput { currencyId: String! } -enum GiftAttemptStatus { - Accepted - Cancelled - Expired - Pending - Rejected -} - -input GiftInfoInput { - email: String! - message: String - name: String! -} - -type GiftTicketUserInfo { - email: String! - name: String -} - input GiftTicketsToUserInput { allowMultipleTicketsPerUsers: Boolean! autoApproveTickets: Boolean! @@ -335,12 +316,11 @@ enum ImageHostingEnum { } type Mutation { - acceptGiftedTicket(giftId: String!): UserTicket! - """ Accept the user's invitation to a team """ acceptTeamInvitation(input: AcceptTeamInvitationInput!): TeamRef! + acceptTransferredTicket(transferId: String!): UserTicket! """ Try to add a person to a team @@ -368,7 +348,7 @@ type Mutation { checkPurchaseOrderStatus(input: CheckForPurchaseOrderInput!): PurchaseOrder! """ - Attempt to claim and/or gift tickets + Attempt to claim and/or transfer tickets """ claimUserTicket(input: TicketClaimInput!): RedeemUserTicketResponse! @@ -431,7 +411,6 @@ type Mutation { Enqueue images to import """ enqueueGoogleAlbumImport(input: EnqueueGoogleAlbumImportInput!): Boolean! - giftMyTicketToUser(input: GiftInfoInput!, ticketId: String!): UserTicketGift! """ Gift tickets to users, allowing multiple tickets per user, and conditionally notify them @@ -457,6 +436,10 @@ type Mutation { Kickoff the email validation flow. This flow will links an email to a user, create a company if it does not exist, and allows filling data for that email's position """ startWorkEmailValidation(email: String!): WorkEmail! + transferMyTicketToUser( + input: UserTicketTransferInfoInput! + ticketId: String! + ): UserTicketTransfer! triggerUserTicketApprovalReview( eventId: String! userId: String! @@ -697,9 +680,9 @@ type PurchaseOrder { } input PurchaseOrderInput { - giftInfo: [GiftInfoInput!] quantity: Int! ticketId: String! + transfersInfo: [UserTicketTransferInfoInput!] } enum PurchaseOrderPaymentStatusEnum { @@ -770,9 +753,9 @@ type Query { ): PaginatedPurchaseOrder! """ - Get a list of user ticket gifts sent or received by the current user + Get a list of user ticket transfers sent or received by the current user """ - myTicketGifts(type: TicketGiftType = ALL): [UserTicketGift!]! + myTicketTransfers(type: TicketTransferType = ALL): [UserTicketTransfer!]! """ Get a list of tickets for the current user @@ -1138,12 +1121,6 @@ input TicketEditInput { visibility: TicketTemplateVisibility } -enum TicketGiftType { - ALL - RECEIVED - SENT -} - enum TicketPaymentStatus { not_required paid @@ -1166,6 +1143,25 @@ enum TicketTemplateVisibility { unlisted } +enum TicketTransferAttemptStatus { + Accepted + Cancelled + Expired + Pending + Rejected +} + +enum TicketTransferType { + ALL + RECEIVED + SENT +} + +type TicketTransferUserInfo { + email: String! + name: String +} + enum TypeOfEmployment { freelance fullTime @@ -1265,29 +1261,35 @@ Representation of a User ticket type UserTicket { approvalStatus: TicketApprovalStatus! createdAt: DateTime! - giftAttempts: [UserTicketGift!]! id: ID! paymentStatus: PurchaseOrderPaymentStatusEnum publicId: String! purchaseOrder: PurchaseOrder redemptionStatus: TicketRedemptionStatus! ticketTemplate: Ticket! + transferAttempts: [UserTicketTransfer!]! user: User } """ -Representation of a user ticket gift +Representation of a user ticket transfer """ -type UserTicketGift { +type UserTicketTransfer { expirationDate: DateTime! - giftMessage: String - gifter: GiftTicketUserInfo! id: ID! - recipient: GiftTicketUserInfo! - status: GiftAttemptStatus! + recipient: TicketTransferUserInfo! + sender: TicketTransferUserInfo! + status: TicketTransferAttemptStatus! + transferMessage: String userTicket: UserTicket! } +input UserTicketTransferInfoInput { + email: String! + message: String + name: String! +} + """ Representation of a user in a team """ diff --git a/src/generated/types.ts b/src/generated/types.ts index 3e24a78b..68d51b63 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -309,26 +309,6 @@ export type GeneratePaymentLinkInput = { currencyId: Scalars["String"]["input"]; }; -export enum GiftAttemptStatus { - Accepted = "Accepted", - Cancelled = "Cancelled", - Expired = "Expired", - Pending = "Pending", - Rejected = "Rejected", -} - -export type GiftInfoInput = { - email: Scalars["String"]["input"]; - message?: InputMaybe; - name: Scalars["String"]["input"]; -}; - -export type GiftTicketUserInfo = { - __typename?: "GiftTicketUserInfo"; - email: Scalars["String"]["output"]; - name?: Maybe; -}; - export type GiftTicketsToUserInput = { allowMultipleTicketsPerUsers: Scalars["Boolean"]["input"]; autoApproveTickets: Scalars["Boolean"]["input"]; @@ -353,9 +333,9 @@ export enum ImageHostingEnum { export type Mutation = { __typename?: "Mutation"; - acceptGiftedTicket: UserTicket; /** Accept the user's invitation to a team */ acceptTeamInvitation: TeamRef; + acceptTransferredTicket: UserTicket; /** Try to add a person to a team */ addPersonToTeam: AddUserToTeamResponseRef; /** Apply to a waitlist */ @@ -366,7 +346,7 @@ export type Mutation = { cancelUserTicket: UserTicket; /** Check the status of a purchase order */ checkPurchaseOrderStatus: PurchaseOrder; - /** Attempt to claim and/or gift tickets */ + /** Attempt to claim and/or transfer tickets */ claimUserTicket: RedeemUserTicketResponse; /** Create an community */ createCommunity: Community; @@ -392,7 +372,6 @@ export type Mutation = { editTicket: Ticket; /** Enqueue images to import */ enqueueGoogleAlbumImport: Scalars["Boolean"]["output"]; - giftMyTicketToUser: UserTicketGift; /** Gift tickets to users, allowing multiple tickets per user, and conditionally notify them */ giftTicketsToUsers: Array; /** Create a purchase order */ @@ -403,6 +382,7 @@ export type Mutation = { rejectTeamInvitation: TeamRef; /** Kickoff the email validation flow. This flow will links an email to a user, create a company if it does not exist, and allows filling data for that email's position */ startWorkEmailValidation: WorkEmail; + transferMyTicketToUser: UserTicketTransfer; triggerUserTicketApprovalReview: Array; /** Update a company */ updateCompany: Company; @@ -419,14 +399,14 @@ export type Mutation = { validateWorkEmail: WorkEmail; }; -export type MutationAcceptGiftedTicketArgs = { - giftId: Scalars["String"]["input"]; -}; - export type MutationAcceptTeamInvitationArgs = { input: AcceptTeamInvitationInput; }; +export type MutationAcceptTransferredTicketArgs = { + transferId: Scalars["String"]["input"]; +}; + export type MutationAddPersonToTeamArgs = { input: AddPersonToTeamInput; }; @@ -499,11 +479,6 @@ export type MutationEnqueueGoogleAlbumImportArgs = { input: EnqueueGoogleAlbumImportInput; }; -export type MutationGiftMyTicketToUserArgs = { - input: GiftInfoInput; - ticketId: Scalars["String"]["input"]; -}; - export type MutationGiftTicketsToUsersArgs = { input: GiftTicketsToUserInput; }; @@ -524,6 +499,11 @@ export type MutationStartWorkEmailValidationArgs = { email: Scalars["String"]["input"]; }; +export type MutationTransferMyTicketToUserArgs = { + input: UserTicketTransferInfoInput; + ticketId: Scalars["String"]["input"]; +}; + export type MutationTriggerUserTicketApprovalReviewArgs = { eventId: Scalars["String"]["input"]; userId: Scalars["String"]["input"]; @@ -743,9 +723,9 @@ export type PurchaseOrder = { }; export type PurchaseOrderInput = { - giftInfo?: InputMaybe>; quantity: Scalars["Int"]["input"]; ticketId: Scalars["String"]["input"]; + transfersInfo?: InputMaybe>; }; export enum PurchaseOrderPaymentStatusEnum { @@ -782,8 +762,8 @@ export type Query = { me: User; /** Get a list of purchase orders for the authenticated user */ myPurchaseOrders: PaginatedPurchaseOrder; - /** Get a list of user ticket gifts sent or received by the current user */ - myTicketGifts: Array; + /** Get a list of user ticket transfers sent or received by the current user */ + myTicketTransfers: Array; /** Get a list of tickets for the current user */ myTickets: PaginatedUserTicket; /** Get public event attendance info */ @@ -856,8 +836,8 @@ export type QueryMyPurchaseOrdersArgs = { input: PaginatedInputMyPurchaseOrdersInput; }; -export type QueryMyTicketGiftsArgs = { - type?: InputMaybe; +export type QueryMyTicketTransfersArgs = { + type?: InputMaybe; }; export type QueryMyTicketsArgs = { @@ -1167,12 +1147,6 @@ export type TicketEditInput = { visibility?: InputMaybe; }; -export enum TicketGiftType { - All = "ALL", - Received = "RECEIVED", - Sent = "SENT", -} - export enum TicketPaymentStatus { NotRequired = "not_required", Paid = "paid", @@ -1195,6 +1169,26 @@ export enum TicketTemplateVisibility { Unlisted = "unlisted", } +export enum TicketTransferAttemptStatus { + Accepted = "Accepted", + Cancelled = "Cancelled", + Expired = "Expired", + Pending = "Pending", + Rejected = "Rejected", +} + +export enum TicketTransferType { + All = "ALL", + Received = "RECEIVED", + Sent = "SENT", +} + +export type TicketTransferUserInfo = { + __typename?: "TicketTransferUserInfo"; + email: Scalars["String"]["output"]; + name?: Maybe; +}; + export enum TypeOfEmployment { Freelance = "freelance", FullTime = "fullTime", @@ -1293,28 +1287,34 @@ export type UserTicket = { __typename?: "UserTicket"; approvalStatus: TicketApprovalStatus; createdAt: Scalars["DateTime"]["output"]; - giftAttempts: Array; id: Scalars["ID"]["output"]; paymentStatus?: Maybe; publicId: Scalars["String"]["output"]; purchaseOrder?: Maybe; redemptionStatus: TicketRedemptionStatus; ticketTemplate: Ticket; + transferAttempts: Array; user?: Maybe; }; -/** Representation of a user ticket gift */ -export type UserTicketGift = { - __typename?: "UserTicketGift"; +/** Representation of a user ticket transfer */ +export type UserTicketTransfer = { + __typename?: "UserTicketTransfer"; expirationDate: Scalars["DateTime"]["output"]; - giftMessage?: Maybe; - gifter: GiftTicketUserInfo; id: Scalars["ID"]["output"]; - recipient: GiftTicketUserInfo; - status: GiftAttemptStatus; + recipient: TicketTransferUserInfo; + sender: TicketTransferUserInfo; + status: TicketTransferAttemptStatus; + transferMessage?: Maybe; userTicket: UserTicket; }; +export type UserTicketTransferInfoInput = { + email: Scalars["String"]["input"]; + message?: InputMaybe; + name: Scalars["String"]["input"]; +}; + /** Representation of a user in a team */ export type UserWithStatusRef = { __typename?: "UserWithStatusRef"; diff --git a/src/schema/index.ts b/src/schema/index.ts index 06a7889f..571eb515 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -51,6 +51,9 @@ import "./user/types"; import "./userTickets/mutations"; import "./userTickets/queries"; import "./userTickets/types"; +import "./userTicketsTransfers/mutations"; +import "./userTicketsTransfers/queries"; +import "./userTicketsTransfers/types"; import "./waitlist/mutations"; import "./waitlist/queries"; import "./waitlist/types"; diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index acdc3010..40ee9370 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -4,7 +4,7 @@ import { AsyncReturnType } from "type-fest"; import { ORM_TYPE } from "~/datasources/db"; import { - InsertUserTicketGiftSchema, + InsertUserTicketTransferSchema, USER, puchaseOrderPaymentStatusEnum, purchaseOrderStatusEnum, @@ -12,7 +12,7 @@ import { selectPurchaseOrdersSchema, selectTicketSchema, selectUserTicketsSchema, - userTicketGiftsSchema, + userTicketTransfersSchema, userTicketsSchema, } from "~/datasources/db/schema"; import { @@ -28,7 +28,7 @@ import { Logger } from "~/logging"; import { ensureProductsAreCreated } from "~/schema/ticket/helpers"; import { Context } from "~/types"; -import { getExpirationDateForGift } from "../userTickets/helpers"; +import { getExpirationDateForTicketTransfer } from "../userTicketsTransfers/helpers"; const fetchPurchaseOrderInformation = async ( purchaseOrderId: string, @@ -630,10 +630,10 @@ export const syncPurchaseOrderPaymentStatus = async ({ }, }, }, - giftAttempts: { + transferAttempts: { columns: { id: true, - giftMessage: true, + transferMessage: true, }, with: { recipientUser: { @@ -718,41 +718,42 @@ export const syncPurchaseOrderPaymentStatus = async ({ if (poPaymentStatus === "paid") { for (const userTicket of purchaseOrder.userTickets) { - if (userTicket.giftAttempts.length > 0) { + if (userTicket.transferAttempts.length > 0) { await DB.update(userTicketsSchema) .set({ approvalStatus: "gifted", }) .where(eq(userTicketsSchema.id, userTicket.id)); - const expirationDate = getExpirationDateForGift(); + const expirationDate = getExpirationDateForTicketTransfer(); - for (const giftAttempt of userTicket.giftAttempts) { - await transactionalEmailService.sendGiftTicketConfirmations({ - giftMessage: giftAttempt.giftMessage, - recipientEmail: giftAttempt.recipientUser.email, + for (const transferAttempt of userTicket.transferAttempts) { + await transactionalEmailService.sendTransferTicketConfirmations({ + transferMessage: transferAttempt.transferMessage, + recipientEmail: transferAttempt.recipientUser.email, recipientName: - giftAttempt.recipientUser.name ?? - giftAttempt.recipientUser.username, + transferAttempt.recipientUser.name ?? + transferAttempt.recipientUser.username, senderName: purchaseOrder.user.name ?? purchaseOrder.user.username, ticketTags: userTicket.ticketTemplate.tags, - giftId: giftAttempt.id, + transferId: transferAttempt.id, expirationDate: expirationDate, senderEmail: purchaseOrder.user.email, }); } - const updateGiftValues: Partial = { - expirationDate: expirationDate, - }; + const updateTransferValues: Partial = + { + expirationDate: expirationDate, + }; - await DB.update(userTicketGiftsSchema) - .set(updateGiftValues) + await DB.update(userTicketTransfersSchema) + .set(updateTransferValues) .where( inArray( - userTicketGiftsSchema.id, - userTicket.giftAttempts.map((ga) => ga.id), + userTicketTransfersSchema.id, + userTicket.transferAttempts.map((ga) => ga.id), ), ); } else { diff --git a/src/schema/shared/refs.ts b/src/schema/shared/refs.ts index bff54f33..c19ccd53 100644 --- a/src/schema/shared/refs.ts +++ b/src/schema/shared/refs.ts @@ -16,7 +16,7 @@ import { selectWorkSenioritySchema, selectPaymentLogsSchema, selectUserDataSchema, - SelectUserTicketGiftSchema, + SelectUserTicketTransferSchema, } from "~/datasources/db/schema"; import { SanityAsset, SanityEvent } from "~/datasources/sanity/types"; @@ -117,5 +117,5 @@ export const ConsolidatedPaymentLogEntryRef = builder.objectRef<{ currencyId: string; }>("ConsolidatedPaymentLogEntry"); -export const UserTicketGiftRef = - builder.objectRef("UserTicketGift"); +export const UserTicketTransferRef = + builder.objectRef("UserTicketTransfer"); diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index ea9fe3a9..6d49adde 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -289,73 +289,3 @@ const bulkApproveUserTickets = async ({ return updated; }; - -type GetOrCreateGiftRecipientsOptions = { - DB: ORM_TYPE; - giftRecipients: { - email: string; - name: string; - }[]; -}; - -type GetOrCreateGiftRecipientsItem = { - id: string; - email: string; - name: string | null; - username: string; -}; - -export const getOrCreateGiftRecipients = async ({ - DB, - giftRecipients, -}: GetOrCreateGiftRecipientsOptions): Promise< - Map -> => { - if (giftRecipients.length === 0) { - return new Map(); - } - - // Insert users that don't exist - // We use onConflictDoNothing to avoid errors - // if the user already exists or if is being created by another process - await DB.insert(usersSchema) - .values( - giftRecipients.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - username: getUsername(recipient.email), - })), - ) - .onConflictDoNothing() - .returning({ - id: usersSchema.id, - email: usersSchema.email, - name: usersSchema.name, - username: usersSchema.username, - }); - - const users = await DB.query.usersSchema.findMany({ - where: (t, { inArray }) => - inArray( - t.email, - giftRecipients.map((r) => r.email), - ), - }); - - const emailToUser = new Map( - users.map((user) => [user.email, user]), - ); - - return emailToUser; -}; - -/** - * Returns the expiration date for a gift. - * The expiration date is at least 7 days from the current date with the end at 23:59:59 of the 7th day. - */ -export const getExpirationDateForGift = () => { - const minDays = 7; - const expirationDate = endOfDay(addDays(new Date(), minDays)); - - return expirationDate; -}; diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index 39883a26..b236f5d6 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -4,13 +4,12 @@ import { GraphQLError } from "graphql"; import { builder } from "~/builder"; import { InsertUserTicketSchema, - UserTicketGiftStatus, - InsertUserTicketGiftSchema, + UserTicketTransferStatus, + InsertUserTicketTransferSchema, selectPurchaseOrdersSchema, selectUserTicketsSchema, - userTicketGiftsSchema, + userTicketTransfersSchema, userTicketsSchema, - userTicketsApprovalStatusEnum, } from "~/datasources/db/schema"; import { applicationError, ServiceErrors } from "~/errors"; import { @@ -19,11 +18,9 @@ import { } from "~/schema/purchaseOrder/helpers"; import { PurchaseOrderRef } from "~/schema/purchaseOrder/types"; import { isValidUUID } from "~/schema/shared/helpers"; -import { UserTicketGiftRef, UserTicketRef } from "~/schema/shared/refs"; +import { UserTicketRef } from "~/schema/shared/refs"; import { assertCanStartTicketClaimingForEvent, - getExpirationDateForGift, - getOrCreateGiftRecipients, validateUserDataAndApproveUserTickets, } from "~/schema/userTickets/helpers"; import { @@ -35,26 +32,8 @@ import { import { RedeemUserTicketError } from "./types"; import { createPaymentIntent } from "../purchaseOrder/actions"; import { cleanEmail } from "../user/userHelpers"; - -type GiftInfoInput = { - email: string; - name: string; - message: string | null; -}; - -const GiftInfoInput = builder.inputType("GiftInfoInput", { - fields: (t) => ({ - email: t.string({ - required: true, - }), - name: t.string({ - required: true, - }), - message: t.string({ - required: false, - }), - }), -}); +import { getOrCreateTransferRecipients } from "../userTicketsTransfers/helpers"; +import { UserTicketTransferInfoInputRef } from "../userTicketsTransfers/mutations"; const PurchaseOrderInput = builder.inputType("PurchaseOrderInput", { fields: (t) => ({ @@ -64,8 +43,8 @@ const PurchaseOrderInput = builder.inputType("PurchaseOrderInput", { quantity: t.int({ required: true, }), - giftInfo: t.field({ - type: [GiftInfoInput], + transfersInfo: t.field({ + type: [UserTicketTransferInfoInputRef], required: false, }), }), @@ -287,7 +266,7 @@ builder.mutationField("redeemUserTicket", (t) => builder.mutationField("claimUserTicket", (t) => t.field({ - description: "Attempt to claim and/or gift tickets", + description: "Attempt to claim and/or transfer tickets", type: RedeemUserTicketResponse, args: { input: t.arg({ type: TicketClaimInput, required: true }), @@ -333,7 +312,11 @@ builder.mutationField("claimUserTicket", (t) => { ticketId: string; quantity: number; - giftInfo: GiftInfoInput[]; + transfersInfo: { + email: string; + name: string; + message: string | null; + }[]; } > = {}; @@ -358,41 +341,40 @@ builder.mutationField("claimUserTicket", (t) => purchaseOrderByTickets[item.ticketId] = { ticketId: item.ticketId, quantity: 0, - giftInfo: [], + transfersInfo: [], }; } purchaseOrderByTickets[item.ticketId].quantity += item.quantity; - purchaseOrderByTickets[item.ticketId].giftInfo.push( - ...((item.giftInfo as GiftInfoInput[] | null | undefined) || []).map( - (gift) => ({ - ...gift, - email: cleanEmail(gift.email), - }), - ), + purchaseOrderByTickets[item.ticketId].transfersInfo.push( + ...(item.transfersInfo || []).map((transfer) => ({ + name: transfer.name, + email: cleanEmail(transfer.email), + message: transfer.message || null, + })), ); } for (const ticket of purchaseOrder) { const order = purchaseOrderByTickets[ticket.ticketId]; - if (order.giftInfo.length > ticket.quantity) { + if (order.transfersInfo.length > ticket.quantity) { return { error: true as const, errorMessage: - "No se puede regalar más tickets de los que se han comprado", + "No se puede transferir más tickets de los que se han comprado", }; } - const isGiftingToSelf = order.giftInfo.some( - (gift) => gift.email === USER.email, + const isTransferringToSelf = order.transfersInfo.some( + (transfer) => transfer.email === USER.email, ); - if (isGiftingToSelf) { + if (isTransferringToSelf) { return { error: true as const, - errorMessage: "Cannot gift to yourself", + errorMessage: "Cannot transfer to yourself", }; } } @@ -409,10 +391,10 @@ builder.mutationField("claimUserTicket", (t) => const ticketTemplatesIds = Object.keys(purchaseOrderByTickets); - const emailsToUsersData = await getOrCreateGiftRecipients({ + const emailsToUsersData = await getOrCreateTransferRecipients({ DB: trx, - giftRecipients: purchaseOrder.flatMap((p) => { - return purchaseOrderByTickets[p.ticketId].giftInfo; + transferRecipients: purchaseOrder.flatMap((p) => { + return purchaseOrderByTickets[p.ticketId].transfersInfo; }), }); @@ -456,8 +438,8 @@ builder.mutationField("claimUserTicket", (t) => const { event } = ticketTemplate; const quantityToPurchase = purchaseOrderByTickets[ticketTemplate.id].quantity; - const giftInfoForTicket = - purchaseOrderByTickets[ticketTemplate.id].giftInfo; + const ticketTransfers = + purchaseOrderByTickets[ticketTemplate.id].transfersInfo; // If the event is not active, we throw an error. if (event.status === "inactive") { @@ -472,23 +454,23 @@ builder.mutationField("claimUserTicket", (t) => ticketTemplate.isFree && !ticketTemplate.requiresApproval; for (let i = 0; i < quantityToPurchase; i++) { - const isGift = i < giftInfoForTicket.length; - const giftInfo = isGift ? giftInfoForTicket[i] : null; - const recipientUser = giftInfo - ? emailsToUsersData.get(giftInfo.email) + const isTransfer = i < ticketTransfers.length; + const transferInfo = isTransfer ? ticketTransfers[i] : null; + const recipientUser = transferInfo + ? emailsToUsersData.get(transferInfo.email) : null; - if (isGift && !giftInfo) { + if (isTransfer && !transferInfo) { throw applicationError( - `Gift info is required for ticket ${i + 1}`, + `Transfer info is required for ticket ${i + 1}`, ServiceErrors.INVALID_ARGUMENT, logger, ); } - if (!recipientUser && giftInfo) { + if (!recipientUser && transferInfo) { throw applicationError( - `User for email ${giftInfo.email} not found`, + `User for email ${transferInfo.email} not found`, ServiceErrors.NOT_FOUND, logger, ); @@ -527,25 +509,26 @@ builder.mutationField("claimUserTicket", (t) => {} as Record, ); - // Prepare gift attempts - const giftAttempts: InsertUserTicketGiftSchema[] = []; + // Prepare transfer attempts + const transfersAttempts: InsertUserTicketTransferSchema[] = []; for (const item of purchaseOrder) { const userTickets = ticketTemplateToUserTickets[item.ticketId] || []; - const giftInfo = purchaseOrderByTickets[item.ticketId].giftInfo; + const transfersInto = + purchaseOrderByTickets[item.ticketId].transfersInfo; - giftInfo.forEach((giftInfo, index) => { + transfersInto.forEach((transferInfo, index) => { const userTicket = userTickets[index]; - const recipientUser = emailsToUsersData.get(giftInfo.email); + const recipientUser = emailsToUsersData.get(transferInfo.email); if (recipientUser) { - giftAttempts.push({ + transfersAttempts.push({ userTicketId: userTicket.id, - gifterUserId: USER.id, + senderUserId: USER.id, recipientUserId: recipientUser.id, - status: UserTicketGiftStatus.Pending, - giftMessage: giftInfo.message || null, + status: UserTicketTransferStatus.Pending, + transferMessage: transferInfo.message || null, // Temporary, this will be updated // when the payment is done expirationDate: new Date(), @@ -553,7 +536,7 @@ builder.mutationField("claimUserTicket", (t) => }); } else { throw applicationError( - `User for email ${giftInfo.email} not found`, + `User for email ${transferInfo.email} not found`, ServiceErrors.INTERNAL_SERVER_ERROR, logger, ); @@ -561,9 +544,11 @@ builder.mutationField("claimUserTicket", (t) => }); } - // Insert gift attempts if any - if (giftAttempts.length > 0) { - await trx.insert(userTicketGiftsSchema).values(giftAttempts); + // Insert transfer attempts if any + if (transfersAttempts.length > 0) { + await trx + .insert(userTicketTransfersSchema) + .values(transfersAttempts); } // Bulk query for existing ticket counts @@ -697,205 +682,6 @@ builder.mutationField("claimUserTicket", (t) => }), ); -builder.mutationField("giftMyTicketToUser", (t) => - t.field({ - type: UserTicketGiftRef, - args: { - ticketId: t.arg.string({ required: true }), - input: t.arg({ type: GiftInfoInput, required: true }), - }, - authz: { - rules: ["IsAuthenticated"], - }, - resolve: async ( - root, - { ticketId, input }, - { DB, USER, RPC_SERVICE_EMAIL }, - ) => { - if (!USER) { - throw new GraphQLError("User not found"); - } - - const { email, name, message } = input; - const cleanedEmail = cleanEmail(email); - - const userTicket = await DB.query.userTicketsSchema.findFirst({ - where: (t, { eq, and }) => - and(eq(t.id, ticketId), eq(t.userId, USER.id)), - with: { - ticketTemplate: { - columns: { - tags: true, - }, - }, - }, - }); - - if (!userTicket) { - throw new GraphQLError("Ticket not found"); - } - - const validApprovalStatus: (typeof userTicketsApprovalStatusEnum)[number][] = - ["approved", "not_required", "gift_accepted"]; - - if (!validApprovalStatus.includes(userTicket.approvalStatus)) { - throw new GraphQLError("Ticket is not giftable"); - } - - const recipientUser = await getOrCreateGiftRecipients({ - DB: DB, - giftRecipients: [{ email: cleanedEmail, name }], - }).then((result) => { - if (!result) { - return null; - } - - return result.get(cleanedEmail); - }); - - if (!recipientUser) { - throw new GraphQLError("Receiver user not found"); - } - - const userTicketGift: InsertUserTicketGiftSchema = { - userTicketId: userTicket.id, - gifterUserId: USER.id, - recipientUserId: recipientUser.id, - status: UserTicketGiftStatus.Pending, - expirationDate: getExpirationDateForGift(), - giftMessage: message ?? null, - }; - - const createdUserTicketGift = await DB.transaction(async (trx) => { - await trx - .update(userTicketsSchema) - .set({ - approvalStatus: "gifted", - userId: recipientUser.id, - }) - .where(eq(userTicketsSchema.id, userTicket.id)); - - const result = await trx - .insert(userTicketGiftsSchema) - .values(userTicketGift) - .returning(); - - return result[0]; - }); - - if (!createdUserTicketGift) { - throw new GraphQLError("Could not create user ticket gift"); - } - - await RPC_SERVICE_EMAIL.sendGiftTicketConfirmations({ - giftId: createdUserTicketGift.id, - giftMessage: userTicketGift.giftMessage ?? null, - expirationDate: userTicketGift.expirationDate, - recipientName: recipientUser.name ?? recipientUser.username, - recipientEmail: recipientUser.email, - senderName: USER.name ?? USER.username, - ticketTags: userTicket.ticketTemplate.tags, - senderEmail: USER.email, - }); - - return createdUserTicketGift; - }, - }), -); - -builder.mutationField("acceptGiftedTicket", (t) => - t.field({ - type: UserTicketRef, - args: { - giftId: t.arg.string({ - required: true, - }), - }, - authz: { - rules: ["IsAuthenticated"], - }, - resolve: async (root, { giftId }, { DB, USER, RPC_SERVICE_EMAIL }) => { - if (!USER) { - throw new GraphQLError("User not found"); - } - - // find the ticket gift - const ticketGift = await DB.query.userTicketGiftsSchema.findFirst({ - where: (t, { eq, and }) => - and(eq(t.id, giftId), eq(t.recipientUserId, USER.id)), - columns: { - id: true, - status: true, - expirationDate: true, - userTicketId: true, - gifterUserId: true, - }, - with: { - gifterUser: { - columns: { - name: true, - email: true, - username: true, - }, - }, - userTicket: { - with: { - ticketTemplate: { - columns: { - tags: true, - }, - }, - }, - }, - }, - }); - - if (!ticketGift) { - throw new GraphQLError("Could not find ticket to accept"); - } - - if (ticketGift.status !== UserTicketGiftStatus.Pending) { - throw new GraphQLError("Ticket is not a gifted ticket"); - } - - if (ticketGift.expirationDate <= new Date()) { - await DB.update(userTicketGiftsSchema) - .set({ - status: UserTicketGiftStatus.Expired, - }) - .where(eq(userTicketGiftsSchema.id, ticketGift.id)); - - throw new GraphQLError("Gift attempt has expired"); - } - - const updatedTicket = await DB.update(userTicketsSchema) - .set({ - approvalStatus: "gift_accepted", - userId: USER.id, - }) - .where(eq(userTicketsSchema.id, ticketGift.userTicketId)) - .returning() - .then((t) => t?.[0]); - - await DB.update(userTicketGiftsSchema) - .set({ - status: UserTicketGiftStatus.Accepted, - }) - .where(eq(userTicketGiftsSchema.id, ticketGift.id)); - - await RPC_SERVICE_EMAIL.sendGiftAcceptanceNotificationToGifter({ - recipientName: USER.name ?? USER.username, - recipientEmail: USER.email, - senderName: - ticketGift.gifterUser.name ?? ticketGift.gifterUser.username, - ticketTags: ticketGift.userTicket.ticketTemplate.tags, - }); - - return updatedTicket; - }, - }), -); - builder.mutationField("triggerUserTicketApprovalReview", (t) => t.field({ type: [UserTicketRef], diff --git a/src/schema/userTickets/queries.ts b/src/schema/userTickets/queries.ts index b0c06b8a..08380a4a 100644 --- a/src/schema/userTickets/queries.ts +++ b/src/schema/userTickets/queries.ts @@ -9,11 +9,7 @@ import { createPaginationInputType, createPaginationObjectType, } from "~/schema/pagination/types"; -import { - PublicUserTicketRef, - UserTicketGiftRef, - UserTicketRef, -} from "~/schema/shared/refs"; +import { PublicUserTicketRef, UserTicketRef } from "~/schema/shared/refs"; import { userTicketFetcher } from "~/schema/userTickets/userTicketFetcher"; import { @@ -22,10 +18,6 @@ import { TicketRedemptionStatus, } from "./types"; -const SearchTicketGiftTypeEnum = builder.enumType("TicketGiftType", { - values: ["SENT", "RECEIVED", "ALL"] as const, -}); - const MyTicketsSearchValues = builder.inputType("MyTicketsSearchValues", { fields: (t) => ({ eventId: t.field({ @@ -112,46 +104,6 @@ builder.queryFields((t) => ({ }; }, }), - myTicketGifts: t.field({ - description: - "Get a list of user ticket gifts sent or received by the current user", - type: [UserTicketGiftRef], - args: { - type: t.arg({ - type: SearchTicketGiftTypeEnum, - defaultValue: "ALL", - }), - }, - authz: { - rules: ["IsAuthenticated"], - }, - resolve: async (root, args, { DB, USER }) => { - if (!USER) { - throw new Error("User not found"); - } - - const results = await DB.query.userTicketGiftsSchema.findMany({ - where: (utg, { eq, or }) => { - if (args.type === "ALL") { - return or( - eq(utg.gifterUserId, USER.id), - eq(utg.recipientUserId, USER.id), - ); - } - - if (args.type === "SENT") { - return eq(utg.gifterUserId, USER.id); - } - - if (args.type === "RECEIVED") { - return eq(utg.recipientUserId, USER.id); - } - }, - }); - - return results; - }, - }), })); const FindUserTicketSearchInput = builder.inputType( diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts deleted file mode 100644 index 513fd302..00000000 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.generated.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable */ -/* @ts-nocheck */ -/* prettier-ignore */ -/* This file is automatically generated using `npm run graphql:types` */ -import type * as Types from '../../../../generated/types'; - -import type { JsonObject } from "type-fest"; -import gql from 'graphql-tag'; -export type AcceptGiftedTicketMutationVariables = Types.Exact<{ - giftId: Types.Scalars['String']['input']; -}>; - - -export type AcceptGiftedTicketMutation = { __typename?: 'Mutation', acceptGiftedTicket: { __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus } }; - - -export const AcceptGiftedTicket = gql` - mutation AcceptGiftedTicket($giftId: String!) { - acceptGiftedTicket(giftId: $giftId) { - id - paymentStatus - approvalStatus - redemptionStatus - } -} - `; \ No newline at end of file diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql b/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql deleted file mode 100644 index c120bebd..00000000 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.gql +++ /dev/null @@ -1,8 +0,0 @@ -mutation AcceptGiftedTicket($giftId: String!) { - acceptGiftedTicket(giftId: $giftId) { - id - paymentStatus - approvalStatus - redemptionStatus - } -} diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts index 797e946c..0de129f9 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.generated.ts @@ -11,7 +11,7 @@ export type ClaimUserTicketMutationVariables = Types.Exact<{ }>; -export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus, giftAttempts: Array<{ __typename?: 'UserTicketGift', id: string, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, recipient: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null } }> }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; +export type ClaimUserTicketMutation = { __typename?: 'Mutation', claimUserTicket: { __typename: 'PurchaseOrder', id: string, tickets: Array<{ __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus, transferAttempts: Array<{ __typename?: 'UserTicketTransfer', id: string, sender: { __typename?: 'TicketTransferUserInfo', email: string, name: string | null }, recipient: { __typename?: 'TicketTransferUserInfo', email: string, name: string | null } }> }> } | { __typename: 'RedeemUserTicketError', errorMessage: string } }; export const ClaimUserTicket = gql` @@ -25,9 +25,9 @@ export const ClaimUserTicket = gql` paymentStatus approvalStatus redemptionStatus - giftAttempts { + transferAttempts { id - gifter { + sender { email name } diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql index b98e8b25..2e25a9c9 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.gql @@ -8,9 +8,9 @@ mutation ClaimUserTicket($input: TicketClaimInput!) { paymentStatus approvalStatus redemptionStatus - giftAttempts { + transferAttempts { id - gifter { + sender { email name } diff --git a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts index 02201722..7fe01357 100644 --- a/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts +++ b/src/schema/userTickets/tests/claimUserTicket/claimUserTicket.test.ts @@ -102,12 +102,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -146,12 +146,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -190,12 +190,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -234,12 +234,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -292,12 +292,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -352,12 +352,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 10, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -411,7 +411,7 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 5, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -430,8 +430,8 @@ describe("Claim a user ticket", () => { }); }); - describe("Should handle gifting scenarios", () => { - it("Should handle gifting scenarios", async () => { + describe("Should handle transferring scenarios", () => { + it("Should handle transferring to another user", async () => { const createdEvent = await insertEvent({ status: "active", }); @@ -444,7 +444,7 @@ describe("Claim a user ticket", () => { event: createdEvent, ticketTemplate: createdTicketTemplate, }); - const giftRecipient = await insertUser(); + const transferRecipient = await insertUser(); await insertUserToCommunity({ communityId: community.id, @@ -464,10 +464,10 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [ + transfersInfo: [ { name: "John Doe", - email: giftRecipient.email, + email: transferRecipient.email, message: "Enjoy the event!", }, ], @@ -487,19 +487,19 @@ describe("Claim a user ticket", () => { assert.equal(response.data.claimUserTicket.tickets.length, 2); assert.equal( - response.data.claimUserTicket.tickets[0].giftAttempts.length, + response.data.claimUserTicket.tickets[0].transferAttempts.length, 1, ); assert.equal( - response.data.claimUserTicket.tickets[0].giftAttempts[0].recipient + response.data.claimUserTicket.tickets[0].transferAttempts[0].recipient .email, - giftRecipient.email, + transferRecipient.email, ); } }); - it("Should not allow gifting to self", async () => { + it("Should not allow transferring to self", async () => { const createdEvent = await insertEvent({ status: "active", }); @@ -531,11 +531,11 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [ + transfersInfo: [ { name: "Para mi", email: user.email, - message: "Self-gift", + message: "Self-transfer", }, ], }, @@ -558,7 +558,7 @@ describe("Claim a user ticket", () => { ) { assert.equal( response.data.claimUserTicket.errorMessage, - "Cannot gift to yourself", + "Cannot transfer to yourself", ); } }); @@ -589,7 +589,7 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, ], }, @@ -643,12 +643,12 @@ describe("Claim a user ticket", () => { { ticketId: ticketTemplate.id, quantity: 2, - giftInfo: [], + transfersInfo: [], }, { ticketId: ticketTemplate.id, quantity: 1, - giftInfo: [], + transfersInfo: [], }, ], }, diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts deleted file mode 100644 index 4052e003..00000000 --- a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.generated.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable */ -/* @ts-nocheck */ -/* prettier-ignore */ -/* This file is automatically generated using `npm run graphql:types` */ -import type * as Types from '../../../../generated/types'; - -import type { JsonObject } from "type-fest"; -import gql from 'graphql-tag'; -export type GiftMyTicketToUserMutationVariables = Types.Exact<{ - ticketId: Types.Scalars['String']['input']; - input: Types.GiftInfoInput; -}>; - - -export type GiftMyTicketToUserMutation = { __typename?: 'Mutation', giftMyTicketToUser: { __typename?: 'UserTicketGift', id: string, status: Types.GiftAttemptStatus, expirationDate: string, giftMessage: string | null, gifter: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, recipient: { __typename?: 'GiftTicketUserInfo', email: string, name: string | null }, userTicket: { __typename?: 'UserTicket', id: string, approvalStatus: Types.TicketApprovalStatus } } }; - - -export const GiftMyTicketToUser = gql` - mutation GiftMyTicketToUser($ticketId: String!, $input: GiftInfoInput!) { - giftMyTicketToUser(ticketId: $ticketId, input: $input) { - id - status - expirationDate - giftMessage - gifter { - email - name - } - recipient { - email - name - } - userTicket { - id - approvalStatus - } - } -} - `; \ No newline at end of file diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql b/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql deleted file mode 100644 index a15cb925..00000000 --- a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.gql +++ /dev/null @@ -1,20 +0,0 @@ -mutation GiftMyTicketToUser($ticketId: String!, $input: GiftInfoInput!) { - giftMyTicketToUser(ticketId: $ticketId, input: $input) { - id - status - expirationDate - giftMessage - gifter { - email - name - } - recipient { - email - name - } - userTicket { - id - approvalStatus - } - } -} diff --git a/src/schema/userTickets/types.ts b/src/schema/userTickets/types.ts index 053da7e0..cc90e664 100644 --- a/src/schema/userTickets/types.ts +++ b/src/schema/userTickets/types.ts @@ -4,8 +4,6 @@ import { puchaseOrderPaymentStatusEnum, userTicketsRedemptionStatusEnum, selectUsersSchema, - UserTicketGiftStatus, - selectUserTicketsSchema, } from "~/datasources/db/schema"; import { PurchaseOrderLoadable, @@ -15,7 +13,7 @@ import { UserRef, UserTicketRef, PublicUserTicketRef, - UserTicketGiftRef, + UserTicketTransferRef, } from "~/schema/shared/refs"; import { TicketLoadable } from "~/schema/ticket/types"; import { UserLoadable } from "~/schema/user/types"; @@ -108,8 +106,8 @@ builder.objectType(UserTicketRef, { nullable: false, resolve: (root) => new Date(root.createdAt), }), - giftAttempts: t.field({ - type: [UserTicketGiftRef], + transferAttempts: t.field({ + type: [UserTicketTransferRef], resolve: async (root, args, context) => { const canRequest = context.USER?.id === root.userId || context.USER?.isSuperAdmin; @@ -122,12 +120,12 @@ builder.objectType(UserTicketRef, { return []; } - const userTicketGifts = - await context.DB.query.userTicketGiftsSchema.findMany({ + const userTicketTransfers = + await context.DB.query.userTicketTransfersSchema.findMany({ where: (utg, { eq }) => eq(utg.userTicketId, root.id), }); - return userTicketGifts; + return userTicketTransfers; }, }), }), @@ -215,85 +213,3 @@ export const RedeemUserTicketError = builder.objectType( }), }, ); - -export const GiftAttemptStatusEnum = builder.enumType(UserTicketGiftStatus, { - name: "GiftAttemptStatus", -}); - -const GiftTicketUserInfo = builder.objectRef<{ - email: string; - name: string | null; -}>("GiftTicketUserInfo"); - -builder.objectType(GiftTicketUserInfo, { - fields: (t) => ({ - email: t.exposeString("email"), - name: t.exposeString("name", { - nullable: true, - }), - }), -}); - -builder.objectType(UserTicketGiftRef, { - description: "Representation of a user ticket gift", - fields: (t) => ({ - id: t.exposeID("id"), - gifter: t.field({ - type: GiftTicketUserInfo, - resolve: async (root, args, { DB }) => { - const user = await DB.query.usersSchema.findFirst({ - where: (u, { eq }) => eq(u.id, root.gifterUserId), - }); - - if (!user) { - throw new Error("User not found"); - } - - return { - email: user.email, - name: user.name, - }; - }, - }), - recipient: t.field({ - type: GiftTicketUserInfo, - resolve: async (root, args, { DB }) => { - const user = await DB.query.usersSchema.findFirst({ - where: (u, { eq }) => eq(u.id, root.recipientUserId), - }); - - if (!user) { - throw new Error("User not found"); - } - - return { - email: user.email, - name: user.name, - }; - }, - }), - status: t.expose("status", { type: GiftAttemptStatusEnum }), - expirationDate: t.expose("expirationDate", { - type: "DateTime", - nullable: false, - }), - giftMessage: t.expose("giftMessage", { - type: "String", - nullable: true, - }), - userTicket: t.field({ - type: UserTicketRef, - resolve: async (root, args, { DB }) => { - const userTicket = await DB.query.userTicketsSchema.findFirst({ - where: (ut, { eq }) => eq(ut.id, root.userTicketId), - }); - - if (!userTicket) { - throw new Error("User ticket not found"); - } - - return selectUserTicketsSchema.parse(userTicket); - }, - }), - }), -}); diff --git a/src/schema/userTicketsTransfers/helpers.ts b/src/schema/userTicketsTransfers/helpers.ts new file mode 100644 index 00000000..066a3ace --- /dev/null +++ b/src/schema/userTicketsTransfers/helpers.ts @@ -0,0 +1,75 @@ +import { addDays, endOfDay } from "date-fns"; + +import { ORM_TYPE } from "~/datasources/db"; +import { usersSchema } from "~/datasources/db/users"; +import { getUsername } from "~/datasources/queries/utils/createUsername"; + +type GetOrCreateTransferRecipientsOptions = { + DB: ORM_TYPE; + transferRecipients: { + email: string; + name: string; + }[]; +}; + +type GetOrCreateTransferRecipientsItem = { + id: string; + email: string; + name: string | null; + username: string; +}; + +export const getOrCreateTransferRecipients = async ({ + DB, + transferRecipients, +}: GetOrCreateTransferRecipientsOptions): Promise< + Map +> => { + if (transferRecipients.length === 0) { + return new Map(); + } + + // Insert users that don't exist + // We use onConflictDoNothing to avoid errors + // if the user already exists or if is being created by another process + await DB.insert(usersSchema) + .values( + transferRecipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + username: getUsername(recipient.email), + })), + ) + .onConflictDoNothing() + .returning({ + id: usersSchema.id, + email: usersSchema.email, + name: usersSchema.name, + username: usersSchema.username, + }); + + const users = await DB.query.usersSchema.findMany({ + where: (t, { inArray }) => + inArray( + t.email, + transferRecipients.map((r) => r.email), + ), + }); + + const emailToUser = new Map( + users.map((user) => [user.email, user]), + ); + + return emailToUser; +}; + +/** + * Returns the expiration date for a transfer. + * The expiration date is at least 7 days from the current date with the end at 23:59:59 of the 7th day. + */ +export const getExpirationDateForTicketTransfer = () => { + const minDays = 7; + const expirationDate = endOfDay(addDays(new Date(), minDays)); + + return expirationDate; +}; diff --git a/src/schema/userTicketsTransfers/mutations.ts b/src/schema/userTicketsTransfers/mutations.ts new file mode 100644 index 00000000..6d92091d --- /dev/null +++ b/src/schema/userTicketsTransfers/mutations.ts @@ -0,0 +1,236 @@ +import { eq } from "drizzle-orm"; +import { GraphQLError } from "graphql"; + +import { builder } from "~/builder"; +import { + UserTicketTransferStatus, + InsertUserTicketTransferSchema, + userTicketTransfersSchema, + userTicketsSchema, + userTicketsApprovalStatusEnum, +} from "~/datasources/db/schema"; +import { UserTicketTransferRef, UserTicketRef } from "~/schema/shared/refs"; + +import { + getExpirationDateForTicketTransfer, + getOrCreateTransferRecipients, +} from "./helpers"; +import { cleanEmail } from "../user/userHelpers"; + +export const UserTicketTransferInfoInputRef = builder.inputType( + "UserTicketTransferInfoInput", + { + fields: (t) => ({ + email: t.string({ + required: true, + }), + name: t.string({ + required: true, + }), + message: t.string({ + required: false, + }), + }), + }, +); + +builder.mutationField("transferMyTicketToUser", (t) => + t.field({ + type: UserTicketTransferRef, + args: { + ticketId: t.arg.string({ required: true }), + input: t.arg({ type: UserTicketTransferInfoInputRef, required: true }), + }, + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async ( + root, + { ticketId, input }, + { DB, USER, RPC_SERVICE_EMAIL }, + ) => { + if (!USER) { + throw new GraphQLError("User not found"); + } + + const { email, name, message } = input; + const cleanedEmail = cleanEmail(email); + + const userTicket = await DB.query.userTicketsSchema.findFirst({ + where: (t, { eq, and }) => + and(eq(t.id, ticketId), eq(t.userId, USER.id)), + with: { + ticketTemplate: { + columns: { + tags: true, + }, + }, + }, + }); + + if (!userTicket) { + throw new GraphQLError("Ticket not found"); + } + + const validApprovalStatus: (typeof userTicketsApprovalStatusEnum)[number][] = + ["approved", "not_required", "gift_accepted"]; + + if (!validApprovalStatus.includes(userTicket.approvalStatus)) { + throw new GraphQLError("Ticket is not transferable"); + } + + const recipientUser = await getOrCreateTransferRecipients({ + DB: DB, + transferRecipients: [{ email: cleanedEmail, name }], + }).then((result) => { + if (!result) { + return null; + } + + return result.get(cleanedEmail); + }); + + if (!recipientUser) { + throw new GraphQLError("Receiver user not found"); + } + + const userTicketTransfer: InsertUserTicketTransferSchema = { + userTicketId: userTicket.id, + senderUserId: USER.id, + recipientUserId: recipientUser.id, + status: UserTicketTransferStatus.Pending, + expirationDate: getExpirationDateForTicketTransfer(), + transferMessage: message ?? null, + }; + + const createdUserTicketTransfer = await DB.transaction(async (trx) => { + await trx + .update(userTicketsSchema) + .set({ + approvalStatus: "gifted", + userId: recipientUser.id, + }) + .where(eq(userTicketsSchema.id, userTicket.id)); + + const result = await trx + .insert(userTicketTransfersSchema) + .values(userTicketTransfer) + .returning(); + + return result[0]; + }); + + if (!createdUserTicketTransfer) { + throw new GraphQLError("Could not create user ticket transfer"); + } + + await RPC_SERVICE_EMAIL.sendTransferTicketConfirmations({ + transferId: createdUserTicketTransfer.id, + transferMessage: userTicketTransfer.transferMessage ?? null, + expirationDate: userTicketTransfer.expirationDate, + recipientName: recipientUser.name ?? recipientUser.username, + recipientEmail: recipientUser.email, + senderName: USER.name ?? USER.username, + ticketTags: userTicket.ticketTemplate.tags, + senderEmail: USER.email, + }); + + return createdUserTicketTransfer; + }, + }), +); + +builder.mutationField("acceptTransferredTicket", (t) => + t.field({ + type: UserTicketRef, + args: { + transferId: t.arg.string({ + required: true, + }), + }, + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async (root, { transferId }, { DB, USER, RPC_SERVICE_EMAIL }) => { + if (!USER) { + throw new GraphQLError("User not found"); + } + + // find the ticket transfer + const ticketTransfer = await DB.query.userTicketTransfersSchema.findFirst( + { + where: (t, { eq, and }) => + and(eq(t.id, transferId), eq(t.recipientUserId, USER.id)), + columns: { + id: true, + status: true, + expirationDate: true, + userTicketId: true, + senderUserId: true, + }, + with: { + senderUser: { + columns: { + name: true, + email: true, + username: true, + }, + }, + userTicket: { + with: { + ticketTemplate: { + columns: { + tags: true, + }, + }, + }, + }, + }, + }, + ); + + if (!ticketTransfer) { + throw new GraphQLError("Could not find ticket to accept"); + } + + if (ticketTransfer.status !== UserTicketTransferStatus.Pending) { + throw new GraphQLError("Ticket is not transferable"); + } + + if (ticketTransfer.expirationDate <= new Date()) { + await DB.update(userTicketTransfersSchema) + .set({ + status: UserTicketTransferStatus.Expired, + }) + .where(eq(userTicketTransfersSchema.id, ticketTransfer.id)); + + throw new GraphQLError("Transfer attempt has expired"); + } + + const updatedTicket = await DB.update(userTicketsSchema) + .set({ + approvalStatus: "gift_accepted", + userId: USER.id, + }) + .where(eq(userTicketsSchema.id, ticketTransfer.userTicketId)) + .returning() + .then((t) => t?.[0]); + + await DB.update(userTicketTransfersSchema) + .set({ + status: UserTicketTransferStatus.Accepted, + }) + .where(eq(userTicketTransfersSchema.id, ticketTransfer.id)); + + await RPC_SERVICE_EMAIL.sendTransferAcceptanceNotificationToSender({ + recipientName: USER.name ?? USER.username, + recipientEmail: USER.email, + senderName: + ticketTransfer.senderUser.name ?? ticketTransfer.senderUser.username, + ticketTags: ticketTransfer.userTicket.ticketTemplate.tags, + }); + + return updatedTicket; + }, + }), +); diff --git a/src/schema/userTicketsTransfers/queries.ts b/src/schema/userTicketsTransfers/queries.ts new file mode 100644 index 00000000..c1555388 --- /dev/null +++ b/src/schema/userTicketsTransfers/queries.ts @@ -0,0 +1,49 @@ +import { builder } from "~/builder"; +import { UserTicketTransferRef } from "~/schema/shared/refs"; + +const SearchTicketTransferTypeEnum = builder.enumType("TicketTransferType", { + values: ["SENT", "RECEIVED", "ALL"] as const, +}); + +builder.queryFields((t) => ({ + myTicketTransfers: t.field({ + description: + "Get a list of user ticket transfers sent or received by the current user", + type: [UserTicketTransferRef], + args: { + type: t.arg({ + type: SearchTicketTransferTypeEnum, + defaultValue: "ALL", + }), + }, + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async (root, args, { DB, USER }) => { + if (!USER) { + throw new Error("User not found"); + } + + const results = await DB.query.userTicketTransfersSchema.findMany({ + where: (utg, { eq, or }) => { + if (args.type === "ALL") { + return or( + eq(utg.senderUserId, USER.id), + eq(utg.recipientUserId, USER.id), + ); + } + + if (args.type === "SENT") { + return eq(utg.senderUserId, USER.id); + } + + if (args.type === "RECEIVED") { + return eq(utg.recipientUserId, USER.id); + } + }, + }); + + return results; + }, + }), +})); diff --git a/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.generated.ts b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.generated.ts new file mode 100644 index 00000000..8685707d --- /dev/null +++ b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.generated.ts @@ -0,0 +1,26 @@ +/* eslint-disable */ +/* @ts-nocheck */ +/* prettier-ignore */ +/* This file is automatically generated using `npm run graphql:types` */ +import type * as Types from '../../../../generated/types'; + +import type { JsonObject } from "type-fest"; +import gql from 'graphql-tag'; +export type AcceptTransferredTicketMutationVariables = Types.Exact<{ + transferId: Types.Scalars['String']['input']; +}>; + + +export type AcceptTransferredTicketMutation = { __typename?: 'Mutation', acceptTransferredTicket: { __typename?: 'UserTicket', id: string, paymentStatus: Types.PurchaseOrderPaymentStatusEnum | null, approvalStatus: Types.TicketApprovalStatus, redemptionStatus: Types.TicketRedemptionStatus } }; + + +export const AcceptTransferredTicket = gql` + mutation AcceptTransferredTicket($transferId: String!) { + acceptTransferredTicket(transferId: $transferId) { + id + paymentStatus + approvalStatus + redemptionStatus + } +} + `; \ No newline at end of file diff --git a/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.gql b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.gql new file mode 100644 index 00000000..1b7e80b6 --- /dev/null +++ b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.gql @@ -0,0 +1,8 @@ +mutation AcceptTransferredTicket($transferId: String!) { + acceptTransferredTicket(transferId: $transferId) { + id + paymentStatus + approvalStatus + redemptionStatus + } +} diff --git a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts similarity index 53% rename from src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts rename to src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts index d10e7e1f..dd333968 100644 --- a/src/schema/userTickets/tests/acceptGiftedTickets/acceptGiftedTicket.test.ts +++ b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts @@ -1,9 +1,7 @@ import { it, describe, assert } from "vitest"; -import { - UserTicketGiftStatus, - userTicketsApprovalStatusEnum, -} from "~/datasources/db/userTickets"; +import { userTicketsApprovalStatusEnum } from "~/datasources/db/userTickets"; +import { UserTicketTransferStatus } from "~/datasources/db/userTicketsTransfers"; import { executeGraphqlOperationAsUser, insertCommunity, @@ -13,15 +11,15 @@ import { insertTicket, insertTicketTemplate, insertUser, - insertUserTicketGift, + insertUserTicketTransfer, } from "~/tests/fixtures"; import { - AcceptGiftedTicket, - AcceptGiftedTicketMutation, - AcceptGiftedTicketMutationVariables, -} from "./acceptGiftedTicket.generated"; -import { getExpirationDateForGift } from "../../helpers"; + AcceptTransferredTicket, + AcceptTransferredTicketMutation, + AcceptTransferredTicketMutationVariables, +} from "./acceptTransferredTicket.generated"; +import { getExpirationDateForTicketTransfer } from "../../helpers"; const prepareTickets = async ( status: (typeof userTicketsApprovalStatusEnum)[number] = "gifted", @@ -33,7 +31,7 @@ const prepareTickets = async ( eventId: event1.id, communityId: community1.id, }); - const gifterUser = await insertUser(); + const senderUser = await insertUser(); const recipientUser = await insertUser(); const ticketTemplate1 = await insertTicketTemplate({ eventId: event1.id, @@ -41,41 +39,41 @@ const prepareTickets = async ( const purchaseOrder = await insertPurchaseOrder(); const ticket1 = await insertTicket({ ticketTemplateId: ticketTemplate1.id, - userId: gifterUser.id, + userId: senderUser.id, purchaseOrderId: purchaseOrder.id, approvalStatus: status, }); - const ticketGift1 = await insertUserTicketGift({ + const ticketTransfer1 = await insertUserTicketTransfer({ userTicketId: ticket1.id, - gifterUserId: gifterUser.id, + senderUserId: senderUser.id, recipientUserId: recipientUser.id, status: status === "gifted" - ? UserTicketGiftStatus.Pending - : UserTicketGiftStatus.Accepted, - expirationDate: getExpirationDateForGift(), + ? UserTicketTransferStatus.Pending + : UserTicketTransferStatus.Accepted, + expirationDate: getExpirationDateForTicketTransfer(), }); return { ticket: ticket1, - gifterUser, + senderUser, recipientUser, - ticketGift: ticketGift1, + ticketTransfer: ticketTransfer1, }; }; -describe("Accept user ticket gift", () => { +describe("Accept user ticket transfer", () => { describe("Should work", () => { - it("If ticket is in a gifted status and user is ticket owner", async () => { - const { recipientUser, ticketGift } = await prepareTickets(); + it("If ticket is in a transferable status and user is ticket owner", async () => { + const { recipientUser, ticketTransfer } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< - AcceptGiftedTicketMutation, - AcceptGiftedTicketMutationVariables + AcceptTransferredTicketMutation, + AcceptTransferredTicketMutationVariables >( { - document: AcceptGiftedTicket, + document: AcceptTransferredTicket, variables: { - giftId: ticketGift.id, + transferId: ticketTransfer.id, }, }, recipientUser, @@ -84,7 +82,7 @@ describe("Accept user ticket gift", () => { assert.equal(response.errors, undefined); assert.equal( - response.data?.acceptGiftedTicket?.approvalStatus, + response.data?.acceptTransferredTicket?.approvalStatus, "gift_accepted", ); }); @@ -92,16 +90,16 @@ describe("Accept user ticket gift", () => { describe("Should throw an error", () => { it("if user is not owner", async () => { - const { ticketGift } = await prepareTickets(); + const { ticketTransfer } = await prepareTickets(); const otherUser = await insertUser(); const response = await executeGraphqlOperationAsUser< - AcceptGiftedTicketMutation, - AcceptGiftedTicketMutationVariables + AcceptTransferredTicketMutation, + AcceptTransferredTicketMutationVariables >( { - document: AcceptGiftedTicket, + document: AcceptTransferredTicket, variables: { - giftId: ticketGift.id, + transferId: ticketTransfer.id, }, }, otherUser, @@ -116,13 +114,13 @@ describe("Accept user ticket gift", () => { it("if ticket does not exist", async () => { const { recipientUser } = await prepareTickets(); const response = await executeGraphqlOperationAsUser< - AcceptGiftedTicketMutation, - AcceptGiftedTicketMutationVariables + AcceptTransferredTicketMutation, + AcceptTransferredTicketMutationVariables >( { - document: AcceptGiftedTicket, + document: AcceptTransferredTicket, variables: { - giftId: "non-existent-id", + transferId: "non-existent-id", }, }, recipientUser, @@ -131,26 +129,23 @@ describe("Accept user ticket gift", () => { assert.equal(response.errors?.[0].message, "Unexpected error."); }); - it("If tickets is not in a gifted state", async () => { - const { recipientUser, ticketGift } = + it("If tickets is not in a transferable state", async () => { + const { recipientUser, ticketTransfer } = await prepareTickets("gift_accepted"); const response = await executeGraphqlOperationAsUser< - AcceptGiftedTicketMutation, - AcceptGiftedTicketMutationVariables + AcceptTransferredTicketMutation, + AcceptTransferredTicketMutationVariables >( { - document: AcceptGiftedTicket, + document: AcceptTransferredTicket, variables: { - giftId: ticketGift.id, + transferId: ticketTransfer.id, }, }, recipientUser, ); - assert.equal( - response.errors?.[0].message, - "Ticket is not a gifted ticket", - ); + assert.equal(response.errors?.[0].message, "Ticket is not transferable"); }); }); }); diff --git a/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.generated.ts b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.generated.ts new file mode 100644 index 00000000..074eaf9d --- /dev/null +++ b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.generated.ts @@ -0,0 +1,39 @@ +/* eslint-disable */ +/* @ts-nocheck */ +/* prettier-ignore */ +/* This file is automatically generated using `npm run graphql:types` */ +import type * as Types from '../../../../generated/types'; + +import type { JsonObject } from "type-fest"; +import gql from 'graphql-tag'; +export type TransferMyTicketToUserMutationVariables = Types.Exact<{ + ticketId: Types.Scalars['String']['input']; + input: Types.UserTicketTransferInfoInput; +}>; + + +export type TransferMyTicketToUserMutation = { __typename?: 'Mutation', transferMyTicketToUser: { __typename?: 'UserTicketTransfer', id: string, status: Types.TicketTransferAttemptStatus, expirationDate: string, transferMessage: string | null, sender: { __typename?: 'TicketTransferUserInfo', email: string, name: string | null }, recipient: { __typename?: 'TicketTransferUserInfo', email: string, name: string | null }, userTicket: { __typename?: 'UserTicket', id: string, approvalStatus: Types.TicketApprovalStatus } } }; + + +export const TransferMyTicketToUser = gql` + mutation TransferMyTicketToUser($ticketId: String!, $input: UserTicketTransferInfoInput!) { + transferMyTicketToUser(ticketId: $ticketId, input: $input) { + id + status + expirationDate + transferMessage + sender { + email + name + } + recipient { + email + name + } + userTicket { + id + approvalStatus + } + } +} + `; \ No newline at end of file diff --git a/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.gql b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.gql new file mode 100644 index 00000000..7e3d682d --- /dev/null +++ b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.gql @@ -0,0 +1,23 @@ +mutation TransferMyTicketToUser( + $ticketId: String! + $input: UserTicketTransferInfoInput! +) { + transferMyTicketToUser(ticketId: $ticketId, input: $input) { + id + status + expirationDate + transferMessage + sender { + email + name + } + recipient { + email + name + } + userTicket { + id + approvalStatus + } + } +} diff --git a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts similarity index 72% rename from src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts rename to src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts index 2ec4a631..dcc7ead6 100644 --- a/src/schema/userTickets/tests/giftMyTicketToUser/giftMyTicketToUser.test.ts +++ b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts @@ -1,7 +1,6 @@ -import { addDays, differenceInSeconds, endOfDay } from "date-fns"; +import { differenceInSeconds } from "date-fns"; import { AsyncReturnType } from "type-fest"; -import { v4 } from "uuid"; -import { assert, describe, it, expect } from "vitest"; +import { assert, describe, it } from "vitest"; import { executeGraphqlOperationAsUser, @@ -13,17 +12,16 @@ import { insertTicketPrice, insertTicketTemplate, insertUser, - insertUserToCommunity, insertTicket, } from "~/tests/fixtures"; import { getTestDB } from "~/tests/fixtures/databaseHelper"; import { - GiftMyTicketToUser, - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables, -} from "./giftMyTicketToUser.generated"; -import { getExpirationDateForGift } from "../../helpers"; + TransferMyTicketToUser, + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables, +} from "./transferMyTicketToUser.generated"; +import { getExpirationDateForTicketTransfer } from "../../helpers"; const createTestSetup = async ({ community, @@ -94,18 +92,18 @@ const createTestSetup = async ({ }; }; -describe("Gift My Ticket To User", () => { - describe("Successful gifting scenarios", () => { - it("Should successfully gift a ticket to an existing user", async () => { +describe("Transfer My Ticket To User", () => { + describe("Successful transfer scenarios", () => { + it("Should successfully transfer a ticket to an existing user", async () => { const { user, userTicket } = await createTestSetup(); const recipientUser = await insertUser(); const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -120,12 +118,15 @@ describe("Gift My Ticket To User", () => { assert.equal(response.errors, undefined); - assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + assert.equal(response.data?.transferMyTicketToUser.status, "Pending"); - assert.equal(response.data?.giftMyTicketToUser.gifter.email, user.email); + assert.equal( + response.data?.transferMyTicketToUser.sender.email, + user.email, + ); assert.equal( - response.data?.giftMyTicketToUser.recipient.email, + response.data?.transferMyTicketToUser.recipient.email, recipientUser.email, ); @@ -140,17 +141,17 @@ describe("Gift My Ticket To User", () => { assert.equal(updatedUserTicket?.userId, recipientUser.id); }); - it("Should successfully gift a ticket to a non-existent user (creating a new user)", async () => { + it("Should successfully transfer a ticket to a non-existent user (creating a new user)", async () => { const { user, userTicket } = await createTestSetup(); const newEmail = "newuser@example.com"; const newName = "New User"; const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -164,11 +165,17 @@ describe("Gift My Ticket To User", () => { assert.equal(response.errors, undefined); - assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + assert.equal(response.data?.transferMyTicketToUser.status, "Pending"); - assert.equal(response.data?.giftMyTicketToUser.gifter.email, user.email); + assert.equal( + response.data?.transferMyTicketToUser.sender.email, + user.email, + ); - assert.equal(response.data?.giftMyTicketToUser.recipient.email, newEmail); + assert.equal( + response.data?.transferMyTicketToUser.recipient.email, + newEmail, + ); // Verify new user creation and database changes const DB = await getTestDB(); @@ -189,16 +196,16 @@ describe("Gift My Ticket To User", () => { assert.equal(updatedUserTicket?.userId, newUser?.id); }); - it("Should handle gifting without a message", async () => { + it("Should handle transfer without a message", async () => { const { user, userTicket } = await createTestSetup(); const recipientUser = await insertUser(); const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -212,23 +219,23 @@ describe("Gift My Ticket To User", () => { assert.equal(response.errors, undefined); - assert.equal(response.data?.giftMyTicketToUser.status, "Pending"); + assert.equal(response.data?.transferMyTicketToUser.status, "Pending"); - assert.equal(response.data?.giftMyTicketToUser.giftMessage, null); + assert.equal(response.data?.transferMyTicketToUser.transferMessage, null); }); }); describe("Error handling scenarios", () => { - it("Should throw an error when gifting a non-existent ticket", async () => { + it("Should throw an error when transferring a non-existent ticket", async () => { const { user } = await createTestSetup(); const recipientUser = await insertUser(); const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: "00000000-0000-4000-8000-000000000000", input: { @@ -243,7 +250,7 @@ describe("Gift My Ticket To User", () => { assert.equal(response.errors?.[0].message, "Ticket not found"); }); - it("Should throw an error when gifting a ticket with invalid approval status", async () => { + it("Should throw an error when transferring a ticket with invalid approval status", async () => { const { user } = await createTestSetup(); const recipientUser = await insertUser(); const invalidTicket = await insertTicket({ @@ -253,11 +260,11 @@ describe("Gift My Ticket To User", () => { }); const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: invalidTicket.id, input: { @@ -269,7 +276,7 @@ describe("Gift My Ticket To User", () => { user, ); - assert.equal(response.errors?.[0].message, "Ticket is not giftable"); + assert.equal(response.errors?.[0].message, "Ticket is not transferable"); // Verify that the ticket status hasn't changed const DB = await getTestDB(); @@ -282,17 +289,17 @@ describe("Gift My Ticket To User", () => { }); describe("Edge cases", () => { - it("Should handle multiple gift attempts for the same ticket", async () => { + it("Should handle multiple transfer attempts for the same ticket", async () => { const { user, userTicket } = await createTestSetup(); const recipientUser1 = await insertUser(); const recipientUser2 = await insertUser(); const response1 = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -304,15 +311,15 @@ describe("Gift My Ticket To User", () => { user, ); - assert.equal(response1.data?.giftMyTicketToUser.status, "Pending"); + assert.equal(response1.data?.transferMyTicketToUser.status, "Pending"); - // Attempt to gift the same ticket again + // Attempt to transfer the same ticket again const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -340,11 +347,11 @@ describe("Gift My Ticket To User", () => { const recipientUser = await insertUser(); const response = await executeGraphqlOperationAsUser< - GiftMyTicketToUserMutation, - GiftMyTicketToUserMutationVariables + TransferMyTicketToUserMutation, + TransferMyTicketToUserMutationVariables >( { - document: GiftMyTicketToUser, + document: TransferMyTicketToUser, variables: { ticketId: userTicket.id, input: { @@ -358,9 +365,9 @@ describe("Gift My Ticket To User", () => { assert.equal(response.errors, undefined); - const expectedExpirationDate = getExpirationDateForGift(); + const expectedExpirationDate = getExpirationDateForTicketTransfer(); const actualExpirationDate = new Date( - response.data?.giftMyTicketToUser.expirationDate ?? "", + response.data?.transferMyTicketToUser.expirationDate ?? "", ); const diffInSeconds = differenceInSeconds( diff --git a/src/schema/userTicketsTransfers/types.ts b/src/schema/userTicketsTransfers/types.ts new file mode 100644 index 00000000..665c1a46 --- /dev/null +++ b/src/schema/userTicketsTransfers/types.ts @@ -0,0 +1,91 @@ +import { builder } from "~/builder"; +import { + UserTicketTransferStatus, + selectUserTicketsSchema, +} from "~/datasources/db/schema"; +import { UserTicketRef, UserTicketTransferRef } from "~/schema/shared/refs"; + +export const TicketTransferAttemptStatusEnum = builder.enumType( + UserTicketTransferStatus, + { + name: "TicketTransferAttemptStatus", + }, +); + +const TicketTransferUserInfoRef = builder.objectRef<{ + email: string; + name: string | null; +}>("TicketTransferUserInfo"); + +builder.objectType(TicketTransferUserInfoRef, { + fields: (t) => ({ + email: t.exposeString("email"), + name: t.exposeString("name", { + nullable: true, + }), + }), +}); + +builder.objectType(UserTicketTransferRef, { + description: "Representation of a user ticket transfer", + fields: (t) => ({ + id: t.exposeID("id"), + sender: t.field({ + type: TicketTransferUserInfoRef, + resolve: async (root, args, { DB }) => { + const user = await DB.query.usersSchema.findFirst({ + where: (u, { eq }) => eq(u.id, root.senderUserId), + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + email: user.email, + name: user.name, + }; + }, + }), + recipient: t.field({ + type: TicketTransferUserInfoRef, + resolve: async (root, args, { DB }) => { + const user = await DB.query.usersSchema.findFirst({ + where: (u, { eq }) => eq(u.id, root.recipientUserId), + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + email: user.email, + name: user.name, + }; + }, + }), + status: t.expose("status", { type: TicketTransferAttemptStatusEnum }), + expirationDate: t.expose("expirationDate", { + type: "DateTime", + nullable: false, + }), + transferMessage: t.expose("transferMessage", { + type: "String", + nullable: true, + }), + userTicket: t.field({ + type: UserTicketRef, + resolve: async (root, args, { DB }) => { + const userTicket = await DB.query.userTicketsSchema.findFirst({ + where: (ut, { eq }) => eq(ut.id, root.userTicketId), + }); + + if (!userTicket) { + throw new Error("User ticket not found"); + } + + return selectUserTicketsSchema.parse(userTicket); + }, + }), + }), +}); diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index f67d1f77..cfa20849 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -40,7 +40,7 @@ import { insertTicketSchema, insertUserDataSchema, insertUserTeamsSchema, - insertUserTicketGiftSchema, + insertUserTicketTransferSchema, insertUserTicketsSchema, insertUsersSchema, insertUsersToCommunitiesSchema, @@ -68,7 +68,7 @@ import { selectTicketSchema, selectUserDataSchema, selectUserTeamsSchema, - selectUserTicketGiftSchema, + selectUserTicketTransferSchema, selectUserTicketsSchema, selectUsersSchema, selectUsersToCommunitiesSchema, @@ -82,7 +82,7 @@ import { ticketsSchema, userDataSchema, userTeamsSchema, - userTicketGiftsSchema, + userTicketTransfersSchema, userTicketsSchema, usersSchema, usersTagsSchema, @@ -820,23 +820,23 @@ export const insertSalary = async ( ); }; -export const insertUserTicketGift = async ( - partialInput: z.infer, +export const insertUserTicketTransfer = async ( + partialInput: z.infer, ) => { const possibleInput = { id: partialInput?.id ?? faker.string.uuid(), userTicketId: partialInput?.userTicketId, - gifterUserId: partialInput?.gifterUserId, + senderUserId: partialInput?.senderUserId, recipientUserId: partialInput?.recipientUserId, expirationDate: partialInput?.expirationDate, status: partialInput?.status, ...CRUDDates(partialInput), - } satisfies z.infer; + } satisfies z.infer; return insertOne( - insertUserTicketGiftSchema, - selectUserTicketGiftSchema, - userTicketGiftsSchema, + insertUserTicketTransferSchema, + selectUserTicketTransferSchema, + userTicketTransfersSchema, possibleInput, ); }; diff --git a/src/tests/fixtures/mocks.ts b/src/tests/fixtures/mocks.ts index afed4b60..46119523 100644 --- a/src/tests/fixtures/mocks.ts +++ b/src/tests/fixtures/mocks.ts @@ -23,6 +23,6 @@ export const MOCKED_RPC_SERVICE_EMAIL = { sendConfirmationWaitlistRejected: vitest.fn(), bulkSendEventTicketInvitations: vitest.fn(), bulkSendUserQRTicketEmail: vitest.fn(), - sendGiftAcceptanceNotificationToGifter: vitest.fn(), - sendGiftTicketConfirmations: vitest.fn(), + sendTransferAcceptanceNotificationToSender: vitest.fn(), + sendTransferTicketConfirmations: vitest.fn(), } satisfies MockedService; diff --git a/workers/transactional_email_service/index.tsx b/workers/transactional_email_service/index.tsx index 6e73a649..4883fed3 100644 --- a/workers/transactional_email_service/index.tsx +++ b/workers/transactional_email_service/index.tsx @@ -4,9 +4,9 @@ import * as React from "react"; import { Resend } from "resend"; import { JSConfCLTicketConfirmation } from "emails/templates/tickets/purchase-order-successful/jsconfcl"; -import { TicketGiftAcceptedByReceiver9punto5 } from "emails/templates/tickets/ticket-gift-accepted-by-receiver/9punto5"; -import { TicketGiftReceived9punto5 } from "emails/templates/tickets/ticket-gift-received/9punto5"; -import { TicketGiftSent9punto5 } from "emails/templates/tickets/ticket-gift-sent/9punto5"; +import { TicketTransferAcceptedByReceiver9punto5 } from "emails/templates/tickets/ticket-transfer-accepted-by-receiver/9punto5"; +import { TicketTransferReceived9punto5 } from "emails/templates/tickets/ticket-transfer-received/9punto5"; +import { TicketTransferSent9punto5 } from "emails/templates/tickets/ticket-transfer-sent/9punto5"; import { ResendEmailArgs, sendTransactionalHTMLEmail, @@ -420,42 +420,42 @@ export default class EmailService extends WorkerEntrypoint { await sendTransactionalHTMLEmail(this.resend, this.logger, resendArgs); } - async sendGiftTicketConfirmations({ - giftId, + async sendTransferTicketConfirmations({ + transferId, recipientName, recipientEmail, senderName, senderEmail, ticketTags, - giftMessage, + transferMessage, expirationDate, }: { - giftId: string; + transferId: string; recipientName: string; recipientEmail: string; senderName: string; senderEmail: string; ticketTags: string[]; - giftMessage: string | null; + transferMessage: string | null; expirationDate: Date; }) { - this.logger.info(`Sending gift ticket notifications`, { - giftId, + this.logger.info(`Sending transfer ticket notifications`, { + transferId, recipientEmail, senderEmail, }); const ticketType = get9unto5TicketType(ticketTags); - // Send email to gift recipient + // Send email to transfer recipient await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( - , ), @@ -467,14 +467,14 @@ export default class EmailService extends WorkerEntrypoint { } 9.5`, }); - // Send confirmation email to gift sender + // Send confirmation email to transfer sender await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( - , @@ -486,10 +486,12 @@ export default class EmailService extends WorkerEntrypoint { } 9.5 para ${recipientName} ha sido enviada`, }); - this.logger.info(`Gift ticket notifications sent successfully`, { giftId }); + this.logger.info(`Transfer ticket notifications sent successfully`, { + transferId, + }); } - async sendGiftAcceptanceNotificationToGifter({ + async sendTransferAcceptanceNotificationToSender({ recipientName, recipientEmail, senderName, @@ -500,7 +502,7 @@ export default class EmailService extends WorkerEntrypoint { senderName: string; ticketTags: string[]; }) { - this.logger.info(`About to send TicketGiftAcceptedByReceiver`, { + this.logger.info(`About to send TicketTransferAcceptedByReceiver`, { recipientName, recipientEmail, senderName, @@ -511,7 +513,7 @@ export default class EmailService extends WorkerEntrypoint { await sendTransactionalHTMLEmail(this.resend, this.logger, { htmlContent: render( - Date: Fri, 11 Oct 2024 12:30:35 -0300 Subject: [PATCH 16/19] chore: remove unused imports --- src/schema/userTickets/helpers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/schema/userTickets/helpers.ts b/src/schema/userTickets/helpers.ts index 6d49adde..ac64393f 100644 --- a/src/schema/userTickets/helpers.ts +++ b/src/schema/userTickets/helpers.ts @@ -1,15 +1,13 @@ -import { addDays, endOfDay } from "date-fns"; import { inArray } from "drizzle-orm"; import { ORM_TYPE } from "~/datasources/db"; import { TeamStatusEnum } from "~/datasources/db/teams"; -import { InsertUserSchema, USER, usersSchema } from "~/datasources/db/users"; +import { USER } from "~/datasources/db/users"; import { UserParticipationStatusEnum } from "~/datasources/db/userTeams"; import { approveUserTicketsSchema, userTicketsSchema, } from "~/datasources/db/userTickets"; -import { getUsername } from "~/datasources/queries/utils/createUsername"; import { applicationError, ServiceErrors } from "~/errors"; import { Logger } from "~/logging"; import { eventsFetcher } from "~/schema/events/eventsFetcher"; From 896752ed2e7b0c401ec8955c3742752e3e9e099b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 11 Oct 2024 12:54:20 -0300 Subject: [PATCH 17/19] feat(tickets): implement transfer-specific approval statuses - Add new approval statuses: transfer_pending and transfer_accepted - Update queries and mutations to include new transfer statuses - Adjust tests to reflect new transfer approval flow - Replace 'gifted' status with 'transfer_pending' where appropriate --- src/datasources/db/userTickets.ts | 4 ++++ src/schema/purchaseOrder/actions.tsx | 2 +- src/schema/ticket/types.ts | 4 +++- src/schema/user/types.ts | 9 ++++++++- src/schema/userTickets/mutations.ts | 4 +++- src/schema/userTickets/queries.ts | 9 ++++++++- src/schema/userTicketsTransfers/mutations.ts | 2 +- .../acceptTransferredTicket.test.ts | 4 ++-- .../transferMyTicketToUser.test.ts | 4 ++-- 9 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/datasources/db/userTickets.ts b/src/datasources/db/userTickets.ts index 312e0074..66095101 100644 --- a/src/datasources/db/userTickets.ts +++ b/src/datasources/db/userTickets.ts @@ -15,6 +15,10 @@ export const userTicketsApprovalStatusEnum = [ "not_required", "rejected", "cancelled", + // A transfer has been initiated but not yet accepted by the recipient + "transfer_pending", + // The transferred ticket has been accepted by the recipient + "transfer_accepted", ] as const; export const userTicketsRedemptionStatusEnum = ["redeemed", "pending"] as const; diff --git a/src/schema/purchaseOrder/actions.tsx b/src/schema/purchaseOrder/actions.tsx index 40ee9370..59f24fe5 100644 --- a/src/schema/purchaseOrder/actions.tsx +++ b/src/schema/purchaseOrder/actions.tsx @@ -721,7 +721,7 @@ export const syncPurchaseOrderPaymentStatus = async ({ if (userTicket.transferAttempts.length > 0) { await DB.update(userTicketsSchema) .set({ - approvalStatus: "gifted", + approvalStatus: "transfer_pending", }) .where(eq(userTicketsSchema.id, userTicket.id)); diff --git a/src/schema/ticket/types.ts b/src/schema/ticket/types.ts index 41ea77f9..bf752354 100644 --- a/src/schema/ticket/types.ts +++ b/src/schema/ticket/types.ts @@ -90,10 +90,12 @@ export const TicketLoadable = builder.loadableObject(TicketRef, { eq(ut.ticketTemplateId, root.id), inArray(ut.approvalStatus, [ "approved", - "gift_accepted", "not_required", "pending", "gifted", + "gift_accepted", + "transfer_accepted", + "transfer_pending", ]), ), }); diff --git a/src/schema/user/types.ts b/src/schema/user/types.ts index dd3c88d7..a1f402fa 100644 --- a/src/schema/user/types.ts +++ b/src/schema/user/types.ts @@ -132,7 +132,14 @@ export const UserLoadable = builder.loadableObject(UserRef, { userIds: [root.id], approvalStatus: ctx.USER?.isSuperAdmin ? undefined - : ["approved", "gifted", "gift_accepted", "not_required"], + : [ + "approved", + "gifted", + "gift_accepted", + "not_required", + "transfer_accepted", + "transfer_pending", + ], }, }); diff --git a/src/schema/userTickets/mutations.ts b/src/schema/userTickets/mutations.ts index b236f5d6..b0e98b9b 100644 --- a/src/schema/userTickets/mutations.ts +++ b/src/schema/userTickets/mutations.ts @@ -567,9 +567,11 @@ builder.mutationField("claimUserTicket", (t) => inArray(userTicketsSchema.approvalStatus, [ "approved", "pending", - "gifted", "not_required", + "gifted", "gift_accepted", + "transfer_pending", + "transfer_accepted", ]), ), ) diff --git a/src/schema/userTickets/queries.ts b/src/schema/userTickets/queries.ts index 08380a4a..3d9075bd 100644 --- a/src/schema/userTickets/queries.ts +++ b/src/schema/userTickets/queries.ts @@ -63,7 +63,14 @@ const getQueryApprovalStatus = ( const normalUserAllowedAppovalStatus = new Set< (typeof userTicketsApprovalStatusEnum)[number] ->(["approved", "not_required", "gifted", "gift_accepted"]); +>([ + "approved", + "not_required", + "gifted", + "gift_accepted", + "transfer_pending", + "transfer_accepted", +]); builder.queryFields((t) => ({ myTickets: t.field({ diff --git a/src/schema/userTicketsTransfers/mutations.ts b/src/schema/userTicketsTransfers/mutations.ts index 6d92091d..9d474064 100644 --- a/src/schema/userTicketsTransfers/mutations.ts +++ b/src/schema/userTicketsTransfers/mutations.ts @@ -107,7 +107,7 @@ builder.mutationField("transferMyTicketToUser", (t) => await trx .update(userTicketsSchema) .set({ - approvalStatus: "gifted", + approvalStatus: "transfer_pending", userId: recipientUser.id, }) .where(eq(userTicketsSchema.id, userTicket.id)); diff --git a/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts index dd333968..6bb1c844 100644 --- a/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts +++ b/src/schema/userTicketsTransfers/tests/acceptTransferredTicket/acceptTransferredTicket.test.ts @@ -22,7 +22,7 @@ import { import { getExpirationDateForTicketTransfer } from "../../helpers"; const prepareTickets = async ( - status: (typeof userTicketsApprovalStatusEnum)[number] = "gifted", + status: (typeof userTicketsApprovalStatusEnum)[number] = "transfer_pending", ) => { const community1 = await insertCommunity(); const event1 = await insertEvent(); @@ -48,7 +48,7 @@ const prepareTickets = async ( senderUserId: senderUser.id, recipientUserId: recipientUser.id, status: - status === "gifted" + status === "transfer_pending" ? UserTicketTransferStatus.Pending : UserTicketTransferStatus.Accepted, expirationDate: getExpirationDateForTicketTransfer(), diff --git a/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts index dcc7ead6..41314e58 100644 --- a/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts +++ b/src/schema/userTicketsTransfers/tests/transferMyTicketToUser/transferMyTicketToUser.test.ts @@ -136,7 +136,7 @@ describe("Transfer My Ticket To User", () => { where: (t, { eq }) => eq(t.id, userTicket.id), }); - assert.equal(updatedUserTicket?.approvalStatus, "gifted"); + assert.equal(updatedUserTicket?.approvalStatus, "transfer_pending"); assert.equal(updatedUserTicket?.userId, recipientUser.id); }); @@ -191,7 +191,7 @@ describe("Transfer My Ticket To User", () => { where: (t, { eq }) => eq(t.id, userTicket.id), }); - assert.equal(updatedUserTicket?.approvalStatus, "gifted"); + assert.equal(updatedUserTicket?.approvalStatus, "transfer_pending"); assert.equal(updatedUserTicket?.userId, newUser?.id); }); From 85bb8930a5c49b83df00aeaa089d72425f8385a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 11 Oct 2024 14:07:23 -0300 Subject: [PATCH 18/19] fix: ensure that user can only see transfers related to a ticket with a valid approvalStatus --- src/schema/userTicketsTransfers/queries.ts | 58 ++++++++++++++++------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/src/schema/userTicketsTransfers/queries.ts b/src/schema/userTicketsTransfers/queries.ts index c1555388..dc543c66 100644 --- a/src/schema/userTicketsTransfers/queries.ts +++ b/src/schema/userTicketsTransfers/queries.ts @@ -1,4 +1,8 @@ +import { and, eq, getTableColumns, inArray, or, SQL } from "drizzle-orm"; + import { builder } from "~/builder"; +import { userTicketsSchema } from "~/datasources/db/userTickets"; +import { userTicketTransfersSchema } from "~/datasources/db/userTicketsTransfers"; import { UserTicketTransferRef } from "~/schema/shared/refs"; const SearchTicketTransferTypeEnum = builder.enumType("TicketTransferType", { @@ -24,24 +28,46 @@ builder.queryFields((t) => ({ throw new Error("User not found"); } - const results = await DB.query.userTicketTransfersSchema.findMany({ - where: (utg, { eq, or }) => { - if (args.type === "ALL") { - return or( - eq(utg.senderUserId, USER.id), - eq(utg.recipientUserId, USER.id), - ); - } + let transferTypeWheres; - if (args.type === "SENT") { - return eq(utg.senderUserId, USER.id); - } + if (args.type === "SENT") { + transferTypeWheres = eq( + userTicketTransfersSchema.senderUserId, + USER.id, + ); + } else if (args.type === "RECEIVED") { + transferTypeWheres = eq( + userTicketTransfersSchema.recipientUserId, + USER.id, + ); + } else if (args.type === "ALL") { + transferTypeWheres = or( + eq(userTicketTransfersSchema.senderUserId, USER.id), + eq(userTicketTransfersSchema.recipientUserId, USER.id), + ); + } - if (args.type === "RECEIVED") { - return eq(utg.recipientUserId, USER.id); - } - }, - }); + const results = await DB.select({ + ...getTableColumns(userTicketTransfersSchema), + }) + .from(userTicketTransfersSchema) + .innerJoin( + userTicketsSchema, + and( + eq(userTicketTransfersSchema.userTicketId, userTicketsSchema.id), + // we ensure that the user cannot see tickets that where rejected + // or are pending of payment for example + inArray(userTicketsSchema.approvalStatus, [ + "approved", + "not_required", + "gifted", + "gift_accepted", + "transfer_pending", + "transfer_accepted", + ]), + transferTypeWheres, + ), + ); return results; }, From 3b98e2c774c92a007a393545845d8d0eb96ed010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Fri, 11 Oct 2024 14:11:19 -0300 Subject: [PATCH 19/19] chore: remove unused import --- src/schema/userTicketsTransfers/queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema/userTicketsTransfers/queries.ts b/src/schema/userTicketsTransfers/queries.ts index dc543c66..a8f380aa 100644 --- a/src/schema/userTicketsTransfers/queries.ts +++ b/src/schema/userTicketsTransfers/queries.ts @@ -1,4 +1,4 @@ -import { and, eq, getTableColumns, inArray, or, SQL } from "drizzle-orm"; +import { and, eq, getTableColumns, inArray, or } from "drizzle-orm"; import { builder } from "~/builder"; import { userTicketsSchema } from "~/datasources/db/userTickets";