Skip to content

Commit 5177602

Browse files
committed
Add API route query purchases by email
1 parent 922d017 commit 5177602

File tree

7 files changed

+868
-22
lines changed

7 files changed

+868
-22
lines changed

src/api/functions/tickets.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb";
2+
import { unmarshall } from "@aws-sdk/util-dynamodb";
3+
import { TicketInfoEntry } from "api/routes/tickets.js";
4+
import { ValidLoggers } from "api/types.js";
5+
import { genericConfig } from "common/config.js";
6+
import { BaseError, DatabaseFetchError } from "common/errors/index.js";
7+
8+
export type GetUserPurchasesInputs = {
9+
dynamoClient: DynamoDBClient;
10+
email: string;
11+
logger: ValidLoggers;
12+
};
13+
14+
export type RawTicketEntry = {
15+
ticket_id: string;
16+
event_id: string;
17+
payment_method: string;
18+
purchase_time: string;
19+
ticketholder_netid: string; // Note this is actually email...
20+
used: boolean;
21+
};
22+
23+
export type RawMerchEntry = {
24+
stripe_pi: string;
25+
email: string;
26+
fulfilled: boolean;
27+
item_id: string;
28+
quantity: number;
29+
refunded: boolean;
30+
scanIsoTimestamp?: string;
31+
scannerEmail?: string;
32+
size: string;
33+
};
34+
35+
export async function getUserTicketingPurchases({
36+
dynamoClient,
37+
email,
38+
logger,
39+
}: GetUserPurchasesInputs) {
40+
const issuedTickets: TicketInfoEntry[] = [];
41+
const ticketCommand = new QueryCommand({
42+
TableName: genericConfig.TicketPurchasesTableName,
43+
IndexName: "UserIndex",
44+
KeyConditionExpression: "ticketholder_netid = :email",
45+
ExpressionAttributeValues: {
46+
":email": { S: email },
47+
},
48+
});
49+
let ticketResults;
50+
try {
51+
ticketResults = await dynamoClient.send(ticketCommand);
52+
if (!ticketResults || !ticketResults.Items) {
53+
throw new Error("No tickets result");
54+
}
55+
} catch (e) {
56+
if (e instanceof BaseError) {
57+
throw e;
58+
}
59+
logger.error(e);
60+
throw new DatabaseFetchError({
61+
message: "Failed to get information from ticketing system.",
62+
});
63+
}
64+
const ticketsResultsUnmarshalled = ticketResults.Items.map(
65+
(x) => unmarshall(x) as RawTicketEntry,
66+
);
67+
for (const item of ticketsResultsUnmarshalled) {
68+
issuedTickets.push({
69+
valid: true,
70+
type: "ticket",
71+
ticketId: item.ticket_id,
72+
purchaserData: {
73+
email: item.ticketholder_netid,
74+
productId: item.event_id,
75+
quantity: 1,
76+
},
77+
refunded: false,
78+
fulfilled: item.used,
79+
});
80+
}
81+
return issuedTickets;
82+
}
83+
84+
export async function getUserMerchPurchases({
85+
dynamoClient,
86+
email,
87+
logger,
88+
}: GetUserPurchasesInputs) {
89+
const issuedTickets: TicketInfoEntry[] = [];
90+
const merchCommand = new QueryCommand({
91+
TableName: genericConfig.MerchStorePurchasesTableName,
92+
IndexName: "UserIndex",
93+
KeyConditionExpression: "email = :email",
94+
ExpressionAttributeValues: {
95+
":email": { S: email },
96+
},
97+
});
98+
let ticketsResult;
99+
try {
100+
ticketsResult = await dynamoClient.send(merchCommand);
101+
if (!ticketsResult || !ticketsResult.Items) {
102+
throw new Error("No merch result");
103+
}
104+
} catch (e) {
105+
if (e instanceof BaseError) {
106+
throw e;
107+
}
108+
logger.error(e);
109+
throw new DatabaseFetchError({
110+
message: "Failed to get information from merch system.",
111+
});
112+
}
113+
const ticketsResultsUnmarshalled = ticketsResult.Items.map(
114+
(x) => unmarshall(x) as RawMerchEntry,
115+
);
116+
for (const item of ticketsResultsUnmarshalled) {
117+
issuedTickets.push({
118+
valid: true,
119+
type: "merch",
120+
ticketId: item.stripe_pi,
121+
purchaserData: {
122+
email: item.email,
123+
productId: item.item_id,
124+
quantity: 1,
125+
},
126+
refunded: item.refunded,
127+
fulfilled: item.fulfilled,
128+
});
129+
}
130+
return issuedTickets;
131+
}

src/api/routes/tickets.ts

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { Modules } from "common/modules.js";
2828
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
2929
import { withRoles, withTags } from "api/components/index.js";
3030
import { FULFILLED_PURCHASES_RETENTION_DAYS } from "common/constants.js";
31+
import {
32+
getUserMerchPurchases,
33+
getUserTicketingPurchases,
34+
} from "api/functions/tickets.js";
3135

3236
const postMerchSchema = z.object({
3337
type: z.literal("merch"),
@@ -56,18 +60,16 @@ const ticketEntryZod = z.object({
5660
purchaserData: purchaseSchema,
5761
});
5862

59-
const ticketInfoEntryZod = ticketEntryZod.extend({
60-
refunded: z.boolean(),
61-
fulfilled: z.boolean(),
62-
});
63-
64-
type TicketInfoEntry = z.infer<typeof ticketInfoEntryZod>;
65-
66-
const responseJsonSchema = ticketEntryZod;
63+
const ticketInfoEntryZod = ticketEntryZod
64+
.extend({
65+
refunded: z.boolean(),
66+
fulfilled: z.boolean(),
67+
})
68+
.meta({
69+
description: "An entry describing one merch or tickets transaction.",
70+
});
6771

68-
const getTicketsResponse = z.object({
69-
tickets: z.array(ticketInfoEntryZod),
70-
});
72+
export type TicketInfoEntry = z.infer<typeof ticketInfoEntryZod>;
7173

7274
const baseItemMetadata = z.object({
7375
itemId: z.string().min(1),
@@ -87,11 +89,6 @@ const ticketingItemMetadata = baseItemMetadata.extend({
8789
type ItemMetadata = z.infer<typeof baseItemMetadata>;
8890
type TicketItemMetadata = z.infer<typeof ticketingItemMetadata>;
8991

90-
const listMerchItemsResponse = z.object({
91-
merch: z.array(baseItemMetadata),
92-
tickets: z.array(ticketingItemMetadata),
93-
});
94-
9592
const postSchema = z.union([postMerchSchema, postTicketSchema]);
9693

9794
const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
@@ -106,6 +103,19 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
106103
[AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER],
107104
withTags(["Tickets/Merchandise"], {
108105
summary: "Retrieve metadata about tickets/merchandise items.",
106+
response: {
107+
200: {
108+
description: "The available items were retrieved.",
109+
content: {
110+
"application/json": {
111+
schema: z.object({
112+
merch: z.array(baseItemMetadata),
113+
tickets: z.array(ticketingItemMetadata),
114+
}),
115+
},
116+
},
117+
},
118+
},
109119
}),
110120
),
111121
onRequest: fastify.authorizeFromSchema,
@@ -198,7 +208,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
198208
},
199209
);
200210
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
201-
"/:eventId",
211+
"/event/:eventId",
202212
{
203213
schema: withRoles(
204214
[AppRoles.TICKETS_MANAGER],
@@ -210,6 +220,18 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
210220
params: z.object({
211221
eventId: z.string().min(1),
212222
}),
223+
response: {
224+
200: {
225+
description: "All issued tickets for this event were retrieved.",
226+
content: {
227+
"application/json": {
228+
schema: z.object({
229+
tickets: z.array(ticketInfoEntryZod),
230+
}),
231+
},
232+
},
233+
},
234+
},
213235
}),
214236
),
215237
onRequest: fastify.authorizeFromSchema,
@@ -231,7 +253,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
231253
const response = await UsEast1DynamoClient.send(command);
232254
if (!response.Items) {
233255
throw new NotFoundError({
234-
endpointName: `/api/v1/tickets/${eventId}`,
256+
endpointName: request.url,
235257
});
236258
}
237259
for (const item of response.Items) {
@@ -271,6 +293,16 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
271293
eventId: z.string().min(1),
272294
}),
273295
body: postMetadataSchema,
296+
response: {
297+
201: {
298+
description: "The item has been modified.",
299+
content: {
300+
"application/json": {
301+
schema: z.null(),
302+
},
303+
},
304+
},
305+
},
274306
}),
275307
),
276308
onRequest: fastify.authorizeFromSchema,
@@ -480,6 +512,60 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
480512
});
481513
},
482514
);
515+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
516+
"/purchases/:email",
517+
{
518+
schema: withRoles(
519+
[AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER],
520+
withTags(["Tickets/Merchandise"], {
521+
summary: "Get all purchases (merch and tickets) for a given user.",
522+
params: z.object({
523+
email: z.email(),
524+
}),
525+
response: {
526+
200: {
527+
description: "The user's purchases were retrieved.",
528+
content: {
529+
"application/json": {
530+
schema: z.object({
531+
merch: z.array(ticketInfoEntryZod),
532+
tickets: z.array(ticketInfoEntryZod),
533+
}),
534+
},
535+
},
536+
},
537+
},
538+
}),
539+
),
540+
onRequest: fastify.authorizeFromSchema,
541+
},
542+
async (request, reply) => {
543+
const userEmail = request.params.email;
544+
try {
545+
const [ticketsResult, merchResult] = await Promise.all([
546+
getUserTicketingPurchases({
547+
dynamoClient: UsEast1DynamoClient,
548+
email: userEmail,
549+
logger: request.log,
550+
}),
551+
getUserMerchPurchases({
552+
dynamoClient: UsEast1DynamoClient,
553+
email: userEmail,
554+
logger: request.log,
555+
}),
556+
]);
557+
await reply.send({ merch: merchResult, tickets: ticketsResult });
558+
} catch (e) {
559+
if (e instanceof BaseError) {
560+
throw e;
561+
}
562+
request.log.error(e);
563+
throw new DatabaseFetchError({
564+
message: "Failed to get user purchases.",
565+
});
566+
}
567+
},
568+
);
483569
};
484570

485571
export default ticketsPlugin;

src/ui/pages/tickets/SelectEventId.page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const SelectTicketsPage: React.FC = () => {
129129
itemSalesActive: newIsActive,
130130
type: isTicketItem(item) ? "ticket" : "merch",
131131
};
132-
await api.patch(`/api/v1/tickets/${item.itemId}`, data);
132+
await api.patch(`/api/v1/tickets/event/${item.itemId}`, data);
133133
await fetchItems();
134134
notifications.show({
135135
title: "Changes saved",

src/ui/pages/tickets/ViewTickets.page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ const ViewTicketsPage: React.FC = () => {
135135
const getTickets = async () => {
136136
try {
137137
setLoading(true);
138-
const response = await api.get(`/api/v1/tickets/${eventId}?type=merch`);
138+
const response = await api.get(
139+
`/api/v1/tickets/event/${eventId}?type=merch`,
140+
);
139141
const parsedResponse = ticketsResponseSchema.parse(response.data);
140142
let localQuantitySold = 0;
141143
for (const item of parsedResponse.tickets) {

tests/live/tickets.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,25 @@ describe("Tickets live API tests", async () => {
3030
expect(Array.isArray(responseBody["merch"])).toBe(true);
3131
expect(Array.isArray(responseBody["tickets"])).toBe(true);
3232
});
33+
test(
34+
"Test that getting user purchases succeeds",
35+
{ timeout: 10000 },
36+
async () => {
37+
const response = await fetch(
38+
`${baseEndpoint}/api/v1/tickets/purchases/[email protected]`,
39+
{
40+
method: "GET",
41+
headers: {
42+
Authorization: `Bearer ${token}`,
43+
},
44+
},
45+
);
46+
expect(response.status).toBe(200);
47+
const responseBody = await response.json();
48+
expect(responseBody).toHaveProperty("merch");
49+
expect(responseBody).toHaveProperty("tickets");
50+
expect(Array.isArray(responseBody["merch"])).toBe(true);
51+
expect(Array.isArray(responseBody["tickets"])).toBe(true);
52+
},
53+
);
3354
});

0 commit comments

Comments
 (0)