Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
131 changes: 131 additions & 0 deletions src/api/functions/tickets.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -373,6 +374,7 @@ Otherwise, email [[email protected]](mailto:[email protected]) 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" });
}
Expand Down
122 changes: 104 additions & 18 deletions src/api/routes/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -56,18 +60,16 @@ const ticketEntryZod = z.object({
purchaserData: purchaseSchema,
});

const ticketInfoEntryZod = ticketEntryZod.extend({
refunded: z.boolean(),
fulfilled: z.boolean(),
});

type TicketInfoEntry = z.infer<typeof ticketInfoEntryZod>;

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<typeof ticketInfoEntryZod>;

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

const listMerchItemsResponse = z.object({
merch: z.array(baseItemMetadata),
tickets: z.array(ticketingItemMetadata),
});

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

const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
Expand All @@ -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,
Expand Down Expand Up @@ -198,7 +208,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/:eventId",
"/event/:eventId",
{
schema: withRoles(
[AppRoles.TICKETS_MANAGER],
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -480,6 +512,60 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => {
});
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().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;
Loading
Loading