Skip to content

Commit f48451c

Browse files
committed
merch broken but ticketing works?
1 parent 27379ee commit f48451c

File tree

7 files changed

+247
-0
lines changed

7 files changed

+247
-0
lines changed

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type GenericConfigType = {
2121
ConfigSecretName: string;
2222
UpcomingEventThresholdSeconds: number;
2323
AwsRegion: string;
24+
MerchStorePurchasesTableName: string;
25+
TicketPurchasesTableName: string;
2426
};
2527

2628
type EnvironmentConfigType = {
@@ -32,6 +34,8 @@ const genericConfig: GenericConfigType = {
3234
ConfigSecretName: "infra-core-api-config",
3335
UpcomingEventThresholdSeconds: 1800, // 30 mins
3436
AwsRegion: process.env.AWS_REGION || "us-east-1",
37+
MerchStorePurchasesTableName: "infra-merchstore-purchase-history",
38+
TicketPurchasesTableName: "infra-events-tickets",
3539
} as const;
3640

3741
const environmentConfig: EnvironmentConfigType = {

src/errors/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,25 @@ export class DiscordEventError extends BaseError<"DiscordEventError"> {
122122
});
123123
}
124124
}
125+
126+
export class TicketNotFoundError extends BaseError<"TicketNotFoundError"> {
127+
constructor({ message }: { message?: string }) {
128+
super({
129+
name: "TicketNotFoundError",
130+
id: 108,
131+
message: message || "Could not find the ticket presented.",
132+
httpStatusCode: 404,
133+
});
134+
}
135+
}
136+
137+
export class TicketNotValidError extends BaseError<"TicketNotValidError"> {
138+
constructor({ message }: { message?: string }) {
139+
super({
140+
name: "TicketNotValidError",
141+
id: 109,
142+
message: message || "Ticket presented was found but is not valid.",
143+
httpStatusCode: 400,
144+
});
145+
}
146+
}

src/functions/validation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { z } from "zod";
2+
3+
export function validateEmail(email: string): boolean {
4+
const emailSchema = z.string().email();
5+
const result = emailSchema.safeParse(email);
6+
return result.success;
7+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import organizationsPlugin from "./routes/organizations.js";
1515
import icalPlugin from "./routes/ics.js";
1616
import vendingPlugin from "./routes/vending.js";
1717
import * as dotenv from "dotenv";
18+
import ticketsPlugin from "./routes/tickets.js";
1819
dotenv.config();
1920

2021
const now = () => Date.now();
@@ -71,6 +72,7 @@ async function init() {
7172
api.register(eventsPlugin, { prefix: "/events" });
7273
api.register(organizationsPlugin, { prefix: "/organizations" });
7374
api.register(icalPlugin, { prefix: "/ical" });
75+
api.register(ticketsPlugin, { prefix: "/tickets" });
7476
if (app.runEnvironment === "dev") {
7577
api.register(vendingPlugin, { prefix: "/vending" });
7678
}

src/plugins/errorHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const errorHandlerPlugin = fp(async (fastify) => {
2727
finalErr.toString(),
2828
);
2929
} else if (err instanceof Error) {
30+
request.log.error(err);
3031
request.log.error(
3132
{ errName: err.name, errMessage: err.message },
3233
"Native unhandled error: response sent to client.",

src/roles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const;
33
export type RunEnvironment = (typeof runEnvironments)[number];
44
export enum AppRoles {
55
EVENTS_MANAGER = "manage:events",
6+
TICKET_SCANNER = "scan:tickets",
67
}
78
export const allAppRoles = Object.values(AppRoles).filter(
89
(value) => typeof value === "string",

src/routes/tickets.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { FastifyPluginAsync } from "fastify";
2+
import { z } from "zod";
3+
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
4+
import { genericConfig } from "../config.js";
5+
import {
6+
BaseError,
7+
DatabaseFetchError,
8+
TicketNotFoundError,
9+
TicketNotValidError,
10+
UnauthenticatedError,
11+
ValidationError,
12+
} from "../errors/index.js";
13+
import { unmarshall } from "@aws-sdk/util-dynamodb";
14+
import { validateEmail } from "../functions/validation.js";
15+
import { AppRoles } from "../roles.js";
16+
import { zodToJsonSchema } from "zod-to-json-schema";
17+
18+
const postMerchSchema = z.object({
19+
type: z.literal("merch"),
20+
email: z.string().email(),
21+
stripe_pi: z.string().min(1),
22+
});
23+
24+
const postTicketSchema = z.object({
25+
type: z.literal("ticket"),
26+
ticket_id: z.string().min(1),
27+
});
28+
29+
const purchaseSchema = z.object({
30+
email: z.string().email(),
31+
productId: z.string(),
32+
quantity: z.number().int().positive(),
33+
size: z.string().optional(),
34+
});
35+
36+
type PurchaseData = z.infer<typeof purchaseSchema>;
37+
38+
const responseJsonSchema = zodToJsonSchema(
39+
z.object({
40+
valid: z.boolean(),
41+
type: z.enum(["merch", "ticket"]),
42+
ticketId: z.string().min(1),
43+
purchaserData: purchaseSchema,
44+
}),
45+
);
46+
47+
const postSchema = z.union([postMerchSchema, postTicketSchema]);
48+
49+
type VerifyPostRequest = z.infer<typeof postSchema>;
50+
51+
const dynamoClient = new DynamoDBClient({
52+
region: genericConfig.AwsRegion,
53+
});
54+
55+
const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
56+
fastify.post<{ Body: VerifyPostRequest }>(
57+
"/checkIn",
58+
{
59+
schema: {
60+
response: { 200: responseJsonSchema },
61+
},
62+
preValidation: async (request, reply) => {
63+
await fastify.zodValidateBody(request, reply, postSchema);
64+
},
65+
onRequest: async (request, reply) => {
66+
await fastify.authorize(request, reply, [AppRoles.TICKET_SCANNER]);
67+
},
68+
},
69+
async (request, reply) => {
70+
let command: UpdateItemCommand;
71+
let ticketId: string;
72+
if (!request.username) {
73+
throw new UnauthenticatedError({ message: "Could not find username." });
74+
}
75+
switch (request.body.type) {
76+
case "merch":
77+
ticketId = request.body.stripe_pi;
78+
command = new UpdateItemCommand({
79+
TableName: genericConfig.MerchStorePurchasesTableName,
80+
Key: {
81+
stripe_pi: { S: ticketId },
82+
},
83+
UpdateExpression: "SET fulfilled = :true_val",
84+
ConditionExpression: "#email = :email_val", // Added # to reference the attribute name
85+
ExpressionAttributeNames: {
86+
"#email": "email", // Define the attribute name
87+
},
88+
ExpressionAttributeValues: {
89+
":true_val": { BOOL: true },
90+
":email_val": { S: request.body.email },
91+
},
92+
ReturnValues: "ALL_OLD",
93+
});
94+
break;
95+
case "ticket":
96+
ticketId = request.body.ticket_id;
97+
command = new UpdateItemCommand({
98+
TableName: genericConfig.TicketPurchasesTableName,
99+
Key: {
100+
ticket_id: { S: ticketId },
101+
},
102+
UpdateExpression: "SET #used = :trueValue",
103+
ExpressionAttributeNames: {
104+
"#used": "used",
105+
},
106+
ExpressionAttributeValues: {
107+
":trueValue": { BOOL: true },
108+
},
109+
ReturnValues: "ALL_OLD",
110+
});
111+
break;
112+
default:
113+
throw new ValidationError({ message: `Unknown verification type!` });
114+
}
115+
let purchaserData: PurchaseData;
116+
try {
117+
const ticketEntry = await dynamoClient.send(command);
118+
if (!ticketEntry.Attributes) {
119+
throw new DatabaseFetchError({
120+
message: "Could not find ticket data",
121+
});
122+
}
123+
const attributes = unmarshall(ticketEntry.Attributes);
124+
if (attributes["refunded"]) {
125+
throw new TicketNotValidError({
126+
message: "Ticket was already refunded.",
127+
});
128+
}
129+
if (attributes["used"]) {
130+
throw new TicketNotValidError({
131+
message: "Ticket has already been used.",
132+
});
133+
}
134+
if (request.body.type === "ticket") {
135+
const rawData = attributes["ticketholder_netid"];
136+
const isEmail = validateEmail(attributes["ticketholder_netid"]);
137+
purchaserData = {
138+
email: isEmail ? rawData : `${rawData}@illinois.edu`,
139+
productId: attributes["event_id"],
140+
quantity: 1,
141+
};
142+
} else {
143+
purchaserData = {
144+
email: attributes["email"],
145+
productId: attributes["item_id"],
146+
quantity: attributes["quantity"],
147+
size: attributes["size"],
148+
};
149+
}
150+
} catch (e: unknown) {
151+
if (!(e instanceof Error)) {
152+
throw e;
153+
}
154+
request.log.error(e);
155+
if (e instanceof BaseError) {
156+
throw e;
157+
}
158+
if (e.name === "ConditionalCheckFailedException") {
159+
throw new TicketNotFoundError({
160+
message: "Ticket does not exist",
161+
});
162+
}
163+
throw new DatabaseFetchError({
164+
message: "Could not set ticket to used - database operation failed",
165+
});
166+
}
167+
const response = {
168+
valid: true,
169+
type: request.body.type,
170+
ticketId,
171+
purchaserData,
172+
};
173+
switch (request.body.type) {
174+
case "merch":
175+
ticketId = request.body.stripe_pi;
176+
command = new UpdateItemCommand({
177+
TableName: genericConfig.MerchStorePurchasesTableName,
178+
Key: {
179+
stripe_pi: { S: ticketId },
180+
},
181+
UpdateExpression: "SET scannerEmail = :scanner_email",
182+
ConditionExpression: "email = :email_val",
183+
ExpressionAttributeValues: {
184+
":scanner_email": { S: request.username },
185+
},
186+
});
187+
break;
188+
case "ticket":
189+
ticketId = request.body.ticket_id;
190+
command = new UpdateItemCommand({
191+
TableName: genericConfig.TicketPurchasesTableName,
192+
Key: {
193+
ticket_id: { S: ticketId },
194+
},
195+
UpdateExpression: "SET scannerEmail = :scanner_email",
196+
ExpressionAttributeValues: {
197+
":scanner_email": { S: request.username },
198+
},
199+
});
200+
break;
201+
default:
202+
throw new ValidationError({ message: `Unknown verification type!` });
203+
}
204+
await dynamoClient.send(command);
205+
reply.send(response);
206+
},
207+
);
208+
};
209+
210+
export default ticketsPlugin;

0 commit comments

Comments
 (0)