From b55d4364b96e0a057a0f2ad60e012e614ffa5db6 Mon Sep 17 00:00:00 2001 From: Felipe Torres Date: Thu, 5 Dec 2024 15:30:49 -0300 Subject: [PATCH 1/4] Add case for multiple users --- src/schema/invitations/mutations.ts | 79 +++++++++++++++---- .../tests/giftTicketsToUsers.test.ts | 50 ++++++++++++ 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/src/schema/invitations/mutations.ts b/src/schema/invitations/mutations.ts index 8715b32f..311ca688 100644 --- a/src/schema/invitations/mutations.ts +++ b/src/schema/invitations/mutations.ts @@ -70,6 +70,8 @@ builder.mutationField("giftTicketsToUsers", (t) => }); const actualTicketIds = actualTickets.map((ticket) => ticket.id); + logger.info("actualTicketIds->", actualTicketIds); + const usersWithTickets = await DB.query.userTicketsSchema.findMany({ where: (u, { and, inArray }) => and( @@ -78,29 +80,27 @@ builder.mutationField("giftTicketsToUsers", (t) => ), }); - let ticketTemplatesUsersMap = new Map>(); - - for (const ticket of actualTickets) { - ticketTemplatesUsersMap.set(ticket.id, new Set()); - } + console.log("usersWithTickets->", usersWithTickets.toString()); usersWithTickets.forEach((userWithTicket) => { - if (userWithTicket.userId) { - ticketTemplatesUsersMap - .get(userWithTicket.ticketTemplateId) - ?.add(userWithTicket.userId); - } + logger.info("userWithTicket-->", JSON.stringify(userWithTicket)); }); - if (ticketTemplatesUsersMap.size === 0) { - throw applicationError( - "Ticket not found", - ServiceErrors.NOT_FOUND, - logger, - ); + let ticketTemplatesUsersMap = new Map>(); + + for (const ticket of actualTickets) { + ticketTemplatesUsersMap.set(ticket.id, new Set()); } if (!allowMultipleTicketsPerUsers) { + usersWithTickets.forEach((userWithTicket) => { + if (userWithTicket.userId) { + ticketTemplatesUsersMap + .get(userWithTicket.ticketTemplateId) + ?.add(userWithTicket.userId); + } + }); + const clearedTicketTemplatesUserMap = new Map>(); ticketTemplatesUsersMap.forEach((existingUserSet, ticketTemplateId) => { @@ -116,25 +116,60 @@ builder.mutationField("giftTicketsToUsers", (t) => }); ticketTemplatesUsersMap = clearedTicketTemplatesUserMap; + } else { + ticketTemplatesUsersMap.forEach((userSet, ticketTemplateId) => { + userIds.forEach((userId) => { + userSet.add(userId); + }); + }); } + logger.info( + "ticketTemplatesUsersMap->", + JSON.stringify(ticketTemplatesUsersMap), + ); + + if (ticketTemplatesUsersMap.size === 0) { + throw applicationError( + "Ticket not found", + ServiceErrors.NOT_FOUND, + logger, + ); + } + + logger.info("Ticket templates users map", ticketTemplatesUsersMap); + if (userIds.length === 0) { throw applicationError( - "All provided users already have tickets", + "All provided users already have tickets.", ServiceErrors.INVALID_ARGUMENT, logger, ); } + logger.info("About to create purchase order"); const purchaseOrder = await createInitialPurchaseOrder({ DB, logger, userId: USER.id, }); + logger.info("Purchase order created"); + const ticketsToInsert: (typeof insertUserTicketsSchema._type)[] = []; + logger.info("About to create tickets"); + + const jsonText = JSON.stringify( + Array.from(ticketTemplatesUsersMap.entries()), + ); + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + logger.info("------------" + jsonText); + ticketTemplatesUsersMap.forEach((userSet, ticketTemplateId) => { + logger.info("userSet->", JSON.stringify(userSet)); + userSet.forEach((userId) => { const parsedData = insertUserTicketsSchema.parse({ userId, @@ -143,10 +178,14 @@ builder.mutationField("giftTicketsToUsers", (t) => approvalStatus: autoApproveTickets ? "approved" : "gifted", }); + logger.info("parsedData", parsedData); + ticketsToInsert.push(parsedData); }); }); + logger.info("Tickets created"); + if (!ticketsToInsert.length) { throw applicationError( "All provided users already have tickets", @@ -155,10 +194,14 @@ builder.mutationField("giftTicketsToUsers", (t) => ); } + logger.info("About to insert tickets"); + const createdUserTickets = await DB.insert(userTicketsSchema) .values(ticketsToInsert) .returning(); + logger.info("Tickets inserted"); + if (notifyUsers) { const userTicketIds = createdUserTickets.map( (userTicket) => userTicket.id, @@ -181,6 +224,8 @@ builder.mutationField("giftTicketsToUsers", (t) => } } + logger.info("Emails sent"); + return createdUserTickets.map((userTicket) => selectUserTicketsSchema.parse(userTicket), ); diff --git a/src/schema/invitations/tests/giftTicketsToUsers.test.ts b/src/schema/invitations/tests/giftTicketsToUsers.test.ts index b3c7e176..73cdd1cc 100644 --- a/src/schema/invitations/tests/giftTicketsToUsers.test.ts +++ b/src/schema/invitations/tests/giftTicketsToUsers.test.ts @@ -87,6 +87,56 @@ describe("Should send tickets to users in bulk", () => { assert.equal(response.data?.giftTicketsToUsers.length, 1); }); + + it("should allow multiple tickets per user when allowMultipleTicketsPerUsers is true", async () => { + const user1 = await insertUser(); + const user2 = await insertUser(); + const event1 = await insertEvent(); + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + const ticket2 = await insertTicketTemplate({ + eventId: event1.id, + }); + + // First, gift a ticket to user1 and user2 + await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: false, + ticketIds: [ticket1.id], + userIds: [user1.id, user2.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + // Now, attempt to gift another ticket to the same users with allowMultipleTicketsPerUsers set to true + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket2.id], + userIds: [user1.id, user2.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftTicketsToUsers.length, 2); + }); }); describe("Should fail send tickets to users in bulk", () => { From 8162c1088530ee308bd0a94e884853347bb3aec8 Mon Sep 17 00:00:00 2001 From: Felipe Torres Date: Thu, 5 Dec 2024 15:35:11 -0300 Subject: [PATCH 2/4] more tests --- .../tests/giftTicketsToUsers.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/src/schema/invitations/tests/giftTicketsToUsers.test.ts b/src/schema/invitations/tests/giftTicketsToUsers.test.ts index 73cdd1cc..7898bfda 100644 --- a/src/schema/invitations/tests/giftTicketsToUsers.test.ts +++ b/src/schema/invitations/tests/giftTicketsToUsers.test.ts @@ -210,3 +210,206 @@ describe("Should fail send tickets to users in bulk", () => { ); }); }); + +describe("Multiple tickets per user scenarios", () => { + it("should allow gifting same ticket template multiple times when allowMultipleTicketsPerUsers is true", async () => { + const user1 = await insertUser(); + const event1 = await insertEvent(); + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + + // Gift first ticket + await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + // Gift second ticket of same template + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftTicketsToUsers.length, 1); + }); + + it("should allow gifting multiple different ticket templates simultaneously", async () => { + const user1 = await insertUser(); + const event1 = await insertEvent(); + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + const ticket2 = await insertTicketTemplate({ + eventId: event1.id, + }); + const ticket3 = await insertTicketTemplate({ + eventId: event1.id, + }); + + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id, ticket2.id, ticket3.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftTicketsToUsers.length, 3); + }); + + it("should handle mixed scenarios of users with and without existing tickets", async () => { + const user1 = await insertUser(); + const user2 = await insertUser(); + const user3 = await insertUser(); + const event1 = await insertEvent(); + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + + // First gift to user1 only + await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: false, + ticketIds: [ticket1.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + // Now gift to all users with allowMultipleTicketsPerUsers true + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id], + userIds: [user1.id, user2.id, user3.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftTicketsToUsers.length, 3); + }); + + it("should verify ticket approval status is set correctly", async () => { + const user1 = await insertUser(); + const event1 = await insertEvent(); + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: true, // Testing with autoApprove + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal( + response.data?.giftTicketsToUsers[0].approvalStatus, + "approved", + ); + }); + + it("should handle multiple tickets across different events", async () => { + const user1 = await insertUser(); + const event1 = await insertEvent(); + const event2 = await insertEvent(); + + const ticket1 = await insertTicketTemplate({ + eventId: event1.id, + }); + const ticket2 = await insertTicketTemplate({ + eventId: event2.id, + }); + + const response = await executeGraphqlOperationAsSuperAdmin< + GiftTicketsToUsersMutation, + GiftTicketsToUsersMutationVariables + >({ + document: GiftTicketsToUsers, + variables: { + input: { + allowMultipleTicketsPerUsers: true, + ticketIds: [ticket1.id, ticket2.id], + userIds: [user1.id], + notifyUsers: false, + autoApproveTickets: false, + }, + }, + }); + + assert.equal(response.errors, undefined); + + assert.equal(response.data?.giftTicketsToUsers.length, 2); + + // Verify tickets are for different events + const ticketEvents = new Set( + response.data?.giftTicketsToUsers.map( + (ticket) => ticket.ticketTemplate.event.id, + ), + ); + + assert.equal(ticketEvents.size, 2); + }); +}); From f102657ff594439d8501c331abbb1ae965d1d6ac Mon Sep 17 00:00:00 2001 From: Felipe Torres Date: Thu, 5 Dec 2024 15:44:46 -0300 Subject: [PATCH 3/4] comments --- src/schema/invitations/mutations.ts | 39 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/schema/invitations/mutations.ts b/src/schema/invitations/mutations.ts index 311ca688..236edb5d 100644 --- a/src/schema/invitations/mutations.ts +++ b/src/schema/invitations/mutations.ts @@ -15,13 +15,14 @@ import { createInitialPurchaseOrder } from "~/schema/purchaseOrder/helpers"; import { UserTicketRef } from "~/schema/shared/refs"; import { ticketsFetcher } from "~/schema/ticket/ticketsFetcher"; +// Input type definition for the giftTicketsToUsers mutation const GiftTicketsToUserInput = builder.inputType("GiftTicketsToUserInput", { fields: (t) => ({ - ticketIds: t.stringList({ required: true }), - userIds: t.stringList({ required: true }), - allowMultipleTicketsPerUsers: t.boolean({ required: true }), - autoApproveTickets: t.boolean({ required: true }), - notifyUsers: t.boolean({ required: true }), + ticketIds: t.stringList({ required: true }), // List of ticket template IDs to be gifted + userIds: t.stringList({ required: true }), // List of user IDs to receive tickets + allowMultipleTicketsPerUsers: t.boolean({ required: true }), // Whether users can receive duplicate tickets + autoApproveTickets: t.boolean({ required: true }), // Whether tickets should be auto-approved + notifyUsers: t.boolean({ required: true }), // Whether to send email notifications }), }); @@ -32,7 +33,7 @@ builder.mutationField("giftTicketsToUsers", (t) => type: [UserTicketRef], nullable: false, authz: { - rules: ["IsSuperAdmin"], + rules: ["IsSuperAdmin"], // Only super admins can execute this mutation }, args: { input: t.arg({ type: GiftTicketsToUserInput, required: true }), @@ -42,6 +43,7 @@ builder.mutationField("giftTicketsToUsers", (t) => { input }, { DB, logger, USER, RPC_SERVICE_EMAIL }, ) => { + // Verify user is authenticated if (!USER) { throw new GraphQLError("User not found"); } @@ -54,6 +56,7 @@ builder.mutationField("giftTicketsToUsers", (t) => userIds, } = input; + // Validate that users are provided if (userIds.length === 0) { throw applicationError( "No users provided", @@ -62,6 +65,7 @@ builder.mutationField("giftTicketsToUsers", (t) => ); } + // Fetch actual tickets from the database using provided IDs const actualTickets = await ticketsFetcher.searchTickets({ DB, search: { @@ -72,6 +76,7 @@ builder.mutationField("giftTicketsToUsers", (t) => logger.info("actualTicketIds->", actualTicketIds); + // Find existing tickets for these users to prevent duplicates if needed const usersWithTickets = await DB.query.userTicketsSchema.findMany({ where: (u, { and, inArray }) => and( @@ -80,19 +85,21 @@ builder.mutationField("giftTicketsToUsers", (t) => ), }); - console.log("usersWithTickets->", usersWithTickets.toString()); - + // Debug logging usersWithTickets.forEach((userWithTicket) => { logger.info("userWithTicket-->", JSON.stringify(userWithTicket)); }); + // Initialize map to track which users should receive which tickets let ticketTemplatesUsersMap = new Map>(); for (const ticket of actualTickets) { ticketTemplatesUsersMap.set(ticket.id, new Set()); } + // Handle user-ticket assignments based on allowMultipleTicketsPerUsers flag if (!allowMultipleTicketsPerUsers) { + // If multiple tickets aren't allowed, track existing tickets usersWithTickets.forEach((userWithTicket) => { if (userWithTicket.userId) { ticketTemplatesUsersMap @@ -101,6 +108,7 @@ builder.mutationField("giftTicketsToUsers", (t) => } }); + // Filter out users who already have tickets const clearedTicketTemplatesUserMap = new Map>(); ticketTemplatesUsersMap.forEach((existingUserSet, ticketTemplateId) => { @@ -117,6 +125,7 @@ builder.mutationField("giftTicketsToUsers", (t) => ticketTemplatesUsersMap = clearedTicketTemplatesUserMap; } else { + // If multiple tickets are allowed, assign all tickets to all users ticketTemplatesUsersMap.forEach((userSet, ticketTemplateId) => { userIds.forEach((userId) => { userSet.add(userId); @@ -124,11 +133,13 @@ builder.mutationField("giftTicketsToUsers", (t) => }); } + // Debug logging logger.info( "ticketTemplatesUsersMap->", JSON.stringify(ticketTemplatesUsersMap), ); + // Validate that we have tickets to process if (ticketTemplatesUsersMap.size === 0) { throw applicationError( "Ticket not found", @@ -147,6 +158,7 @@ builder.mutationField("giftTicketsToUsers", (t) => ); } + // Create purchase order for the tickets logger.info("About to create purchase order"); const purchaseOrder = await createInitialPurchaseOrder({ DB, @@ -156,17 +168,19 @@ builder.mutationField("giftTicketsToUsers", (t) => logger.info("Purchase order created"); + // Prepare tickets for insertion const ticketsToInsert: (typeof insertUserTicketsSchema._type)[] = []; logger.info("About to create tickets"); + // Debug logging const jsonText = JSON.stringify( Array.from(ticketTemplatesUsersMap.entries()), ); - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands logger.info("------------" + jsonText); + // Create ticket records for each user-ticket combination ticketTemplatesUsersMap.forEach((userSet, ticketTemplateId) => { logger.info("userSet->", JSON.stringify(userSet)); @@ -186,6 +200,7 @@ builder.mutationField("giftTicketsToUsers", (t) => logger.info("Tickets created"); + // Validate that we have tickets to insert if (!ticketsToInsert.length) { throw applicationError( "All provided users already have tickets", @@ -194,20 +209,22 @@ builder.mutationField("giftTicketsToUsers", (t) => ); } + // Insert tickets into database logger.info("About to insert tickets"); - const createdUserTickets = await DB.insert(userTicketsSchema) .values(ticketsToInsert) .returning(); logger.info("Tickets inserted"); + // Handle email notifications if enabled if (notifyUsers) { const userTicketIds = createdUserTickets.map( (userTicket) => userTicket.id, ); if (autoApproveTickets) { + // Send QR code emails for approved tickets await sendActualUserTicketQREmails({ DB, logger, @@ -215,6 +232,7 @@ builder.mutationField("giftTicketsToUsers", (t) => RPC_SERVICE_EMAIL, }); } else { + // Send invitation emails for gifted tickets await sendTicketInvitationEmails({ DB, logger, @@ -226,6 +244,7 @@ builder.mutationField("giftTicketsToUsers", (t) => logger.info("Emails sent"); + // Return created tickets return createdUserTickets.map((userTicket) => selectUserTicketsSchema.parse(userTicket), ); From 4434b3c367862361c2fac7ef619b4bee0bba06fd Mon Sep 17 00:00:00 2001 From: Felipe Torres Date: Thu, 5 Dec 2024 15:51:18 -0300 Subject: [PATCH 4/4] comments --- src/schema/purchaseOrder/helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schema/purchaseOrder/helpers.ts b/src/schema/purchaseOrder/helpers.ts index 9cf15593..e2867ea7 100644 --- a/src/schema/purchaseOrder/helpers.ts +++ b/src/schema/purchaseOrder/helpers.ts @@ -129,6 +129,7 @@ export const createInitialPurchaseOrder = async ({ .values( insertPurchaseOrdersSchema.parse({ userId, + purchaseOrderPaymentStatus: "not_required", }), ) .returning()