diff --git a/src/api/index.ts b/src/api/index.ts index d049bd6..5e79c8e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -24,6 +24,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import mobileWalletRoute from "./routes/mobileWallet.js"; import stripeRoutes from "./routes/stripe.js"; +import paidEventsPlugin from "./routes/paidEvents.js"; dotenv.config(); const now = () => Date.now(); @@ -109,6 +110,7 @@ async function init() { async (api, _options) => { api.register(protectedRoute, { prefix: "/protected" }); api.register(eventsPlugin, { prefix: "/events" }); + api.register(paidEventsPlugin, { prefix: "/paidEvents" }); api.register(organizationsPlugin, { prefix: "/organizations" }); api.register(icalPlugin, { prefix: "/ical" }); api.register(iamRoutes, { prefix: "/iam" }); diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index da3cd80..28af4a6 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -39,7 +39,8 @@ const baseSchema = z.object({ locationLink: z.optional(z.string().url()), host: z.enum(OrganizationList as [string, ...string[]]), featured: z.boolean().default(false), - paidEventId: z.optional(z.string().min(1)), + paidEventId: z.optional(z.string()), + type: z.literal(undefined), }); const requestSchema = baseSchema.extend({ @@ -47,6 +48,37 @@ const requestSchema = baseSchema.extend({ repeatEnds: z.string().optional(), }); +const ticketEventSchema = requestSchema.extend({ + type: z.literal("ticket"), + event_id: z.string(), + event_name: z.string(), + eventCost: z.optional(z.record(z.number())), + eventDetails: z.string(), + eventImage: z.string(), + event_capacity: z.number(), + event_sales_active_utc: z.number(), + event_time: z.number(), + member_price: z.optional(z.string()), + nonmember_price: z.optional(z.string()), + tickets_sold: z.number(), +}); + +const merchEventSchema = requestSchema.extend({ + type: z.literal("merch"), + item_id: z.string(), + item_email_desc: z.string(), + item_image: z.string(), + item_name: z.string(), + item_price: z.optional(z.record(z.string(), z.number())), + item_sales_active_utc: z.number(), + limit_per_person: z.number(), + member_price: z.optional(z.string()), + nonmember_price: z.optional(z.string()), + ready_for_pickup: z.boolean(), + sizes: z.optional(z.array(z.string())), + total_avail: z.optional(z.record(z.string(), z.string())), +}); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const postRequestSchema = requestSchema.refine( (data) => (data.repeatEnds ? data.repeats !== undefined : true), @@ -55,7 +87,36 @@ const postRequestSchema = requestSchema.refine( }, ); -export type EventPostRequest = z.infer; +/*.refine( + (data) => (data.paidEventId === undefined), + { + message: "paidEventId should be empty if you are not creating a paid event", + }, +)*/ //Potential check here in case people creates event with a paideventid but no other entry so zod validates to just a normal event + +const postTicketEventSchema = ticketEventSchema.refine( + (data) => + data.paidEventId !== undefined && data.paidEventId === data.event_id, + { + message: "event_id needs to be the same as paidEventId", //currently useless bc if this false it will auto convert to a unpaid event... + }, +); + +const postMerchEventSchema = merchEventSchema.refine( + (data) => data.paidEventId !== undefined && data.paidEventId === data.item_id, + { + message: "merch_id needs to be the same as paidEventId", //currently useless bc if this false it will auto convert to a unpaid event... + }, +); + +const postRefinedSchema = z.union([ + postRequestSchema, + postMerchEventSchema, + postTicketEventSchema, +]); + +export type EventPostRequest = z.infer; + type EventGetRequest = { Params: { id: string }; Querystring: undefined; @@ -81,6 +142,70 @@ const getEventsSchema = z.array(getEventSchema); export type EventsGetResponse = z.infer; type EventsGetQueryParams = { upcomingOnly?: boolean }; +const splitter = (input: z.infer) => { + type entry = undefined | string | number | boolean; + const { type, ...rest } = input; + console.log(rest); + let eventData: any = {}; //TODO: Need to specify type very faulty + const paidData: { [key: string]: entry } = {}; + const eventSchemaKeys = Object.keys(requestSchema.shape); + if (type === undefined) { + eventData = rest as { [key: string]: entry }; + } else if (type === "ticket") { + const data = rest as { [key: string]: entry }; + const paidSchemaKeys = [ + "event_id", + "event_name", + "eventCost", + "eventDetails", + "eventImage", + "event_capacity", + "event_sales_active_utc", + "event_time", + "member_price", + "nonmember_price", + "tickets_sold", + ]; + for (const key of paidSchemaKeys) { + if (key in data) { + paidData[key] = data[key]; + } + } + for (const key of eventSchemaKeys) { + if (key in data) { + eventData[key] = data[key]; + } + } + } else if (type === "merch") { + const data = rest as { [key: string]: entry }; + const paidSchemaKeys = [ + "item_id", + "item_email_desc", + "item_image", + "item_name", + "item_price", + "item_sales_active_utc", + "limit_per_person", + "member_price", + "nonmember_price", + "ready_for_pickup", + "sizes", + "total_avail", + ]; + for (const key of paidSchemaKeys) { + if (key in data) { + paidData[key] = data[key]; + } + } + for (const key of eventSchemaKeys) { + if (key in data) { + eventData[key] = data[key]; + } + } + } + return [type, eventData, paidData]; +}; + const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.post<{ Body: EventPostRequest }>( "/:id?", @@ -89,11 +214,11 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { response: { 201: responseJsonSchema }, }, preValidation: async (request, reply) => { - await fastify.zodValidateBody(request, reply, postRequestSchema); + await fastify.zodValidateBody(request, reply, postRefinedSchema); }, - onRequest: async (request, reply) => { + /*onRequest: async (request, reply) => { await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); - }, + },*/ }, async (request, reply) => { try { @@ -116,27 +241,89 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); } } + const obj = splitter(request.body); + const eventEntry: z.infer = obj[1]; const entry = { - ...request.body, + ...eventEntry, id: entryUUID, - createdBy: request.username, + createdBy: "request.username", //temporary disabled for testing createdAt: originalEvent ? originalEvent.createdAt || new Date().toISOString() : new Date().toISOString(), updatedAt: new Date().toISOString(), }; + console.log("EventPut", entry); await fastify.dynamoClient.send( new PutItemCommand({ TableName: genericConfig.EventsDynamoTableName, Item: marshall(entry), }), ); + + switch (obj[0]) { + case "ticket": + const ticketEntry: z.infer = obj[2]; + const ticketResponse = await fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.TicketMetadataTableName, + KeyConditionExpression: "event_id = :id", + ExpressionAttributeValues: { + ":id": { S: ticketEntry.event_id }, + }, + }), + ); + if (ticketResponse.Items?.length != 0) { + throw new Error("Event_id already exists"); + } + const ticketDBEntry = { + ...ticketEntry, + member_price: "Send to stripe API", + nonmember_price: "Send to stripe API", + }; + console.log("TicketPut", ticketDBEntry); + await fastify.dynamoClient.send( + new PutItemCommand({ + TableName: genericConfig.TicketMetadataTableName, + Item: marshall(ticketDBEntry), + }), + ); + break; + case "merch": + const merchEntry: z.infer = obj[2]; + const merchResponse = await fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.MerchStoreMetadataTableName, + KeyConditionExpression: "item_id = :id", + ExpressionAttributeValues: { + ":id": { S: merchEntry.item_id }, + }, + }), + ); + if (merchResponse.Items?.length != 0) { + throw new Error("Item_id already exists"); + } + const merchDBEntry = { + ...merchEntry, + member_price: "Send to stripe API", + nonmember_price: "Send to stripe API", + }; + console.log("ItemPut", merchDBEntry); + await fastify.dynamoClient.send( + new PutItemCommand({ + TableName: genericConfig.MerchStoreMetadataTableName, + Item: marshall(merchDBEntry), + }), + ); + break; + } + let verb = "created"; if (userProvidedId && userProvidedId === entryUUID) { verb = "modified"; } + /* Disable for now... try { - if (request.body.featured && !request.body.repeats) { + if (eventEntry.featured && !eventEntry.repeats) { await updateDiscord( fastify.secretsManagerClient, entry, @@ -168,7 +355,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { throw e; } throw new DiscordEventError({}); - } + } */ reply.status(201).send({ id: entryUUID, resource: `/api/v1/events/${entryUUID}`, @@ -278,10 +465,12 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { ); }, ); + type EventsGetRequest = { Body: undefined; Querystring?: EventsGetQueryParams; }; + fastify.get( "/", { diff --git a/src/api/routes/paidEvents.ts b/src/api/routes/paidEvents.ts new file mode 100644 index 0000000..693ef61 --- /dev/null +++ b/src/api/routes/paidEvents.ts @@ -0,0 +1,465 @@ +import { FastifyPluginAsync, FastifyRequest } from "fastify"; +import { + DynamoDBClient, + UpdateItemCommand, + QueryCommand, + ScanCommand, + ConditionalCheckFailedException, + PutItemCommand, + DeleteItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "../../common/config.js"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; +import { DatabaseFetchError } from "../../common/errors/index.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { AppRoles } from "../../common/roles.js"; + +const dynamoclient = new DynamoDBClient({ + region: genericConfig.AwsRegion, +}); + +type EventGetRequest = { + Params: { id: string }; + Querystring: undefined; + Body: undefined; +}; + +type EventUpdateRequest = { + Params: { id: string }; + Querystring: undefined; + Body: { attribute: string; value: string }; +}; + +type EventDeleteRequest = { + Params: { id: string }; + Querystring: undefined; + Body: undefined; +}; + +const TicketPostSchema = z.object({ + event_id: z.string(), + event_name: z.string(), + eventCost: z.optional(z.record(z.number())), + eventDetails: z.string(), + eventImage: z.string(), + event_capacity: z.number(), + event_sales_active_utc: z.number(), + event_time: z.number(), + member_price: z.optional(z.string()), + nonmember_price: z.optional(z.string()), + tickets_sold: z.number(), +}); + +const MerchPostSchema = z.object({ + item_id: z.string(), + item_email_desc: z.string(), + item_image: z.string(), + item_name: z.string(), + item_price: z.optional(z.record(z.string(), z.number())), + item_sales_active_utc: z.number(), + limit_per_person: z.number(), + member_price: z.optional(z.string()), + nonmember_price: z.optional(z.string()), + ready_for_pickup: z.boolean(), + sizes: z.optional(z.array(z.string())), + total_avail: z.optional(z.record(z.string(), z.string())), +}); + +type TicketPostSchema = z.infer; +type MerchPostSchema = z.infer; + +const responseJsonSchema = zodToJsonSchema( + z.object({ + id: z.string(), + resource: z.string(), + }), +); + +const paidEventsPlugin: FastifyPluginAsync = async (fastify, _options) => { + //Healthz + fastify.get("/", (request, reply) => { + reply.send({ Status: "Up" }); + }); + + fastify.get("/ticketEvents", async (request, reply) => { + try { + const response = await dynamoclient.send( + new ScanCommand({ + TableName: genericConfig.TicketMetadataTableName, + }), + ); + const items = response.Items?.map((item) => unmarshall(item)); + reply + .header( + "cache-control", + "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", + ) + .send(items); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed" + e.toString()); + } else { + request.log.error(`Failed to get from DynamoDB. ${e}`); + } + throw new DatabaseFetchError({ + message: "Failed to get events from Dynamo table.", + }); + } + }); + + fastify.get("/merchEvents", async (request, reply) => { + try { + const response = await dynamoclient.send( + new ScanCommand({ + TableName: genericConfig.MerchStoreMetadataTableName, + }), + ); + const items = response.Items?.map((item) => unmarshall(item)); + reply + .header( + "cache-control", + "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", + ) + .send(items); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed" + e.toString()); + } else { + request.log.error(`Failed to get from DynamoDB. ${e}`); + } + throw new DatabaseFetchError({ + message: "Failed to get events from Dynamo table.", + }); + } + }); + + fastify.get( + "/ticketEvents/:id", + async (request: FastifyRequest, reply) => { + const id = request.params.id; + try { + const response = await dynamoclient.send( + new QueryCommand({ + TableName: genericConfig.TicketMetadataTableName, + KeyConditionExpression: "event_id = :id", + ExpressionAttributeValues: { + ":id": { S: id }, + }, + }), + ); + const items = response.Items?.map((item) => unmarshall(item)); + if (items?.length !== 1) { + throw new Error("Event not found"); + } + reply.send(items[0]); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to get from DynamoDB: " + e.toString()); + } + throw new DatabaseFetchError({ + message: "Failed to get event from Dynamo table.", + }); + } + }, + ); + + //Get merchEvents by id + fastify.get( + "/merchEvents/:id", + async (request: FastifyRequest, reply) => { + const id = request.params.id; + try { + const response = await dynamoclient.send( + new QueryCommand({ + TableName: genericConfig.MerchStoreMetadataTableName, + KeyConditionExpression: "item_id = :id", + ExpressionAttributeValues: { + ":id": { S: id }, + }, + }), + ); + const items = response.Items?.map((item) => unmarshall(item)); + if (items?.length !== 1) { + throw new Error("Event not found"); + } + reply.send(items[0]); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to get from DynamoDB: " + e.toString()); + } + throw new DatabaseFetchError({ + message: "Failed to get event from Dynamo table.", + }); + } + }, + ); + + //Update ticketEvents by id + fastify.put( + "/ticketEvents/:id", + { + schema: { + response: { 200: responseJsonSchema }, + }, + /*onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); + },*/ //Validation taken off for testing + }, + async (request: FastifyRequest, reply) => { + try { + const id = request.params.id; + const attribute = request.body.attribute; + const value = request.body.value; + + let valueExpression; + const temp = Number(value); + if (isNaN(temp)) { + valueExpression = { S: value }; + } else { + valueExpression = { N: value }; + } + + const _response = await dynamoclient.send( + new UpdateItemCommand({ + TableName: genericConfig.TicketMetadataTableName, + Key: { + event_id: { S: id }, + }, + ConditionExpression: "attribute_exists(#attr)", + UpdateExpression: "SET #attr = :value", + ExpressionAttributeNames: { + "#attr": attribute, + }, + ExpressionAttributeValues: { + ":value": valueExpression, + }, + }), + ); + reply.send({ + id: id, + resource: `/api/v1/paidEvents/ticketEvents/${id}`, + }); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to update to DynamoDB: " + e.toString()); + } + if (e instanceof ConditionalCheckFailedException) { + request.log.error("Attribute does not exist"); + } + throw new DatabaseFetchError({ + message: "Failed to update event in Dynamo table.", + }); + } + }, + ); + + //Update merchEvents by id + fastify.put( + "/merchEvents/:id", + { + schema: { + response: { 200: responseJsonSchema }, + }, + /*onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); + },*/ //Validatison taken off for testing + }, + async (request: FastifyRequest, reply) => { + try { + const id = request.params.id; + const attribute = request.body.attribute; + const value = request.body.value; + + let valueExpression; + const num = Number(value); + if (isNaN(num)) { + valueExpression = { S: value }; + } else { + valueExpression = { N: value }; + } + + const _response = await dynamoclient.send( + new UpdateItemCommand({ + TableName: genericConfig.MerchStoreMetadataTableName, + Key: { + item_id: { S: id }, + }, + ConditionExpression: "attribute_exists(#attr)", + UpdateExpression: "SET #attr = :value", + ExpressionAttributeNames: { + "#attr": attribute, + }, + ExpressionAttributeValues: { + ":value": valueExpression, + }, + }), + ); + reply.send({ + id: id, + resource: `/api/v1/paidEvents/merchEvents/${id}`, + }); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to update to DynamoDB: " + e.toString()); + } + if (e instanceof ConditionalCheckFailedException) { + request.log.error("Attribute does not exist"); + } + throw new DatabaseFetchError({ + message: "Failed to update event in Dynamo table.", + }); + } + }, + ); + + //Post ticketEvents + fastify.post<{ Body: TicketPostSchema }>( + "/ticketEvents", + { + schema: { + response: { 200: responseJsonSchema }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody(request, reply, TicketPostSchema); + }, + /*onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); + },*/ //validation taken off + }, + async (request: FastifyRequest<{ Body: TicketPostSchema }>, reply) => { + const id = request.body.event_id; + try { + //Verify if event_id already exists + const response = await dynamoclient.send( + new QueryCommand({ + TableName: genericConfig.TicketMetadataTableName, + KeyConditionExpression: "event_id = :id", + ExpressionAttributeValues: { + ":id": { S: id }, + }, + }), + ); + if (response.Items?.length != 0) { + throw new Error("Event_id already exists"); + } + const entry = { + ...request.body, + member_price: "Send to stripe API", + nonmember_price: "Send to stripe API", + }; + await dynamoclient.send( + new PutItemCommand({ + TableName: genericConfig.TicketMetadataTableName, + Item: marshall(entry), + }), + ); + reply.send({ + id: id, + resource: `/api/v1/paidEvents/ticketEvents/${id}`, + }); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to post to DynamoDB: " + e.toString()); + } + throw new DatabaseFetchError({ + message: "Failed to post event to Dynamo table.", + }); + } + }, + ); + + //Post merchEvents + fastify.post<{ Body: MerchPostSchema }>( + "/merchEvents", + { + schema: { + response: { 200: responseJsonSchema }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody(request, reply, MerchPostSchema); + }, + /*onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); + },*/ //validation taken off + }, + async (request: FastifyRequest<{ Body: MerchPostSchema }>, reply) => { + const id = request.body.item_id; + try { + //Verify if item_id already exists + const response = await dynamoclient.send( + new QueryCommand({ + TableName: genericConfig.TicketMetadataTableName, + KeyConditionExpression: "item_id = :id", + ExpressionAttributeValues: { + ":id": { S: id }, + }, + }), + ); + if (response.Items?.length != 0) { + throw new Error("Item_id already exists"); + } + const entry = { + ...request.body, + member_price: "Send to stripe API", + nonmember_price: "Send to stripe API", + }; + await dynamoclient.send( + new PutItemCommand({ + TableName: genericConfig.TicketMetadataTableName, + Item: marshall(entry), + }), + ); + reply.send({ + id: id, + resource: `/api/v1/paidEvents/merchEvents/${id}`, + }); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to post to DynamoDB: " + e.toString()); + } + throw new DatabaseFetchError({ + message: "Failed to post event to Dynamo table.", + }); + } + }, + ); + + fastify.delete( + "/ticketEvents/:id", + { + schema: { + response: { 200: responseJsonSchema }, + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); + }, //auth + }, + async (request: FastifyRequest, reply) => { + const id = request.params.id; + try { + await dynamoclient.send( + new DeleteItemCommand({ + TableName: genericConfig.TicketMetadataTableName, + Key: { + event_id: { S: id }, + }, + }), + ); + reply.send({ + id: id, + resource: `/api/v1/paidEvents/ticketEvents/${id}`, + }); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to delete from DynamoDB: " + e.toString()); + } + throw new DatabaseFetchError({ + message: "Failed to delete event from Dynamo table.", + }); + } + }, + ); +}; + +export default paidEventsPlugin; diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx index 9dc36e7..802b73a 100644 --- a/src/ui/pages/events/ManageEvent.page.tsx +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -1,4 +1,16 @@ -import { Title, Box, TextInput, Textarea, Switch, Select, Button, Loader } from '@mantine/core'; +import { + Title, + Box, + TextInput, + Textarea, + Switch, + Select, + Button, + Loader, + Group, + NumberInput, + Checkbox, +} from '@mantine/core'; import { DateTimePicker } from '@mantine/dates'; import { useForm, zodResolver } from '@mantine/form'; import { notifications } from '@mantine/notifications'; @@ -51,6 +63,7 @@ type EventPostRequest = z.infer; export const ManageEventPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); + const [isPaidEvent, setIsPaidEvent] = useState<'ticket' | 'merch' | undefined>(undefined); const navigate = useNavigate(); const api = useApi('core'); @@ -128,6 +141,7 @@ export const ManageEventPage: React.FC = () => { setIsSubmitting(true); const realValues = { ...values, + type: isPaidEvent, start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, repeatEnds: @@ -136,7 +150,6 @@ export const ManageEventPage: React.FC = () => { : undefined, repeats: values.repeats ? values.repeats : undefined, }; - const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; const response = await api.post(eventURL, realValues); notifications.show({ @@ -153,12 +166,19 @@ export const ManageEventPage: React.FC = () => { } }; + const handleFormClose = () => { + navigate('/events/manage'); + }; + return ( - - - {isEditing ? `Edit` : `Create`} Event - + + + {isEditing ? `Edit` : `Create`} Event + +
{ {...form.getInputProps('repeatEnds')} /> )} - + + + { + setIsPaidEvent(isPaidEvent === 'ticket' ? undefined : 'ticket'); + }} + > + { + setIsPaidEvent(isPaidEvent === 'merch' ? undefined : 'merch'); + }} + > + + + {isPaidEvent != undefined && ( + + )} + + {isPaidEvent == 'ticket' && ( + + + + + + + form.setFieldValue('event_capacity', parseInt(event.toString())), + }} + /> + + form.setFieldValue('event_sales_active_utc', parseInt(event.toString())), + }} + /> + form.setFieldValue('event_time', parseInt(event.toString())), + }} + /> + + form.setFieldValue('tickets_sold', parseInt(event.toString())), + }} + /> + + )} + {isPaidEvent == 'merch' && ( + + + + + + + + form.setFieldValue('item_sales_active_utc', parseInt(event.toString())), + }} + /> + + form.setFieldValue('limit_per_person', parseInt(event.toString())), + }} + /> + + + )}