Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type GenericConfigType = {
ConfigSecretName: string;
UpcomingEventThresholdSeconds: number;
AwsRegion: string;
MerchStorePurchasesTableName: string;
TicketPurchasesTableName: string;
};

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

const environmentConfig: EnvironmentConfigType = {
Expand Down
22 changes: 22 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,25 @@ export class DiscordEventError extends BaseError<"DiscordEventError"> {
});
}
}

export class TicketNotFoundError extends BaseError<"TicketNotFoundError"> {
constructor({ message }: { message?: string }) {
super({
name: "TicketNotFoundError",
id: 108,
message: message || "Could not find the ticket presented.",
httpStatusCode: 404,
});
}
}

export class TicketNotValidError extends BaseError<"TicketNotValidError"> {
constructor({ message }: { message?: string }) {
super({
name: "TicketNotValidError",
id: 109,
message: message || "Ticket presented was found but is not valid.",
httpStatusCode: 400,
});
}
}
7 changes: 7 additions & 0 deletions src/functions/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export function validateEmail(email: string): boolean {
const emailSchema = z.string().email();
const result = emailSchema.safeParse(email);
return result.success;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import organizationsPlugin from "./routes/organizations.js";
import icalPlugin from "./routes/ics.js";
import vendingPlugin from "./routes/vending.js";
import * as dotenv from "dotenv";
import ticketsPlugin from "./routes/tickets.js";
dotenv.config();

const now = () => Date.now();
Expand Down Expand Up @@ -71,6 +72,7 @@ async function init() {
api.register(eventsPlugin, { prefix: "/events" });
api.register(organizationsPlugin, { prefix: "/organizations" });
api.register(icalPlugin, { prefix: "/ical" });
api.register(ticketsPlugin, { prefix: "/tickets" });
if (app.runEnvironment === "dev") {
api.register(vendingPlugin, { prefix: "/vending" });
}
Expand Down
1 change: 1 addition & 0 deletions src/plugins/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const errorHandlerPlugin = fp(async (fastify) => {
finalErr.toString(),
);
} else if (err instanceof Error) {
request.log.error(err);
request.log.error(
{ errName: err.name, errMessage: err.message },
"Native unhandled error: response sent to client.",
Expand Down
1 change: 1 addition & 0 deletions src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const;
export type RunEnvironment = (typeof runEnvironments)[number];
export enum AppRoles {
EVENTS_MANAGER = "manage:events",
TICKET_SCANNER = "scan:tickets",
}
export const allAppRoles = Object.values(AppRoles).filter(
(value) => typeof value === "string",
Expand Down
211 changes: 211 additions & 0 deletions src/routes/tickets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
import { genericConfig } from "../config.js";
import {
BaseError,
DatabaseFetchError,
TicketNotFoundError,
TicketNotValidError,
UnauthenticatedError,
ValidationError,
} from "../errors/index.js";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { validateEmail } from "../functions/validation.js";
import { AppRoles } from "../roles.js";
import { zodToJsonSchema } from "zod-to-json-schema";

const postMerchSchema = z.object({
type: z.literal("merch"),
email: z.string().email(),
stripePi: z.string().min(1),
});

const postTicketSchema = z.object({
type: z.literal("ticket"),
ticketId: z.string().min(1),
});

const purchaseSchema = z.object({
email: z.string().email(),
productId: z.string(),
quantity: z.number().int().positive(),
size: z.string().optional(),
});

type PurchaseData = z.infer<typeof purchaseSchema>;

const responseJsonSchema = zodToJsonSchema(
z.object({
valid: z.boolean(),
type: z.enum(["merch", "ticket"]),
ticketId: z.string().min(1),
purchaserData: purchaseSchema,
}),
);

const postSchema = z.union([postMerchSchema, postTicketSchema]);

type VerifyPostRequest = z.infer<typeof postSchema>;

const dynamoClient = new DynamoDBClient({
region: genericConfig.AwsRegion,
});

const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
fastify.post<{ Body: VerifyPostRequest }>(
"/checkIn",
{
schema: {
response: { 200: responseJsonSchema },
},
preValidation: async (request, reply) => {
await fastify.zodValidateBody(request, reply, postSchema);
},
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.TICKET_SCANNER]);
},
},
async (request, reply) => {
let command: UpdateItemCommand;
let ticketId: string;
if (!request.username) {
throw new UnauthenticatedError({ message: "Could not find username." });
}
switch (request.body.type) {
case "merch":
ticketId = request.body.stripePi;
command = new UpdateItemCommand({
TableName: genericConfig.MerchStorePurchasesTableName,
Key: {
stripe_pi: { S: ticketId },
},
UpdateExpression: "SET fulfilled = :true_val",
ConditionExpression: "#email = :email_val",
ExpressionAttributeNames: {
"#email": "email",
},
ExpressionAttributeValues: {
":true_val": { BOOL: true },
":email_val": { S: request.body.email },
},
ReturnValues: "ALL_OLD",
});
break;
case "ticket":
ticketId = request.body.ticketId;
command = new UpdateItemCommand({
TableName: genericConfig.TicketPurchasesTableName,
Key: {
ticket_id: { S: ticketId },
},
UpdateExpression: "SET #used = :trueValue",
ExpressionAttributeNames: {
"#used": "used",
},
ExpressionAttributeValues: {
":trueValue": { BOOL: true },
},
ReturnValues: "ALL_OLD",
});
break;
default:
throw new ValidationError({ message: `Unknown verification type!` });
}
let purchaserData: PurchaseData;
try {
const ticketEntry = await dynamoClient.send(command);
if (!ticketEntry.Attributes) {
throw new DatabaseFetchError({
message: "Could not find ticket data",
});
}
const attributes = unmarshall(ticketEntry.Attributes);
if (attributes["refunded"]) {
throw new TicketNotValidError({
message: "Ticket was already refunded.",
});
}
if (attributes["used"] || attributes["fulfilled"]) {
throw new TicketNotValidError({
message: "Ticket has already been used.",
});
}
if (request.body.type === "ticket") {
const rawData = attributes["ticketholder_netid"];
const isEmail = validateEmail(attributes["ticketholder_netid"]);
purchaserData = {
email: isEmail ? rawData : `${rawData}@illinois.edu`,
productId: attributes["event_id"],
quantity: 1,
};
} else {
purchaserData = {
email: attributes["email"],
productId: attributes["item_id"],
quantity: attributes["quantity"],
size: attributes["size"],
};
}
} catch (e: unknown) {
if (!(e instanceof Error)) {
throw e;
}
request.log.error(e);
if (e instanceof BaseError) {
throw e;
}
if (e.name === "ConditionalCheckFailedException") {
throw new TicketNotFoundError({
message: "Ticket does not exist",
});
}
throw new DatabaseFetchError({
message: "Could not set ticket to used - database operation failed",
});
}
const response = {
valid: true,
type: request.body.type,
ticketId,
purchaserData,
};
switch (request.body.type) {
case "merch":
ticketId = request.body.stripePi;
command = new UpdateItemCommand({
TableName: genericConfig.MerchStorePurchasesTableName,
Key: {
stripe_pi: { S: ticketId },
},
UpdateExpression: "SET scannerEmail = :scanner_email",
ConditionExpression: "email = :email_val",
ExpressionAttributeValues: {
":scanner_email": { S: request.username },
":email_val": { S: request.body.email },
},
});
break;
case "ticket":
ticketId = request.body.ticketId;
command = new UpdateItemCommand({
TableName: genericConfig.TicketPurchasesTableName,
Key: {
ticket_id: { S: ticketId },
},
UpdateExpression: "SET scannerEmail = :scanner_email",
ExpressionAttributeValues: {
":scanner_email": { S: request.username },
},
});
break;
default:
throw new ValidationError({ message: `Unknown verification type!` });
}
await dynamoClient.send(command);
reply.send(response);
},
);
};

export default ticketsPlugin;
3 changes: 2 additions & 1 deletion tests/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mockClient } from "aws-sdk-client-mock";
import init from "../../src/index.js";
import { secretJson, secretObject, jwtPayload } from "./secret.testdata.js";
import jwt from "jsonwebtoken";
import { allAppRoles } from "../../src/roles.js";

const ddbMock = mockClient(SecretsManagerClient);

Expand Down Expand Up @@ -48,6 +49,6 @@ test("Test happy path", async () => {
const jsonBody = await response.json();
expect(jsonBody).toEqual({
username: "[email protected]",
roles: ["manage:events"],
roles: allAppRoles,
});
});
Loading
Loading