diff --git a/src/schema/invitations/mutations.ts b/src/schema/invitations/mutations.ts index 8715b32f..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: { @@ -70,6 +74,9 @@ builder.mutationField("giftTicketsToUsers", (t) => }); const actualTicketIds = actualTickets.map((ticket) => ticket.id); + 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( @@ -78,29 +85,30 @@ builder.mutationField("giftTicketsToUsers", (t) => ), }); + // 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()); } - usersWithTickets.forEach((userWithTicket) => { - if (userWithTicket.userId) { - ticketTemplatesUsersMap - .get(userWithTicket.ticketTemplateId) - ?.add(userWithTicket.userId); - } - }); - - if (ticketTemplatesUsersMap.size === 0) { - throw applicationError( - "Ticket not found", - ServiceErrors.NOT_FOUND, - logger, - ); - } - + // 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 + .get(userWithTicket.ticketTemplateId) + ?.add(userWithTicket.userId); + } + }); + + // Filter out users who already have tickets const clearedTicketTemplatesUserMap = new Map>(); ticketTemplatesUsersMap.forEach((existingUserSet, ticketTemplateId) => { @@ -116,25 +124,66 @@ 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); + }); + }); } + // Debug logging + logger.info( + "ticketTemplatesUsersMap->", + JSON.stringify(ticketTemplatesUsersMap), + ); + + // Validate that we have tickets to process + 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, ); } + // Create purchase order for the tickets + logger.info("About to create purchase order"); const purchaseOrder = await createInitialPurchaseOrder({ DB, logger, userId: USER.id, }); + 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()), + ); + + logger.info("------------" + jsonText); + + // Create ticket records for each user-ticket combination ticketTemplatesUsersMap.forEach((userSet, ticketTemplateId) => { + logger.info("userSet->", JSON.stringify(userSet)); + userSet.forEach((userId) => { const parsedData = insertUserTicketsSchema.parse({ userId, @@ -143,10 +192,15 @@ builder.mutationField("giftTicketsToUsers", (t) => approvalStatus: autoApproveTickets ? "approved" : "gifted", }); + logger.info("parsedData", parsedData); + ticketsToInsert.push(parsedData); }); }); + logger.info("Tickets created"); + + // Validate that we have tickets to insert if (!ticketsToInsert.length) { throw applicationError( "All provided users already have tickets", @@ -155,16 +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, @@ -172,6 +232,7 @@ builder.mutationField("giftTicketsToUsers", (t) => RPC_SERVICE_EMAIL, }); } else { + // Send invitation emails for gifted tickets await sendTicketInvitationEmails({ DB, logger, @@ -181,6 +242,9 @@ builder.mutationField("giftTicketsToUsers", (t) => } } + logger.info("Emails sent"); + + // Return created tickets 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..7898bfda 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", () => { @@ -160,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); + }); +}); 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()