From 509e7aba78bd8eee535d3b56ae7bdb292c2fb701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Sun, 3 Nov 2024 01:48:32 -0300 Subject: [PATCH 1/2] feat: add cancelUserTicketAddons mutation --- src/generated/schema.gql | 10 + src/generated/types.ts | 6 + src/schema/userTicketsAddons/mutations.ts | 178 +++++- .../tests/cancelUserTicketAddons.generated.ts | 27 + .../tests/cancelUserTicketAddons.gql | 9 + .../tests/cancelUserTicketAddons.test.ts | 572 ++++++++++++++++++ 6 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 src/schema/userTicketsAddons/tests/cancelUserTicketAddons.generated.ts create mode 100644 src/schema/userTicketsAddons/tests/cancelUserTicketAddons.gql create mode 100644 src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts diff --git a/src/generated/schema.gql b/src/generated/schema.gql index e3fd8ec9..43e2824e 100644 --- a/src/generated/schema.gql +++ b/src/generated/schema.gql @@ -430,6 +430,16 @@ type Mutation { """ cancelUserTicket(userTicketId: String!): UserTicket! + """ + Cancel addons for multiple user tickets + """ + cancelUserTicketAddons( + """ + The IDs of the user ticket addons to cancel + """ + userTicketAddonIds: [String!]! + ): [UserTicketAddon!]! + """ Check the status of a purchase order """ diff --git a/src/generated/types.ts b/src/generated/types.ts index 4f0a9990..53d6a1c9 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -416,6 +416,8 @@ export type Mutation = { approvalUserTicket: UserTicket; /** Cancel a ticket */ cancelUserTicket: UserTicket; + /** Cancel addons for multiple user tickets */ + cancelUserTicketAddons: Array; /** Check the status of a purchase order */ checkPurchaseOrderStatus: PurchaseOrder; /** Attempt to claim and/or transfer tickets */ @@ -500,6 +502,10 @@ export type MutationCancelUserTicketArgs = { userTicketId: Scalars["String"]["input"]; }; +export type MutationCancelUserTicketAddonsArgs = { + userTicketAddonIds: Array; +}; + export type MutationCheckPurchaseOrderStatusArgs = { input: CheckForPurchaseOrderInput; }; diff --git a/src/schema/userTicketsAddons/mutations.ts b/src/schema/userTicketsAddons/mutations.ts index ab7d00db..5cf58f31 100644 --- a/src/schema/userTicketsAddons/mutations.ts +++ b/src/schema/userTicketsAddons/mutations.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { GraphQLError } from "graphql"; import { builder } from "~/builder"; @@ -6,6 +6,9 @@ import { userTicketAddonsSchema, selectPurchaseOrdersSchema, purchaseOrdersSchema, + userTicketsSchema, + UserTicketAddonApprovalStatus, + AddonConstraintType, } from "~/datasources/db/schema"; import { applicationError, ServiceErrors } from "~/errors"; import { handlePaymentLinkGeneration } from "~/schema/purchaseOrder/actions"; @@ -20,6 +23,7 @@ import { claimUserTicketAddonsHelpers, } from "./helpers"; import { PurchaseOrderRef } from "../purchaseOrder/types"; +import { UserTicketAddonRef } from "../ticketAddons/types"; const ClaimUserTicketAddonInput = builder .inputRef<{ @@ -256,3 +260,175 @@ function handleClaimError(error: unknown): RedeemUserTicketAddonsErrorType { errorMessage: "An unknown error occurred", }; } + +builder.mutationField("cancelUserTicketAddons", (t) => + t.field({ + description: "Cancel addons for multiple user tickets", + type: [UserTicketAddonRef], + args: { + userTicketAddonIds: t.arg({ + type: ["String"], + required: true, + description: "The IDs of the user ticket addons to cancel", + }), + }, + authz: { + rules: ["IsAuthenticated"], + }, + resolve: async (root, { userTicketAddonIds }, context) => { + const { USER, DB, logger } = context; + + if (!USER) { + throw new GraphQLError("User not found"); + } + + if (userTicketAddonIds.length === 0) { + throw applicationError( + "No user ticket addons provided", + ServiceErrors.INVALID_ARGUMENT, + logger, + ); + } + + return await DB.transaction(async (trx) => { + try { + const allUserTickets = await trx.query.userTicketsSchema.findMany({ + where: eq(userTicketsSchema.userId, USER.id), + columns: { + id: true, + }, + with: { + userTicketAddons: { + columns: { + id: true, + addonId: true, + approvalStatus: true, + }, + with: { + purchaseOrder: { + columns: { + id: true, + paymentPlatform: true, + }, + }, + addon: { + columns: { + id: true, + }, + with: { + constraints: { + columns: { + id: true, + constraintType: true, + relatedAddonId: true, + }, + }, + }, + }, + }, + }, + }, + }); + + // Fetch the user ticket addons and validate ownership + const userTicketAddonsToCancel = allUserTickets + .flatMap((ut) => ut.userTicketAddons) + .filter((uta) => userTicketAddonIds.includes(uta.id)); + + // Validate that all requested addons exist and belong to the user + const notFoundUserTicketAddonIds = userTicketAddonIds.filter( + (id) => !userTicketAddonsToCancel.some((uta) => uta.id === id), + ); + + if (notFoundUserTicketAddonIds.length > 0) { + throw applicationError( + `Some user ticket addons were not found or don't belong to the user: ${notFoundUserTicketAddonIds.join( + ", ", + )}`, + ServiceErrors.NOT_FOUND, + logger, + ); + } + + // Check for already cancelled addons + const alreadyCancelledAddons = userTicketAddonsToCancel.filter( + (uta) => + uta.approvalStatus === UserTicketAddonApprovalStatus.CANCELLED, + ); + + if (alreadyCancelledAddons.length > 0) { + throw applicationError( + `Some addons are already cancelled: ${alreadyCancelledAddons + .map((ada) => ada.id) + .join(", ")}`, + ServiceErrors.FAILED_PRECONDITION, + logger, + ); + } + + // Fetch all user ticket addons for the same tickets to check dependencies + const otherUserTicketAddons = allUserTickets + .flatMap((ut) => ut.userTicketAddons) + .filter((uta) => !userTicketAddonIds.includes(uta.id)); + + // TODO: Future improvements needed: + // 1. Handle paid addons cancellation - requires refund logic implementation + // 2. Consider implementing a cancellation window/policy + // 3. Consider support for partial refunds based on time until event + // 4. Consider option to cascade cancel dependent addons if the user confirms it + + // For now, we only allow addons with no dependencies + for (const addonToCancel of userTicketAddonsToCancel) { + const dependentAddons = otherUserTicketAddons.filter((uta) => { + // Check if any of the user's active addons depend on the addon being cancelled + return uta.addon.constraints.some( + (constraint) => + constraint.constraintType === + AddonConstraintType.DEPENDENCY && + constraint.relatedAddonId === addonToCancel.addonId, + ); + }); + + if (dependentAddons.length > 0) { + throw applicationError( + `NOT SUPPORTED: Cannot cancel addon ${ + addonToCancel.addonId + } because other addons depend on it: ${dependentAddons + .map((da) => da.addonId) + .join(", ")}`, + ServiceErrors.CONFLICT, + logger, + ); + } + } + + // For now, we only allow canceling free addons + for (const addon of userTicketAddonsToCancel) { + if (addon.purchaseOrder.paymentPlatform !== null) { + throw applicationError( + "NOT SUPPORTED: Cancellation of paid addons is not supported yet", + ServiceErrors.FAILED_PRECONDITION, + logger, + ); + } + } + + // Cancel the addons by updating their status + const cancelledAddons = await trx + .update(userTicketAddonsSchema) + .set({ + approvalStatus: UserTicketAddonApprovalStatus.CANCELLED, + updatedAt: new Date(), + }) + .where(inArray(userTicketAddonsSchema.id, userTicketAddonIds)) + .returning(); + + return cancelledAddons; + } catch (e: unknown) { + logger.error("Error cancelling user ticket addons", e); + throw e; + } + }); + }, + }), +); diff --git a/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.generated.ts b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.generated.ts new file mode 100644 index 00000000..27998033 --- /dev/null +++ b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.generated.ts @@ -0,0 +1,27 @@ +/* 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 CancelUserTicketAddonsMutationVariables = Types.Exact<{ + userTicketAddonIds: Array | Types.Scalars['String']['input']; +}>; + + +export type CancelUserTicketAddonsMutation = { __typename?: 'Mutation', cancelUserTicketAddons: Array<{ __typename?: 'UserTicketAddon', id: string, approvalStatus: Types.UserTicketAddonApprovalStatus, quantity: number, addonId: string, userTicketId: string }> }; + + +export const CancelUserTicketAddons = gql` + mutation cancelUserTicketAddons($userTicketAddonIds: [String!]!) { + cancelUserTicketAddons(userTicketAddonIds: $userTicketAddonIds) { + id + approvalStatus + quantity + addonId + userTicketId + } +} + `; \ No newline at end of file diff --git a/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.gql b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.gql new file mode 100644 index 00000000..273afb7c --- /dev/null +++ b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.gql @@ -0,0 +1,9 @@ +mutation CancelUserTicketAddons($userTicketAddonIds: [String!]!) { + cancelUserTicketAddons(userTicketAddonIds: $userTicketAddonIds) { + id + approvalStatus + quantity + addonId + userTicketId + } +} diff --git a/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts new file mode 100644 index 00000000..f1a947af --- /dev/null +++ b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts @@ -0,0 +1,572 @@ +import { assert, beforeEach, describe, it } from "vitest"; + +import { AddonConstraintType } from "~/datasources/db/ticketAddons"; +import { UserTicketAddonApprovalStatus } from "~/datasources/db/userTicketsAddons"; +import { UserTicketAddonApprovalStatus as GraphQLUserTicketAddonApprovalStatus } from "~/generated/types"; +import { + executeGraphqlOperationAsUser, + insertAddon, + insertAddonConstraint, + insertCommunity, + insertEvent, + insertEventToCommunity, + insertTicket, + insertTicketAddon, + insertTicketTemplate, + insertUser, + insertUserTicketAddon, + insertPurchaseOrder, +} from "~/tests/fixtures"; + +import { + CancelUserTicketAddons, + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables, +} from "./cancelUserTicketAddons.generated"; + +describe("cancelUserTicketAddons mutation", () => { + beforeEach(() => { + // Reset any mocks if needed + }); + + it("should successfully cancel a free addon", async () => { + // Setup + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + isFree: true, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const addon = await insertAddon({ + name: "Free Addon", + description: "Free Addon Description", + totalStock: 100, + maxPerTicket: 2, + isUnlimited: false, + eventId: event.id, + isFree: true, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon.id, + orderDisplay: 1, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const userTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [userTicketAddon.id], + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + const cancelledAddons = response.data?.cancelUserTicketAddons; + + assert.equal(cancelledAddons?.length, 1); + + assert.equal(cancelledAddons?.[0].id, userTicketAddon.id); + + assert.equal( + cancelledAddons?.[0].approvalStatus, + GraphQLUserTicketAddonApprovalStatus.Cancelled, + ); + }); + + it("should fail when trying to cancel non-existent addon", async () => { + const user = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: ["non-existent-id"], + }, + }, + user, + ); + + assert.exists(response.errors); + + assert.include( + response.errors?.[0].message, + "Some user ticket addons were not found or don't belong to the user", + ); + }); + + it("should fail when trying to cancel addon that belongs to another user", async () => { + const community = await insertCommunity(); + const event = await insertEvent(); + const user1 = await insertUser(); + const user2 = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + }); + + const userTicket = await insertTicket({ + userId: user1.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const addon = await insertAddon({ + name: "Free Addon", + eventId: event.id, + isFree: true, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon.id, + orderDisplay: 1, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user1.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const userTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [userTicketAddon.id], + }, + }, + user2, + ); + + assert.exists(response.errors); + + assert.include( + response.errors?.[0].message, + "Some user ticket addons were not found or don't belong to the user", + ); + }); + + it("should fail when trying to cancel an addon that other addons depend on", async () => { + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const baseAddon = await insertAddon({ + name: "Base Addon", + eventId: event.id, + isFree: true, + }); + + const dependentAddon = await insertAddon({ + name: "Dependent Addon", + eventId: event.id, + isFree: true, + }); + + // Set up dependency constraint + await insertAddonConstraint({ + addonId: dependentAddon.id, + relatedAddonId: baseAddon.id, + constraintType: AddonConstraintType.DEPENDENCY, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: baseAddon.id, + orderDisplay: 1, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: dependentAddon.id, + orderDisplay: 2, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const baseUserTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: baseAddon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: dependentAddon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [baseUserTicketAddon.id], + }, + }, + user, + ); + + assert.exists(response.errors); + + assert.include(response.errors?.[0].message, "Cannot cancel addon"); + + assert.include( + response.errors?.[0].message, + "because other addons depend on it", + ); + }); + + it("should fail when trying to cancel a paid addon", async () => { + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const addon = await insertAddon({ + name: "Paid Addon", + eventId: event.id, + isFree: false, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon.id, + orderDisplay: 1, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "1000", + purchaseOrderPaymentStatus: "paid", + paymentPlatform: "stripe", + }); + + const userTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 1000, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [userTicketAddon.id], + }, + }, + user, + ); + + assert.exists(response.errors); + + assert.include( + response.errors?.[0].message, + "Cancellation of paid addons is not supported yet", + ); + }); + + it("should fail when no addon IDs are provided", async () => { + const user = await insertUser(); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [], + }, + }, + user, + ); + + assert.exists(response.errors); + + assert.include( + response.errors?.[0].message, + "No user ticket addons provided", + ); + }); + + it("should successfully cancel multiple free addons", async () => { + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const addon1 = await insertAddon({ + name: "Free Addon 1", + eventId: event.id, + isFree: true, + }); + + const addon2 = await insertAddon({ + name: "Free Addon 2", + eventId: event.id, + isFree: true, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon1.id, + orderDisplay: 1, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon2.id, + orderDisplay: 2, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const userTicketAddon1 = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon1.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + const userTicketAddon2 = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon2.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [userTicketAddon1.id, userTicketAddon2.id], + }, + }, + user, + ); + + assert.equal(response.errors, undefined); + + const cancelledAddons = response.data?.cancelUserTicketAddons; + + assert.equal(cancelledAddons?.length, 2); + + assert.equal(cancelledAddons?.[0].id, userTicketAddon1.id); + + assert.equal( + cancelledAddons?.[0].approvalStatus, + GraphQLUserTicketAddonApprovalStatus.Cancelled, + ); + + assert.equal(cancelledAddons?.[1].id, userTicketAddon2.id); + + assert.equal( + cancelledAddons?.[1].approvalStatus, + GraphQLUserTicketAddonApprovalStatus.Cancelled, + ); + }); + + it("should fail when trying to cancel already cancelled addons", async () => { + // Setup + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + isFree: true, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const addon = await insertAddon({ + name: "Free Addon", + eventId: event.id, + isFree: true, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: addon.id, + orderDisplay: 1, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const userTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: addon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + approvalStatus: UserTicketAddonApprovalStatus.CANCELLED, + }); + + const response = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [userTicketAddon.id], + }, + }, + user, + ); + + assert.exists(response.errors); + + assert.include( + response.errors?.[0].message, + "Some addons are already cancelled", + ); + }); +}); From 48d5fd9db0bf29e181da7f5a69483de814695f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Guzm=C3=A1n?= Date: Sun, 3 Nov 2024 01:59:30 -0300 Subject: [PATCH 2/2] fix: don't consider cancelled addons on cancel dependencies check --- src/schema/userTicketsAddons/mutations.ts | 16 +- .../tests/cancelUserTicketAddons.test.ts | 156 ++++++++++++++++++ 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/schema/userTicketsAddons/mutations.ts b/src/schema/userTicketsAddons/mutations.ts index 5cf58f31..06594133 100644 --- a/src/schema/userTicketsAddons/mutations.ts +++ b/src/schema/userTicketsAddons/mutations.ts @@ -6,7 +6,6 @@ import { userTicketAddonsSchema, selectPurchaseOrdersSchema, purchaseOrdersSchema, - userTicketsSchema, UserTicketAddonApprovalStatus, AddonConstraintType, } from "~/datasources/db/schema"; @@ -263,7 +262,7 @@ function handleClaimError(error: unknown): RedeemUserTicketAddonsErrorType { builder.mutationField("cancelUserTicketAddons", (t) => t.field({ - description: "Cancel addons for multiple user tickets", + description: "Cancel multiple user ticket addons", type: [UserTicketAddonRef], args: { userTicketAddonIds: t.arg({ @@ -293,7 +292,9 @@ builder.mutationField("cancelUserTicketAddons", (t) => return await DB.transaction(async (trx) => { try { const allUserTickets = await trx.query.userTicketsSchema.findMany({ - where: eq(userTicketsSchema.userId, USER.id), + where: (etc, ops) => { + return ops.eq(etc.userId, USER.id); + }, columns: { id: true, }, @@ -366,10 +367,15 @@ builder.mutationField("cancelUserTicketAddons", (t) => ); } - // Fetch all user ticket addons for the same tickets to check dependencies + // Fetch all user ticket addons to check dependencies + // We only consider approved addons to check dependencies const otherUserTicketAddons = allUserTickets .flatMap((ut) => ut.userTicketAddons) - .filter((uta) => !userTicketAddonIds.includes(uta.id)); + .filter( + (uta) => + !userTicketAddonIds.includes(uta.id) && + uta.approvalStatus === UserTicketAddonApprovalStatus.APPROVED, + ); // TODO: Future improvements needed: // 1. Handle paid addons cancellation - requires refund logic implementation diff --git a/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts index f1a947af..30edce81 100644 --- a/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts +++ b/src/schema/userTicketsAddons/tests/cancelUserTicketAddons.test.ts @@ -269,6 +269,7 @@ describe("cancelUserTicketAddons mutation", () => { quantity: 1, purchaseOrderId: purchaseOrder.id, unitPriceInCents: 0, + approvalStatus: UserTicketAddonApprovalStatus.APPROVED, }); await insertUserTicketAddon({ @@ -277,6 +278,7 @@ describe("cancelUserTicketAddons mutation", () => { quantity: 1, purchaseOrderId: purchaseOrder.id, unitPriceInCents: 0, + approvalStatus: UserTicketAddonApprovalStatus.APPROVED, }); const response = await executeGraphqlOperationAsUser< @@ -569,4 +571,158 @@ describe("cancelUserTicketAddons mutation", () => { "Some addons are already cancelled", ); }); + + it("should allow cancelling base addon after dependent addon is cancelled", async () => { + const community = await insertCommunity(); + const event = await insertEvent(); + const user = await insertUser(); + + await insertEventToCommunity({ + eventId: event.id, + communityId: community.id, + }); + + const ticket = await insertTicketTemplate({ + eventId: event.id, + quantity: 100, + }); + + const userTicket = await insertTicket({ + userId: user.id, + ticketTemplateId: ticket.id, + approvalStatus: "approved", + }); + + const baseAddon = await insertAddon({ + name: "Base Addon", + eventId: event.id, + isFree: true, + }); + + const dependentAddon = await insertAddon({ + name: "Dependent Addon", + eventId: event.id, + isFree: true, + }); + + // Set up dependency constraint + await insertAddonConstraint({ + addonId: dependentAddon.id, + relatedAddonId: baseAddon.id, + constraintType: AddonConstraintType.DEPENDENCY, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: baseAddon.id, + orderDisplay: 1, + }); + + await insertTicketAddon({ + ticketId: ticket.id, + addonId: dependentAddon.id, + orderDisplay: 2, + }); + + const purchaseOrder = await insertPurchaseOrder({ + userId: user.id, + status: "complete", + totalPrice: "0", + purchaseOrderPaymentStatus: "not_required", + }); + + const baseUserTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: baseAddon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + approvalStatus: UserTicketAddonApprovalStatus.APPROVED, + }); + + const dependentUserTicketAddon = await insertUserTicketAddon({ + userTicketId: userTicket.id, + addonId: dependentAddon.id, + quantity: 1, + purchaseOrderId: purchaseOrder.id, + unitPriceInCents: 0, + approvalStatus: UserTicketAddonApprovalStatus.APPROVED, + }); + + // First attempt: Try to cancel base addon while dependent is active + const firstResponse = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [baseUserTicketAddon.id], + }, + }, + user, + ); + + assert.exists(firstResponse.errors); + + assert.include(firstResponse.errors?.[0].message, "Cannot cancel addon"); + + assert.include( + firstResponse.errors?.[0].message, + "because other addons depend on it", + ); + + // Second attempt: Cancel the dependent addon + const secondResponse = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [dependentUserTicketAddon.id], + }, + }, + user, + ); + + assert.equal(secondResponse.errors, undefined); + const cancelledDependentAddons = + secondResponse.data?.cancelUserTicketAddons; + + assert.equal(cancelledDependentAddons?.length, 1); + + assert.equal(cancelledDependentAddons?.[0].id, dependentUserTicketAddon.id); + + assert.equal( + cancelledDependentAddons?.[0].approvalStatus, + GraphQLUserTicketAddonApprovalStatus.Cancelled, + ); + + // Third attempt: Now try to cancel base addon + const thirdResponse = await executeGraphqlOperationAsUser< + CancelUserTicketAddonsMutation, + CancelUserTicketAddonsMutationVariables + >( + { + document: CancelUserTicketAddons, + variables: { + userTicketAddonIds: [baseUserTicketAddon.id], + }, + }, + user, + ); + + assert.equal(thirdResponse.errors, undefined); + const cancelledBaseAddons = thirdResponse.data?.cancelUserTicketAddons; + + assert.equal(cancelledBaseAddons?.length, 1); + + assert.equal(cancelledBaseAddons?.[0].id, baseUserTicketAddon.id); + + assert.equal( + cancelledBaseAddons?.[0].approvalStatus, + GraphQLUserTicketAddonApprovalStatus.Cancelled, + ); + }); });