Skip to content

Commit 3fe0874

Browse files
authored
Redesign ticketing check-in process (#394)
- Require UIN or iCard swipe - Do not call UIN endpoint on client side, instead call it on the server side to enforce no NetID can be used. - Filter tickets for current event
1 parent ca87a36 commit 3fe0874

File tree

10 files changed

+256
-143
lines changed

10 files changed

+256
-143
lines changed

src/api/functions/tickets.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type GetUserPurchasesInputs = {
99
dynamoClient: DynamoDBClient;
1010
email: string;
1111
logger: ValidLoggers;
12+
productId?: string;
1213
};
1314

1415
export type RawTicketEntry = {
@@ -36,6 +37,7 @@ export async function getUserTicketingPurchases({
3637
dynamoClient,
3738
email,
3839
logger,
40+
productId,
3941
}: GetUserPurchasesInputs) {
4042
const issuedTickets: TicketInfoEntry[] = [];
4143
const ticketCommand = new QueryCommand({
@@ -44,7 +46,9 @@ export async function getUserTicketingPurchases({
4446
KeyConditionExpression: "ticketholder_netid = :email",
4547
ExpressionAttributeValues: {
4648
":email": { S: email },
49+
...(productId && { ":productId": { S: productId } }),
4750
},
51+
...(productId && { FilterExpression: "event_id = :productId" }),
4852
});
4953
let ticketResults;
5054
try {
@@ -85,6 +89,7 @@ export async function getUserMerchPurchases({
8589
dynamoClient,
8690
email,
8791
logger,
92+
productId,
8893
}: GetUserPurchasesInputs) {
8994
const issuedTickets: TicketInfoEntry[] = [];
9095
const merchCommand = new QueryCommand({
@@ -93,7 +98,9 @@ export async function getUserMerchPurchases({
9398
KeyConditionExpression: "email = :email",
9499
ExpressionAttributeValues: {
95100
":email": { S: email },
101+
...(productId && { ":productId": { S: productId } }),
96102
},
103+
...(productId && { FilterExpression: "item_id = :productId" }),
97104
});
98105
let ticketsResult;
99106
try {
@@ -122,6 +129,7 @@ export async function getUserMerchPurchases({
122129
email: item.email,
123130
productId: item.item_id,
124131
quantity: item.quantity,
132+
size: item.size,
125133
},
126134
refunded: item.refunded,
127135
fulfilled: item.fulfilled,

src/api/functions/uin.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {
22
DynamoDBClient,
33
PutItemCommand,
4+
QueryCommand,
45
UpdateItemCommand,
56
} from "@aws-sdk/client-dynamodb";
6-
import { marshall } from "@aws-sdk/util-dynamodb";
7+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
78
import { argon2id, hash } from "argon2";
89
import { genericConfig } from "common/config.js";
910
import {
1011
BaseError,
12+
DatabaseFetchError,
1113
EntraFetchError,
1214
InternalServerError,
1315
UnauthenticatedError,
@@ -184,3 +186,52 @@ export async function saveHashedUserUin({
184186
}),
185187
);
186188
}
189+
190+
export async function getUserIdByUin({
191+
dynamoClient,
192+
uin,
193+
pepper,
194+
}: {
195+
dynamoClient: DynamoDBClient;
196+
uin: string;
197+
pepper: string;
198+
}): Promise<{ id: string }> {
199+
const uinHash = await getUinHash({
200+
pepper,
201+
uin,
202+
});
203+
204+
const queryCommand = new QueryCommand({
205+
TableName: genericConfig.UserInfoTable,
206+
IndexName: "UinHashIndex",
207+
KeyConditionExpression: "uinHash = :hash",
208+
ExpressionAttributeValues: {
209+
":hash": { S: uinHash },
210+
},
211+
});
212+
213+
const response = await dynamoClient.send(queryCommand);
214+
215+
if (!response || !response.Items) {
216+
throw new DatabaseFetchError({
217+
message: "Failed to retrieve user from database.",
218+
});
219+
}
220+
221+
if (response.Items.length === 0) {
222+
throw new ValidationError({
223+
message:
224+
"Failed to find user in database. Please have the user run sync and try again.",
225+
});
226+
}
227+
228+
if (response.Items.length > 1) {
229+
throw new ValidationError({
230+
message:
231+
"Multiple users tied to this UIN. This user probably had a NetID change. Please contact support.",
232+
});
233+
}
234+
235+
const data = unmarshall(response.Items[0]) as { id: string };
236+
return data;
237+
}

src/api/routes/tickets.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
getUserMerchPurchases,
3333
getUserTicketingPurchases,
3434
} from "api/functions/tickets.js";
35+
import { illinoisUin } from "common/types/generic.js";
36+
import { getUserIdByUin } from "api/functions/uin.js";
3537

3638
const postMerchSchema = z.object({
3739
type: z.literal("merch"),
@@ -512,15 +514,18 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
512514
});
513515
},
514516
);
515-
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
516-
"/purchases/:email",
517+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
518+
"/getPurchasesByUser",
517519
{
518520
schema: withRoles(
519521
[AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER],
520522
withTags(["Tickets/Merchandise"], {
521523
summary: "Get all purchases (merch and tickets) for a given user.",
522-
params: z.object({
523-
email: z.email(),
524+
body: z.object({
525+
productId: z.string().min(1).meta({
526+
description: "The product ID currently being verified",
527+
}),
528+
uin: illinoisUin,
524529
}),
525530
response: {
526531
200: {
@@ -540,18 +545,24 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
540545
onRequest: fastify.authorizeFromSchema,
541546
},
542547
async (request, reply) => {
543-
const userEmail = request.params.email;
548+
const { id: userEmail } = await getUserIdByUin({
549+
dynamoClient: fastify.dynamoClient,
550+
uin: request.body.uin,
551+
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
552+
});
544553
try {
545554
const [ticketsResult, merchResult] = await Promise.all([
546555
getUserTicketingPurchases({
547556
dynamoClient: UsEast1DynamoClient,
548557
email: userEmail,
549558
logger: request.log,
559+
productId: request.body.productId,
550560
}),
551561
getUserMerchPurchases({
552562
dynamoClient: UsEast1DynamoClient,
553563
email: userEmail,
554564
logger: request.log,
565+
productId: request.body.productId,
555566
}),
556567
]);
557568
await reply.send({ merch: merchResult, tickets: ticketsResult });

src/api/routes/user.ts

Lines changed: 9 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
searchUserByUinRequest,
1313
searchUserByUinResponse,
1414
} from "common/types/user.js";
15-
import { getUinHash } from "api/functions/uin.js";
15+
import { getUinHash, getUserIdByUin } from "api/functions/uin.js";
1616
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1717
import { QueryCommand } from "@aws-sdk/client-dynamodb";
1818
import { genericConfig } from "common/config.js";
@@ -30,11 +30,7 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => {
3030
"/findUserByUin",
3131
{
3232
schema: withRoles(
33-
[
34-
AppRoles.VIEW_USER_INFO,
35-
AppRoles.TICKETS_MANAGER,
36-
AppRoles.TICKETS_SCANNER,
37-
],
33+
[AppRoles.VIEW_USER_INFO],
3834
withTags(["Generic"], {
3935
summary: "Find a user by UIN.",
4036
body: searchUserByUinRequest,
@@ -53,40 +49,13 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => {
5349
onRequest: fastify.authorizeFromSchema,
5450
},
5551
async (request, reply) => {
56-
const uinHash = await getUinHash({
57-
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
58-
uin: request.body.uin,
59-
});
60-
const queryCommand = new QueryCommand({
61-
TableName: genericConfig.UserInfoTable,
62-
IndexName: "UinHashIndex",
63-
KeyConditionExpression: "uinHash = :hash",
64-
ExpressionAttributeValues: {
65-
":hash": { S: uinHash },
66-
},
67-
});
68-
const response = await fastify.dynamoClient.send(queryCommand);
69-
if (!response || !response.Items) {
70-
throw new DatabaseFetchError({
71-
message: "Failed to retrieve user from database.",
72-
});
73-
}
74-
if (response.Items.length === 0) {
75-
throw new ValidationError({
76-
message:
77-
"Failed to find user in database. Please have the user run sync and try again.",
78-
});
79-
}
80-
if (response.Items.length > 1) {
81-
throw new ValidationError({
82-
message:
83-
"Multiple users tied to this UIN. This user probably had a NetID change. Please contact support.",
84-
});
85-
}
86-
const data = unmarshall(response.Items[0]) as { id: string };
87-
return reply.send({
88-
email: data.id,
89-
});
52+
return reply.send(
53+
await getUserIdByUin({
54+
dynamoClient: fastify.dynamoClient,
55+
uin: request.body.uin,
56+
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
57+
}),
58+
);
9059
},
9160
);
9261
};

src/common/types/generic.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export const illinoisNetId = z
2525
id: "IllinoisNetId",
2626
});
2727

28+
export const illinoisUin = z
29+
.string()
30+
.length(9, { message: "UIN must be 9 characters." })
31+
.regex(/^\d{9}$/i, {
32+
message: "UIN is malformed.",
33+
})
34+
.meta({
35+
description: "Valid Illinois UIN.",
36+
example: "627838939",
37+
id: "IllinoisUin",
38+
});
39+
40+
2841
export const OrgUniqueId = z.enum(Object.keys(Organizations)).meta({
2942
description: "The unique org ID for a given ACM sub-organization. See https://github.com/acm-uiuc/js-shared/blob/main/src/orgs.ts#L15",
3043
examples: ["A01", "C01"],

src/common/types/user.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as z from "zod/v4";
2+
import { illinoisUin } from "./generic.js";
23

34
export const searchUserByUinRequest = z.object({
4-
uin: z.string().length(9)
5+
uin: illinoisUin
56
});
67

78
export const searchUserByUinResponse = z.object({

0 commit comments

Comments
 (0)