diff --git a/src/api/functions/tickets.ts b/src/api/functions/tickets.ts new file mode 100644 index 00000000..cab26de8 --- /dev/null +++ b/src/api/functions/tickets.ts @@ -0,0 +1,131 @@ +import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { TicketInfoEntry } from "api/routes/tickets.js"; +import { ValidLoggers } from "api/types.js"; +import { genericConfig } from "common/config.js"; +import { BaseError, DatabaseFetchError } from "common/errors/index.js"; + +export type GetUserPurchasesInputs = { + dynamoClient: DynamoDBClient; + email: string; + logger: ValidLoggers; +}; + +export type RawTicketEntry = { + ticket_id: string; + event_id: string; + payment_method: string; + purchase_time: string; + ticketholder_netid: string; // Note this is actually email... + used: boolean; +}; + +export type RawMerchEntry = { + stripe_pi: string; + email: string; + fulfilled: boolean; + item_id: string; + quantity: number; + refunded: boolean; + scanIsoTimestamp?: string; + scannerEmail?: string; + size: string; +}; + +export async function getUserTicketingPurchases({ + dynamoClient, + email, + logger, +}: GetUserPurchasesInputs) { + const issuedTickets: TicketInfoEntry[] = []; + const ticketCommand = new QueryCommand({ + TableName: genericConfig.TicketPurchasesTableName, + IndexName: "UserIndex", + KeyConditionExpression: "ticketholder_netid = :email", + ExpressionAttributeValues: { + ":email": { S: email }, + }, + }); + let ticketResults; + try { + ticketResults = await dynamoClient.send(ticketCommand); + if (!ticketResults || !ticketResults.Items) { + throw new Error("No tickets result"); + } + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get information from ticketing system.", + }); + } + const ticketsResultsUnmarshalled = ticketResults.Items.map( + (x) => unmarshall(x) as RawTicketEntry, + ); + for (const item of ticketsResultsUnmarshalled) { + issuedTickets.push({ + valid: !item.used, + type: "ticket", + ticketId: item.ticket_id, + purchaserData: { + email: item.ticketholder_netid, + productId: item.event_id, + quantity: 1, + }, + refunded: false, + fulfilled: item.used, + }); + } + return issuedTickets; +} + +export async function getUserMerchPurchases({ + dynamoClient, + email, + logger, +}: GetUserPurchasesInputs) { + const issuedTickets: TicketInfoEntry[] = []; + const merchCommand = new QueryCommand({ + TableName: genericConfig.MerchStorePurchasesTableName, + IndexName: "UserIndex", + KeyConditionExpression: "email = :email", + ExpressionAttributeValues: { + ":email": { S: email }, + }, + }); + let ticketsResult; + try { + ticketsResult = await dynamoClient.send(merchCommand); + if (!ticketsResult || !ticketsResult.Items) { + throw new Error("No merch result"); + } + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get information from merch system.", + }); + } + const ticketsResultsUnmarshalled = ticketsResult.Items.map( + (x) => unmarshall(x) as RawMerchEntry, + ); + for (const item of ticketsResultsUnmarshalled) { + issuedTickets.push({ + valid: !item.refunded && !item.fulfilled, + type: "merch", + ticketId: item.stripe_pi, + purchaserData: { + email: item.email, + productId: item.item_id, + quantity: item.quantity, + }, + refunded: item.refunded, + fulfilled: item.fulfilled, + }); + } + return issuedTickets; +} diff --git a/src/api/index.ts b/src/api/index.ts index 458c8cdb..9fb1d6a3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -61,6 +61,7 @@ import membershipV2Plugin from "./routes/v2/membership.js"; import { docsHtml, securitySchemes } from "./docs.js"; import syncIdentityPlugin from "./routes/syncIdentity.js"; import { createRedisModule } from "./redis.js"; +import userRoute from "./routes/user.js"; /** END ROUTES */ export const instanceId = randomUUID(); @@ -373,6 +374,7 @@ Otherwise, email [infra@acm.illinois.edu](mailto:infra@acm.illinois.edu) for sup api.register(logsPlugin, { prefix: "/logs" }); api.register(apiKeyRoute, { prefix: "/apiKey" }); api.register(clearSessionRoute, { prefix: "/clearSession" }); + api.register(userRoute, { prefix: "/users" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index 7b418e2d..a8bf4cbb 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -28,6 +28,10 @@ import { Modules } from "common/modules.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { withRoles, withTags } from "api/components/index.js"; import { FULFILLED_PURCHASES_RETENTION_DAYS } from "common/constants.js"; +import { + getUserMerchPurchases, + getUserTicketingPurchases, +} from "api/functions/tickets.js"; const postMerchSchema = z.object({ type: z.literal("merch"), @@ -56,18 +60,16 @@ const ticketEntryZod = z.object({ purchaserData: purchaseSchema, }); -const ticketInfoEntryZod = ticketEntryZod.extend({ - refunded: z.boolean(), - fulfilled: z.boolean(), -}); - -type TicketInfoEntry = z.infer; - -const responseJsonSchema = ticketEntryZod; +const ticketInfoEntryZod = ticketEntryZod + .extend({ + refunded: z.boolean(), + fulfilled: z.boolean(), + }) + .meta({ + description: "An entry describing one merch or tickets transaction.", + }); -const getTicketsResponse = z.object({ - tickets: z.array(ticketInfoEntryZod), -}); +export type TicketInfoEntry = z.infer; const baseItemMetadata = z.object({ itemId: z.string().min(1), @@ -87,11 +89,6 @@ const ticketingItemMetadata = baseItemMetadata.extend({ type ItemMetadata = z.infer; type TicketItemMetadata = z.infer; -const listMerchItemsResponse = z.object({ - merch: z.array(baseItemMetadata), - tickets: z.array(ticketingItemMetadata), -}); - const postSchema = z.union([postMerchSchema, postTicketSchema]); const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { @@ -106,6 +103,19 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { [AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER], withTags(["Tickets/Merchandise"], { summary: "Retrieve metadata about tickets/merchandise items.", + response: { + 200: { + description: "The available items were retrieved.", + content: { + "application/json": { + schema: z.object({ + merch: z.array(baseItemMetadata), + tickets: z.array(ticketingItemMetadata), + }), + }, + }, + }, + }, }), ), onRequest: fastify.authorizeFromSchema, @@ -198,7 +208,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }, ); fastify.withTypeProvider().get( - "/:eventId", + "/event/:eventId", { schema: withRoles( [AppRoles.TICKETS_MANAGER], @@ -210,6 +220,18 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { params: z.object({ eventId: z.string().min(1), }), + response: { + 200: { + description: "All issued tickets for this event were retrieved.", + content: { + "application/json": { + schema: z.object({ + tickets: z.array(ticketInfoEntryZod), + }), + }, + }, + }, + }, }), ), onRequest: fastify.authorizeFromSchema, @@ -231,7 +253,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { const response = await UsEast1DynamoClient.send(command); if (!response.Items) { throw new NotFoundError({ - endpointName: `/api/v1/tickets/${eventId}`, + endpointName: request.url, }); } for (const item of response.Items) { @@ -271,6 +293,16 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { eventId: z.string().min(1), }), body: postMetadataSchema, + response: { + 201: { + description: "The item has been modified.", + content: { + "application/json": { + schema: z.null(), + }, + }, + }, + }, }), ), onRequest: fastify.authorizeFromSchema, @@ -480,6 +512,60 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); }, ); + fastify.withTypeProvider().get( + "/purchases/:email", + { + schema: withRoles( + [AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER], + withTags(["Tickets/Merchandise"], { + summary: "Get all purchases (merch and tickets) for a given user.", + params: z.object({ + email: z.email(), + }), + response: { + 200: { + description: "The user's purchases were retrieved.", + content: { + "application/json": { + schema: z.object({ + merch: z.array(ticketInfoEntryZod), + tickets: z.array(ticketInfoEntryZod), + }), + }, + }, + }, + }, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + const userEmail = request.params.email; + try { + const [ticketsResult, merchResult] = await Promise.all([ + getUserTicketingPurchases({ + dynamoClient: UsEast1DynamoClient, + email: userEmail, + logger: request.log, + }), + getUserMerchPurchases({ + dynamoClient: UsEast1DynamoClient, + email: userEmail, + logger: request.log, + }), + ]); + await reply.send({ merch: merchResult, tickets: ticketsResult }); + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + request.log.error(e); + throw new DatabaseFetchError({ + message: "Failed to get user purchases.", + }); + } + }, + ); }; export default ticketsPlugin; diff --git a/src/api/routes/user.ts b/src/api/routes/user.ts new file mode 100644 index 00000000..98cd7fcc --- /dev/null +++ b/src/api/routes/user.ts @@ -0,0 +1,89 @@ +import { FastifyPluginAsync } from "fastify"; +import rateLimiter from "api/plugins/rateLimiter.js"; +import { withRoles, withTags } from "api/components/index.js"; +import { getUserOrgRoles } from "api/functions/organizations.js"; +import { + DatabaseFetchError, + UnauthenticatedError, + ValidationError, +} from "common/errors/index.js"; +import * as z from "zod/v4"; +import { + searchUserByUinRequest, + searchUserByUinResponse, +} from "common/types/user.js"; +import { getUinHash } from "api/functions/uin.js"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import { QueryCommand } from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "common/config.js"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; + +const userRoute: FastifyPluginAsync = async (fastify, _options) => { + await fastify.register(rateLimiter, { + limit: 45, + duration: 30, + rateLimitIdentifier: "user", + }); + // This route is a POST to avoid leaking/storing UINs in logs everywhere + fastify.withTypeProvider().post( + "/findUserByUin", + { + schema: withRoles( + [], + withTags(["Generic"], { + summary: "Find a user by UIN.", + body: searchUserByUinRequest, + response: { + 200: { + description: "User located.", + content: { + "application/json": { + schema: searchUserByUinResponse, + }, + }, + }, + }, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + const uinHash = await getUinHash({ + pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, + uin: request.body.uin, + }); + const queryCommand = new QueryCommand({ + TableName: genericConfig.UserInfoTable, + IndexName: "UinHashIndex", + KeyConditionExpression: "uinHash = :hash", + ExpressionAttributeValues: { + ":hash": { S: uinHash }, + }, + }); + const response = await fastify.dynamoClient.send(queryCommand); + if (!response || !response.Items) { + throw new DatabaseFetchError({ + message: "Failed to retrieve user from database.", + }); + } + if (response.Items.length === 0) { + throw new ValidationError({ + message: + "Failed to find user in database. Please have the user run sync and try again.", + }); + } + if (response.Items.length > 1) { + throw new ValidationError({ + message: + "Multiple users tied to this UIN. This user probably had a NetID change. Please contact support.", + }); + } + const data = unmarshall(response.Items[0]) as { id: string }; + return reply.send({ + email: data.id, + }); + }, + ); +}; + +export default userRoute; diff --git a/src/common/types/user.ts b/src/common/types/user.ts new file mode 100644 index 00000000..527b56a6 --- /dev/null +++ b/src/common/types/user.ts @@ -0,0 +1,9 @@ +import * as z from "zod/v4"; + +export const searchUserByUinRequest = z.object({ + uin: z.string().length(9) +}); + +export const searchUserByUinResponse = z.object({ + email: z.email(), +}); diff --git a/src/ui/pages/tickets/ScanTickets.page.tsx b/src/ui/pages/tickets/ScanTickets.page.tsx index c670a62d..bbbd5c26 100644 --- a/src/ui/pages/tickets/ScanTickets.page.tsx +++ b/src/ui/pages/tickets/ScanTickets.page.tsx @@ -10,6 +10,7 @@ import { Group, LoadingOverlay, Select, + TextInput, } from "@mantine/core"; import { IconAlertCircle, IconCheck, IconCamera } from "@tabler/icons-react"; import jsQR from "jsqr"; @@ -19,6 +20,7 @@ import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; +import { ValidationError } from "@common/errors"; interface QRDataMerch { type: string; @@ -48,6 +50,13 @@ export interface APIResponseSchema { type: ProductType; ticketId: string; purchaserData: PurchaseData; + refunded: boolean; + fulfilled: boolean; +} + +export interface PurchasesByEmailResponse { + merch: APIResponseSchema[]; + tickets: APIResponseSchema[]; } type QRData = QRDataMerch | QRDataTicket; @@ -70,7 +79,36 @@ export const recursiveToCamel = (item: unknown): unknown => { ); }; -export const ScanTicketsPage: React.FC = () => { +interface TicketItem { + itemId: string; + itemName: string; + itemSalesActive: string | false; +} + +interface TicketItemsResponse { + tickets: TicketItem[]; + merch: TicketItem[]; +} + +interface ScanTicketsPageProps { + getOrganizations?: () => Promise; + getTicketItems?: () => Promise; + getPurchasesByEmail?: (email: string) => Promise; + checkInTicket?: ( + data: + | QRData + | { type: string; ticketId?: string; email?: string; stripePi?: string }, + ) => Promise; + getEmailFromUIN?: (uin: string) => Promise; +} + +const ScanTicketsPageInternal: React.FC = ({ + getOrganizations: getOrganizationsProp, + getTicketItems: getTicketItemsProp, + getPurchasesByEmail: getPurchasesByEmailProp, + checkInTicket: checkInTicketProp, + getEmailFromUIN: getEmailFromUINProp, +}) => { const [orgList, setOrgList] = useState(null); const [showModal, setShowModal] = useState(false); const [scanResult, setScanResult] = useState(null); @@ -82,6 +120,18 @@ export const ScanTicketsPage: React.FC = () => { { value: string; label: string }[] >([]); const [selectedDevice, setSelectedDevice] = useState(null); + const [manualInput, setManualInput] = useState(""); + const [showTicketSelection, setShowTicketSelection] = useState(false); + const [availableTickets, setAvailableTickets] = useState( + [], + ); + const [ticketItems, setTicketItems] = useState; + }> | null>(null); + const [selectedItemFilter, setSelectedItemFilter] = useState( + null, + ); const api = useApi("core"); const videoRef = useRef(null); @@ -90,6 +140,62 @@ export const ScanTicketsPage: React.FC = () => { const animationFrameId = useRef(0); const lastScanTime = useRef(0); const isScanningRef = useRef(false); // Use ref for immediate updates + const manualInputRef = useRef(null); + + // Default API functions + const getOrganizations = + getOrganizationsProp || + (async () => { + const response = await api.get("/api/v1/organizations"); + return response.data; + }); + + const getTicketItems = + getTicketItemsProp || + (async () => { + const response = await api.get("/api/v1/tickets"); + return response.data; + }); + + const getPurchasesByEmail = + getPurchasesByEmailProp || + (async (email: string) => { + const response = await api.get( + `/api/v1/tickets/purchases/${encodeURIComponent(email)}`, + ); + return response.data; + }); + + const checkInTicket = + checkInTicketProp || + (async (data: any) => { + const response = await api.post( + `/api/v1/tickets/checkIn`, + recursiveToCamel(data), + ); + return response.data as APIResponseSchema; + }); + + const getEmailFromUINDefault = async (uin: string): Promise => { + try { + const response = await api.post(`/api/v1/users/findUserByUin`, { uin }); + return response.data.email; + } catch (error: any) { + const samp = new ValidationError({ + message: "Failed to convert UIN to email.", + }); + if ( + error.response?.status === samp.httpStatusCode && + error.response?.data.id === samp.id + ) { + const validationData = error.response.data; + throw new ValidationError(validationData.message || samp.message); + } + throw error; + } + }; + + const getEmailFromUIN = getEmailFromUINProp || getEmailFromUINDefault; const getVideoDevices = async () => { try { @@ -126,11 +232,93 @@ export const ScanTicketsPage: React.FC = () => { }; useEffect(() => { - const getOrgs = async () => { - const response = await api.get("/api/v1/organizations"); - setOrgList(response.data); + const fetchData = async () => { + try { + const orgs = await getOrganizations(); + setOrgList(orgs); + } catch (err) { + console.error("Failed to fetch organizations:", err); + } + + try { + const response = await getTicketItems(); + const activeTickets: Array<{ value: string; label: string }> = []; + const inactiveTickets: Array<{ value: string; label: string }> = []; + const activeMerch: Array<{ value: string; label: string }> = []; + const inactiveMerch: Array<{ value: string; label: string }> = []; + + const now = new Date(); + + // Process all tickets + if (response.tickets) { + response.tickets.forEach((ticket: TicketItem) => { + const isActive = + ticket.itemSalesActive !== false && + (typeof ticket.itemSalesActive === "string" + ? new Date(ticket.itemSalesActive) <= now + : false); + + const item = { + value: ticket.itemId, + label: ticket.itemName, + }; + + if (isActive) { + activeTickets.push(item); + } else { + inactiveTickets.push(item); + } + }); + } + + // Process all merch + if (response.merch) { + response.merch.forEach((merch: TicketItem) => { + const isActive = + merch.itemSalesActive !== false && + (typeof merch.itemSalesActive === "string" + ? new Date(merch.itemSalesActive) <= now + : false); + + const item = { + value: merch.itemId, + label: merch.itemName, + }; + + if (isActive) { + activeMerch.push(item); + } else { + inactiveMerch.push(item); + } + }); + } + + // Build grouped data structure for Mantine Select + const groups: Array<{ + group: string; + items: Array<{ value: string; label: string }>; + }> = []; + if (activeMerch.length > 0) { + groups.push({ group: "Active Merch", items: activeMerch }); + } + if (activeTickets.length > 0) { + groups.push({ group: "Active Events", items: activeTickets }); + } + if (inactiveMerch.length > 0) { + groups.push({ group: "Inactive Merch", items: inactiveMerch }); + } + if (inactiveTickets.length > 0) { + groups.push({ group: "Inactive Events", items: inactiveTickets }); + } + + setTicketItems(groups); + } catch (err) { + console.error("Failed to fetch ticket items:", err); + setTicketItems([]); + } }; - getOrgs(); + + fetchData(); // Initialize canvas canvasRef.current = document.createElement("canvas"); @@ -293,14 +481,11 @@ export const ScanTicketsPage: React.FC = () => { const handleSuccessfulScan = async (parsedData: QRData) => { try { - const response = await api.post( - `/api/v1/tickets/checkIn`, - recursiveToCamel(parsedData), - ); - if (!response.data.valid) { + const result = await checkInTicket(parsedData); + if (!result.valid) { throw new Error("Ticket is invalid."); } - setScanResult(response.data as APIResponseSchema); + setScanResult(result); setShowModal(true); } catch (err: any) { if (err.response && err.response.data) { @@ -322,9 +507,144 @@ export const ScanTicketsPage: React.FC = () => { setScanResult(null); setError(""); setShowModal(false); + setManualInput(""); + // Refocus the manual input field for easy card swiping + setTimeout(() => { + manualInputRef.current?.focus(); + }, 100); + }; + + const handleManualInputSubmit = async () => { + if (!manualInput.trim() || !selectedItemFilter) { + return; + } + + const inputValue = manualInput.trim(); + setManualInput(""); // Clear input immediately + + try { + setIsLoading(true); + setError(""); + + let email = inputValue; + + // Check if input is UIN (all digits) + if (/^\d+$/.test(email)) { + try { + email = await getEmailFromUIN(email); + } catch (err) { + let errorMessage = + "Failed to convert UIN to email. Please enter NetID or email instead."; + if (err instanceof ValidationError) { + errorMessage = err.message; + } + setError(errorMessage); + setIsLoading(false); + setShowModal(true); + return; + } + } + // Check if input is NetID (no @ symbol) + else if (!email.includes("@")) { + email = `${email}@illinois.edu`; + } + + // Fetch purchases for this email + const response = await getPurchasesByEmail(email); + + // Combine all valid tickets (both merch and tickets) and filter by selected item + const allValidTickets = [ + ...response.tickets.filter( + (t) => + t.valid && + !t.refunded && + t.purchaserData.productId === selectedItemFilter, + ), + ...response.merch.filter( + (m) => + m.valid && + !m.refunded && + m.purchaserData.productId === selectedItemFilter, + ), + ]; + + if (allValidTickets.length === 0) { + setError( + "No valid tickets found for this user and selected event/item.", + ); + setShowModal(true); + setIsLoading(false); + return; + } + + if (allValidTickets.length === 1) { + // Only one valid ticket, mark it automatically + await markTicket(allValidTickets[0]); + } else { + // Multiple valid tickets, show selection modal + setAvailableTickets(allValidTickets); + setShowTicketSelection(true); + } + + setIsLoading(false); + } catch (err: any) { + setIsLoading(false); + if (err.response && err.response.data) { + setError( + err.response.data + ? `Error ${err.response.data.id} (${err.response.data.name}): ${err.response.data.message}` + : "System encountered a failure, please contact the ACM Infra Chairs.", + ); + } else { + setError( + "Failed to fetch ticket information. Please check your connection and try again.", + ); + } + setShowModal(true); + } }; - if (orgList === null) { + const markTicket = async (ticket: APIResponseSchema) => { + try { + setIsLoading(true); + const qrData = + ticket.type === ProductType.Ticket + ? { type: "ticket", ticketId: ticket.ticketId } + : { + type: "merch", + stripePi: ticket.ticketId, + email: ticket.purchaserData.email, + }; + + const result = await checkInTicket(qrData); + + if (!result.valid) { + throw new Error("Ticket is invalid."); + } + + setScanResult(result); + setShowModal(true); + setShowTicketSelection(false); + setIsLoading(false); + } catch (err: any) { + setIsLoading(false); + setShowTicketSelection(false); + if (err.response && err.response.data) { + setError( + err.response.data + ? `Error ${err.response.data.id} (${err.response.data.name}): ${err.response.data.message}` + : "System encountered a failure, please contact the ACM Infra Chairs.", + ); + } else { + setError( + err instanceof Error ? err.message : "Failed to process ticket", + ); + } + setShowModal(true); + } + }; + + if (orgList === null || ticketItems === null) { return ; } @@ -336,65 +656,110 @@ export const ScanTicketsPage: React.FC = () => { Scan Tickets -
-
- - { + setSelectedDevice(value); + if (isScanning) { + stopScanning(); + setTimeout(() => startScanning(), 100); + } + }} + disabled={isLoading || isScanning} + mb="md" + /> + + + + {error && !showModal && ( + } + title="Error" + color="red" + variant="filled" + > + {error} + + )} + )}
@@ -454,7 +819,68 @@ export const ScanTicketsPage: React.FC = () => { ) )} + + { + setShowTicketSelection(false); + setManualInput(""); + }} + title="Select a Ticket" + size="lg" + centered + > + + + Multiple valid tickets found. Please select which one to mark: + + {availableTickets.map((ticket, index) => ( + markTicket(ticket)} + > + + + {ticket.type.toUpperCase()} -{" "} + {ticket.purchaserData.productId} + + Email: {ticket.purchaserData.email} + {ticket.purchaserData.quantity && ( + + Quantity: {ticket.purchaserData.quantity} + + )} + {ticket.purchaserData.size && ( + Size: {ticket.purchaserData.size} + )} + + Ticket ID: {ticket.ticketId} + + + + ))} + + + + + ); }; + +// Wrapper component that provides the default implementation +export const ScanTicketsPage: React.FC = (props) => { + return ; +}; diff --git a/src/ui/pages/tickets/SelectEventId.page.tsx b/src/ui/pages/tickets/SelectEventId.page.tsx index 9e399589..4b9a3e54 100644 --- a/src/ui/pages/tickets/SelectEventId.page.tsx +++ b/src/ui/pages/tickets/SelectEventId.page.tsx @@ -129,7 +129,7 @@ const SelectTicketsPage: React.FC = () => { itemSalesActive: newIsActive, type: isTicketItem(item) ? "ticket" : "merch", }; - await api.patch(`/api/v1/tickets/${item.itemId}`, data); + await api.patch(`/api/v1/tickets/event/${item.itemId}`, data); await fetchItems(); notifications.show({ title: "Changes saved", diff --git a/src/ui/pages/tickets/ViewTickets.page.tsx b/src/ui/pages/tickets/ViewTickets.page.tsx index 99414e67..9ba85a31 100644 --- a/src/ui/pages/tickets/ViewTickets.page.tsx +++ b/src/ui/pages/tickets/ViewTickets.page.tsx @@ -135,7 +135,9 @@ const ViewTicketsPage: React.FC = () => { const getTickets = async () => { try { setLoading(true); - const response = await api.get(`/api/v1/tickets/${eventId}?type=merch`); + const response = await api.get( + `/api/v1/tickets/event/${eventId}?type=merch`, + ); const parsedResponse = ticketsResponseSchema.parse(response.data); let localQuantitySold = 0; for (const item of parsedResponse.tickets) { diff --git a/terraform/modules/frontend/main.tf b/terraform/modules/frontend/main.tf index b663ac47..93a8c63d 100644 --- a/terraform/modules/frontend/main.tf +++ b/terraform/modules/frontend/main.tf @@ -233,6 +233,20 @@ resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { function_arn = aws_cloudfront_function.origin_key_injection.arn } } + ordered_cache_behavior { + path_pattern = "/api/v1/users/findUserByUin" + target_origin_id = "SlowLambdaFunction-${var.CurrentActiveRegion}" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.no_cache.id + origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" + compress = true + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.origin_key_injection.arn + } + } ordered_cache_behavior { path_pattern = "/api/v1/events*" target_origin_id = "LambdaFunction-${var.CurrentActiveRegion}" diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index 0b694d77..dfdc1acb 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -222,6 +222,7 @@ resource "aws_iam_policy" "shared_iam_policy" { Resource = [ // Tickets is still in us-east-1! "arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/infra-events-tickets", + "arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/infra-events-tickets/index/*", "arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/infra-events-ticketing-metadata", "arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/infra-merchstore-purchase-history", "arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/infra-merchstore-purchase-history/index/*", diff --git a/tests/live/tickets.test.ts b/tests/live/tickets.test.ts index ab7c54e0..06fb0f7b 100644 --- a/tests/live/tickets.test.ts +++ b/tests/live/tickets.test.ts @@ -30,4 +30,25 @@ describe("Tickets live API tests", async () => { expect(Array.isArray(responseBody["merch"])).toBe(true); expect(Array.isArray(responseBody["tickets"])).toBe(true); }); + test( + "Test that getting user purchases succeeds", + { timeout: 10000 }, + async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/tickets/purchases/acm@illinois.edu`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + expect(response.status).toBe(200); + const responseBody = await response.json(); + expect(responseBody).toHaveProperty("merch"); + expect(responseBody).toHaveProperty("tickets"); + expect(Array.isArray(responseBody["merch"])).toBe(true); + expect(Array.isArray(responseBody["tickets"])).toBe(true); + }, + ); }); diff --git a/tests/unit/functions/tickets.test.ts b/tests/unit/functions/tickets.test.ts new file mode 100644 index 00000000..14b55c99 --- /dev/null +++ b/tests/unit/functions/tickets.test.ts @@ -0,0 +1,356 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { + DynamoDBClient, + QueryCommand, +} from "@aws-sdk/client-dynamodb"; +import { getUserMerchPurchases, getUserTicketingPurchases } from "../../../src/api/functions/tickets.js"; +import { genericConfig } from "../../../src/common/config.js"; +import { mockClient } from "aws-sdk-client-mock"; +import { describe } from "node:test"; +import { DatabaseFetchError } from "../../../src/common/errors/index.js"; +import { marshall } from "@aws-sdk/util-dynamodb"; + +const ddbMock = mockClient(DynamoDBClient); + +const mockLogger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +}; + +beforeEach(() => { + ddbMock.reset(); + vi.clearAllMocks(); +}); + +describe("getUserTicketingPurchases tests", () => { + const testEmail = "test@example.com"; + + test("should return empty array when no tickets found", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + const result = await getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + expect(result).toEqual([]); + }); + + test("should successfully fetch and transform ticket purchases", async () => { + const mockTickets = [ + { + ticket_id: "ticket-123", + event_id: "event-456", + payment_method: "stripe", + purchase_time: "2024-01-01T00:00:00Z", + ticketholder_netid: testEmail, + used: false, + }, + { + ticket_id: "ticket-789", + event_id: "event-101", + payment_method: "stripe", + purchase_time: "2024-01-02T00:00:00Z", + ticketholder_netid: testEmail, + used: true, + }, + ]; + + ddbMock.on(QueryCommand).resolves({ + Items: mockTickets.map((ticket) => marshall(ticket)), + }); + + const result = await getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + valid: true, + type: "ticket", + ticketId: "ticket-123", + purchaserData: { + email: testEmail, + productId: "event-456", + quantity: 1, + }, + refunded: false, + fulfilled: false, + }); + expect(result[1]).toEqual({ + valid: false, + type: "ticket", + ticketId: "ticket-789", + purchaserData: { + email: testEmail, + productId: "event-101", + quantity: 1, + }, + refunded: false, + fulfilled: true, + }); + }); + + test("should query with correct parameters", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + await getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + const calls = ddbMock.commandCalls(QueryCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ + TableName: genericConfig.TicketPurchasesTableName, + IndexName: "UserIndex", + KeyConditionExpression: "ticketholder_netid = :email", + ExpressionAttributeValues: { + ":email": { S: testEmail }, + }, + }); + }); + + test("should throw DatabaseFetchError when Items is undefined", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: undefined, + }); + + await expect( + getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(DatabaseFetchError); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + + test("should throw DatabaseFetchError when query fails", async () => { + const error = new Error("DynamoDB error"); + ddbMock.on(QueryCommand).rejects(error); + + await expect( + getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(DatabaseFetchError); + + expect(mockLogger.error).toHaveBeenCalledWith(error); + }); + + test("should rethrow BaseError without wrapping", async () => { + const baseError = new DatabaseFetchError({ message: "Custom error" }); + ddbMock.on(QueryCommand).rejects(baseError); + + await expect( + getUserTicketingPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(baseError); + + expect(mockLogger.error).not.toHaveBeenCalled(); + }); +}); + +describe("getUserMerchPurchases tests", () => { + const testEmail = "test@example.com"; + + test("should return empty array when no merch purchases found", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + const result = await getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + expect(result).toEqual([]); + }); + + test("should successfully fetch and transform merch purchases", async () => { + const mockMerch = [ + { + stripe_pi: "pi_123", + email: testEmail, + fulfilled: true, + item_id: "merch-001", + quantity: 2, + refunded: false, + size: "L", + }, + { + stripe_pi: "pi_456", + email: testEmail, + fulfilled: false, + item_id: "merch-002", + quantity: 1, + refunded: true, + scanIsoTimestamp: "2024-01-01T00:00:00Z", + scannerEmail: "scanner@example.com", + size: "M", + }, + ]; + + ddbMock.on(QueryCommand).resolves({ + Items: mockMerch.map((merch) => marshall(merch)), + }); + + const result = await getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + valid: false, + type: "merch", + ticketId: "pi_123", + purchaserData: { + email: testEmail, + productId: "merch-001", + quantity: 2, + }, + refunded: false, + fulfilled: true, + }); + expect(result[1]).toEqual({ + valid: false, + type: "merch", + ticketId: "pi_456", + purchaserData: { + email: testEmail, + productId: "merch-002", + quantity: 1, + }, + refunded: true, + fulfilled: false, + }); + }); + + test("should query with correct parameters", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + await getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + const calls = ddbMock.commandCalls(QueryCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ + TableName: genericConfig.MerchStorePurchasesTableName, + IndexName: "UserIndex", + KeyConditionExpression: "email = :email", + ExpressionAttributeValues: { + ":email": { S: testEmail }, + }, + }); + }); + + test("should throw DatabaseFetchError when Items is undefined", async () => { + ddbMock.on(QueryCommand).resolves({ + Items: undefined, + }); + + await expect( + getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(DatabaseFetchError); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + + test("should throw DatabaseFetchError when query fails", async () => { + const error = new Error("DynamoDB error"); + ddbMock.on(QueryCommand).rejects(error); + + await expect( + getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(DatabaseFetchError); + + expect(mockLogger.error).toHaveBeenCalledWith(error); + }); + + test("should rethrow BaseError without wrapping", async () => { + const baseError = new DatabaseFetchError({ message: "Custom error" }); + ddbMock.on(QueryCommand).rejects(baseError); + + await expect( + getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }) + ).rejects.toThrow(baseError); + + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + test("should handle optional fields in merch entries", async () => { + const mockMerch = [ + { + stripe_pi: "pi_789", + email: testEmail, + fulfilled: false, + item_id: "merch-003", + quantity: 1, + refunded: false, + size: "S", + // scanIsoTimestamp and scannerEmail are optional and not included + }, + ]; + + ddbMock.on(QueryCommand).resolves({ + Items: mockMerch.map((merch) => marshall(merch)), + }); + + const result = await getUserMerchPurchases({ + dynamoClient: new DynamoDBClient({}), + email: testEmail, + logger: mockLogger, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + valid: true, + type: "merch", + ticketId: "pi_789", + purchaserData: { + email: testEmail, + productId: "merch-003", + quantity: 1, + }, + refunded: false, + fulfilled: false, + }); + }); +}); diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index a9a42aef..30496c14 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -26,6 +26,7 @@ import { merchMetadata, ticketsMetadata, } from "./data/mockTIcketsMerchMetadata.testdata.js"; +import { marshall } from "@aws-sdk/util-dynamodb"; const ddbMock = mockClient(DynamoDBClient); const jwt_secret = testSecretObject["jwt_key"]; @@ -497,7 +498,7 @@ describe("Test getting all issued tickets", async () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get("/api/v1/tickets/2024_fa_barcrawl?type=merch") + .get("/api/v1/tickets/event/2024_fa_barcrawl?type=merch") .set("authorization", `Bearer ${testJwt}`); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -509,7 +510,7 @@ describe("Test getting all issued tickets", async () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get("/api/v1/tickets/2024_fa_barcrawl?type=ticket") + .get("/api/v1/tickets/event/2024_fa_barcrawl?type=ticket") .set("authorization", `Bearer ${testJwt}`); const responseDataJson = response.body; expect(response.statusCode).toEqual(400); @@ -517,6 +518,255 @@ describe("Test getting all issued tickets", async () => { }); }); +describe("Test getting user purchases", () => { + const testEmail = "test@illinois.edu"; + + test("Happy path: get all purchases for a user", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ + Items: [ + marshall({ + ticket_id: "ticket-123", + event_id: "event-456", + payment_method: "stripe", + purchase_time: "2024-01-01T00:00:00Z", + ticketholder_netid: testEmail, + used: false, + }), + ], + }) + .resolvesOnce({ + Items: [ + marshall({ + stripe_pi: "pi_123", + email: testEmail, + fulfilled: true, + item_id: "merch-001", + quantity: 2, + refunded: false, + size: "L", + }), + ], + }); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson).toHaveProperty("merch"); + expect(responseDataJson).toHaveProperty("tickets"); + expect(responseDataJson.tickets).toHaveLength(1); + expect(responseDataJson.merch).toHaveLength(1); + expect(responseDataJson.tickets[0]).toEqual({ + valid: true, + type: "ticket", + ticketId: "ticket-123", + purchaserData: { + email: testEmail, + productId: "event-456", + quantity: 1, + }, + refunded: false, + fulfilled: false, + }); + expect(responseDataJson.merch[0]).toEqual({ + valid: false, + type: "merch", + ticketId: "pi_123", + purchaserData: { + email: testEmail, + productId: "merch-001", + quantity: 2, + }, + refunded: false, + fulfilled: true, + }); + }); + + test("Happy path: user with no purchases", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ Items: [] }) + .resolvesOnce({ Items: [] }); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson).toEqual({ + merch: [], + tickets: [], + }); + }); + + test("Happy path: user with only tickets", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ + Items: [ + marshall({ + ticket_id: "ticket-789", + event_id: "event-101", + payment_method: "stripe", + purchase_time: "2024-01-02T00:00:00Z", + ticketholder_netid: testEmail, + used: true, + }), + ], + }) + .resolvesOnce({ Items: [] }); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson.tickets).toHaveLength(1); + expect(responseDataJson.merch).toHaveLength(0); + }); + + test("Happy path: user with only merch", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ Items: [] }) + .resolvesOnce({ + Items: [ + marshall({ + stripe_pi: "pi_456", + email: testEmail, + fulfilled: false, + item_id: "merch-002", + quantity: 1, + refunded: true, + size: "M", + }), + ], + }); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson.tickets).toHaveLength(0); + expect(responseDataJson.merch).toHaveLength(1); + }); + + test("Happy path: user with multiple purchases of each type", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ + Items: [ + marshall({ + ticket_id: "ticket-1", + event_id: "event-1", + payment_method: "stripe", + purchase_time: "2024-01-01T00:00:00Z", + ticketholder_netid: testEmail, + used: false, + }), + marshall({ + ticket_id: "ticket-2", + event_id: "event-2", + payment_method: "stripe", + purchase_time: "2024-01-02T00:00:00Z", + ticketholder_netid: testEmail, + used: true, + }), + ], + }) + .resolvesOnce({ + Items: [ + marshall({ + stripe_pi: "pi_1", + email: testEmail, + fulfilled: true, + item_id: "merch-1", + quantity: 1, + refunded: false, + size: "S", + }), + marshall({ + stripe_pi: "pi_2", + email: testEmail, + fulfilled: false, + item_id: "merch-2", + quantity: 3, + refunded: false, + size: "XL", + }), + ], + }); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson.tickets).toHaveLength(2); + expect(responseDataJson.merch).toHaveLength(2); + }); + + test("Sad path: invalid email format", async () => { + const invalidEmail = "not-an-email"; + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${invalidEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + expect(response.statusCode).toEqual(400); + expect(response.body).toHaveProperty("error"); + }); + + test("Sad path: database error on tickets query", async () => { + ddbMock.on(QueryCommand).rejectsOnce(new Error("Database error")); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + expect(response.statusCode).toEqual(500); + expect(response.body).toHaveProperty("error"); + }); + + test("Sad path: database error on merch query", async () => { + ddbMock + .on(QueryCommand) + .resolvesOnce({ Items: [] }) + .rejectsOnce(new Error("Database error")); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .get(`/api/v1/tickets/purchases/${testEmail}`) + .set("authorization", `Bearer ${testJwt}`); + + expect(response.statusCode).toEqual(500); + expect(response.body).toHaveProperty("error"); + }); +}); + afterAll(async () => { await app.close(); vi.useRealTimers();