diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 5101bd29..7ced4b53 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -72,6 +72,14 @@ Resources: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-external + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status + # Index accesses + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/* + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/* + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/index/* + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests/index/* + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status/index/* - Sid: DynamoDBCacheAccess Effect: Allow @@ -94,15 +102,6 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter - - Sid: DynamoDBIndexAccess - Effect: Allow - Action: - - dynamodb:Query - Resource: - - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/* - - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/* - - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/index/* - - Sid: DynamoDBStreamAccess Effect: Allow Action: @@ -211,6 +210,19 @@ Resources: ForAllValues:StringLike: ses:Recipients: - "*@illinois.edu" + - PolicyName: ses-notifications + PolicyDocument: + Version: "2012-10-17" + Statement: + - Action: + - ses:SendEmail + - ses:SendRawEmail + Effect: Allow + Resource: "*" + Condition: + StringEquals: + ses:FromAddress: + Fn::Sub: "notifications@${SesEmailDomain}" - PolicyName: ses-sales PolicyDocument: Version: "2012-10-17" diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 3371dc29..5274b22f 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -307,6 +307,70 @@ Resources: - AttributeName: netid_list KeyType: HASH + RoomRequestsTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Retain" + UpdateReplacePolicy: "Retain" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-room-requests + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: !If [IsProd, true, false] + AttributeDefinitions: + - AttributeName: userId#requestId + AttributeType: S + - AttributeName: requestId + AttributeType: S + - AttributeName: semesterId + AttributeType: S + KeySchema: + - AttributeName: semesterId + KeyType: HASH + - AttributeName: userId#requestId + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: RequestIdIndex + KeySchema: + - AttributeName: requestId + KeyType: HASH + Projection: + ProjectionType: ALL + + RoomRequestUpdatesTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Retain" + UpdateReplacePolicy: "Retain" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-room-requests-status + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: !If [IsProd, true, false] + AttributeDefinitions: + - AttributeName: requestId + AttributeType: S + - AttributeName: semesterId + AttributeType: S + - AttributeName: createdAt#status + AttributeType: S + KeySchema: + - AttributeName: requestId + KeyType: HASH + - AttributeName: createdAt#status + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: SemesterId + KeySchema: + - AttributeName: semesterId + KeyType: HASH + - AttributeName: requestId + KeyType: RANGE + Projection: + ProjectionType: ALL + + + IamGroupRolesTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" diff --git a/src/api/index.ts b/src/api/index.ts index 53678935..9e4687a4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,6 +26,7 @@ import mobileWalletRoute from "./routes/mobileWallet.js"; import stripeRoutes from "./routes/stripe.js"; import membershipPlugin from "./routes/membership.js"; import path from "path"; // eslint-disable-line import/no-nodejs-modules +import roomRequestRoutes from "./routes/roomRequests.js"; dotenv.config(); @@ -133,6 +134,7 @@ async function init(prettyPrint: boolean = false) { api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(roomRequestRoutes, { prefix: "/roomRequests" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts new file mode 100644 index 00000000..90c91fb9 --- /dev/null +++ b/src/api/routes/roomRequests.ts @@ -0,0 +1,440 @@ +import { FastifyPluginAsync } from "fastify"; +import rateLimiter from "api/plugins/rateLimiter.js"; +import { + formatStatus, + roomGetResponse, + roomRequestBaseSchema, + RoomRequestFormValues, + roomRequestPostResponse, + roomRequestSchema, + RoomRequestStatus, + RoomRequestStatusUpdatePostBody, + roomRequestStatusUpdateRequest, +} from "common/types/roomRequest.js"; +import { AppRoles } from "common/roles.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { + BaseError, + DatabaseFetchError, + DatabaseInsertError, + InternalServerError, + UnauthenticatedError, +} from "common/errors/index.js"; +import { + PutItemCommand, + QueryCommand, + TransactWriteItemsCommand, +} from "@aws-sdk/client-dynamodb"; +import { genericConfig, notificationRecipients } from "common/config.js"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; +import { z } from "zod"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; + +const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { + await fastify.register(rateLimiter, { + limit: 20, + duration: 30, + rateLimitIdentifier: "roomRequests", + }); + fastify.post<{ + Body: RoomRequestStatusUpdatePostBody; + Params: { requestId: string; semesterId: string }; + }>( + "/:semesterId/:requestId/status", + { + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_UPDATE]); + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody( + request, + reply, + roomRequestStatusUpdateRequest, + ); + }, + }, + async (request, reply) => { + if (!request.username) { + throw new InternalServerError({ + message: "Could not get username from request.", + }); + } + const requestId = request.params.requestId; + const semesterId = request.params.semesterId; + const getReservationData = new QueryCommand({ + TableName: genericConfig.RoomRequestsStatusTableName, + KeyConditionExpression: "requestId = :requestId", + FilterExpression: "#statusKey = :status", + ExpressionAttributeNames: { + "#statusKey": "status", + }, + ExpressionAttributeValues: { + ":status": { S: RoomRequestStatus.CREATED }, + ":requestId": { S: requestId }, + }, + }); + const createdNotified = + await fastify.dynamoClient.send(getReservationData); + if (!createdNotified.Items || createdNotified.Count == 0) { + throw new InternalServerError({ + message: "Could not find original reservation request details", + }); + } + const originalRequestor = unmarshall(createdNotified.Items[0]).createdBy; + if (!originalRequestor) { + throw new InternalServerError({ + message: "Could not find original reservation requestor", + }); + } + const createdAt = new Date().toISOString(); + const command = new PutItemCommand({ + TableName: genericConfig.RoomRequestsStatusTableName, + Item: marshall({ + requestId, + semesterId, + "createdAt#status": `${createdAt}#${request.body.status}`, + createdBy: request.username, + ...request.body, + }), + }); + try { + await fastify.dynamoClient.send(command); + } catch (e) { + request.log.error(e); + if (e instanceof BaseError) { + throw e; + } + throw new DatabaseInsertError({ + message: "Could not save status update.", + }); + } + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username, + reqId: request.id, + }, + payload: { + to: [originalRequestor], + subject: "Room Reservation Request Status Change", + content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig["UserFacingUrl"]}/roomRequests/${semesterId}/${requestId} to view details.`, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (!result.MessageId) { + request.log.error(result); + throw new InternalServerError({ + message: "Could not add room reservation email to queue.", + }); + } + request.log.info( + `Queued room reservation email to SQS with message ID ${result.MessageId}`, + ); + return reply.status(201).send(); + }, + ); + fastify.get<{ + Body: undefined; + Params: { semesterId: string }; + }>( + "/:semesterId", + { + schema: { + response: { + 200: zodToJsonSchema(roomGetResponse), + }, + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); + }, + }, + async (request, reply) => { + const semesterId = request.params.semesterId; + if (!request.username) { + throw new InternalServerError({ + message: "Could not retrieve username.", + }); + } + let command: QueryCommand; + if (request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)) { + command = new QueryCommand({ + TableName: genericConfig.RoomRequestsTableName, + KeyConditionExpression: "semesterId = :semesterValue", + ExpressionAttributeValues: { + ":semesterValue": { S: semesterId }, + }, + }); + } else { + command = new QueryCommand({ + TableName: genericConfig.RoomRequestsTableName, + KeyConditionExpression: "semesterId = :semesterValue", + FilterExpression: "begins_with(#hashKey, :username)", + ExpressionAttributeNames: { + "#hashKey": "userId#requestId", + }, + ProjectionExpression: "requestId, host, title, semester", + ExpressionAttributeValues: { + ":semesterValue": { S: semesterId }, + ":username": { S: request.username }, + }, + }); + } + const response = await fastify.dynamoClient.send(command); + if (!response.Items) { + throw new DatabaseFetchError({ + message: "Could not get room requests.", + }); + } + const items = response.Items.map((x) => { + const item = unmarshall(x) as { + host: string; + title: string; + requestId: string; + status: string; + }; + const statusPromise = fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.RoomRequestsStatusTableName, + KeyConditionExpression: "requestId = :requestId", + ExpressionAttributeValues: { + ":requestId": { S: item.requestId }, + }, + ProjectionExpression: "#status", + ExpressionAttributeNames: { + "#status": "status", + }, + ScanIndexForward: false, + Limit: 1, + }), + ); + + return statusPromise.then((statusResponse) => { + if ( + !statusResponse || + !statusResponse.Items || + statusResponse.Items.length == 0 + ) { + return "unknown"; + } + const statuses = statusResponse.Items.map((s) => unmarshall(s)); + const latestStatus = statuses.length > 0 ? statuses[0].status : null; + return { + ...item, + status: latestStatus, + }; + }); + }); + + const itemsWithStatus = await Promise.all(items); + + return reply.status(200).send(itemsWithStatus); + }, + ); + fastify.post<{ Body: RoomRequestFormValues }>( + "/", + { + schema: { + response: { 201: zodToJsonSchema(roomRequestPostResponse) }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody(request, reply, roomRequestSchema); + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); + }, + }, + async (request, reply) => { + const requestId = request.id; + if (!request.username) { + throw new InternalServerError({ + message: "Could not retrieve username.", + }); + } + const body = { + ...request.body, + requestId, + userId: request.username, + "userId#requestId": `${request.username}#${requestId}`, + semesterId: request.body.semester, + }; + try { + const createdAt = new Date().toISOString(); + const transactionCommand = new TransactWriteItemsCommand({ + TransactItems: [ + { + Put: { + TableName: genericConfig.RoomRequestsTableName, + Item: marshall(body), + }, + }, + { + Put: { + TableName: genericConfig.RoomRequestsStatusTableName, + Item: marshall({ + requestId, + semesterId: request.body.semester, + "createdAt#status": `${createdAt}#${RoomRequestStatus.CREATED}`, + createdBy: request.username, + status: RoomRequestStatus.CREATED, + notes: "This request was created by the user.", + }), + }, + }, + ], + }); + await fastify.dynamoClient.send(transactionCommand); + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + request.log.error(e); + throw new DatabaseInsertError({ + message: "Could not save room request.", + }); + } + reply.status(201).send({ + id: requestId, + status: RoomRequestStatus.CREATED, + }); + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username, + reqId: request.id, + }, + payload: { + to: [notificationRecipients[fastify.runEnvironment].OfficerBoard], + subject: "New Room Reservation Request", + content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig["UserFacingUrl"]}/roomRequests/${request.body.semester}/${requestId} to view details.`, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (!result.MessageId) { + request.log.error(result); + throw new InternalServerError({ + message: "Could not add room reservation email to queue.", + }); + } + request.log.info( + `Queued room reservation email to SQS with message ID ${result.MessageId}`, + ); + }, + ); + fastify.get<{ + Body: undefined; + Params: { requestId: string; semesterId: string }; + }>( + "/:semesterId/:requestId", + { + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); + }, + }, + async (request, reply) => { + const requestId = request.params.requestId; + const semesterId = request.params.semesterId; + let command; + if (request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)) { + command = new QueryCommand({ + TableName: genericConfig.RoomRequestsTableName, + IndexName: "RequestIdIndex", + KeyConditionExpression: "requestId = :requestId", + FilterExpression: "semesterId = :semesterId", + ExpressionAttributeValues: { + ":requestId": { S: requestId }, + ":semesterId": { S: semesterId }, + }, + Limit: 1, + }); + } else { + command = new QueryCommand({ + TableName: genericConfig.RoomRequestsTableName, + KeyConditionExpression: + "semesterId = :semesterId AND #userIdRequestId = :userRequestId", + ExpressionAttributeValues: { + ":userRequestId": { S: `${request.username}#${requestId}` }, + ":semesterId": { S: semesterId }, + }, + ExpressionAttributeNames: { + "#userIdRequestId": "userId#requestId", + }, + Limit: 1, + }); + } + try { + const resp = await fastify.dynamoClient.send(command); + if (!resp.Items || resp.Count != 1) { + throw new DatabaseFetchError({ + message: "Recieved no response.", + }); + } + // this isn't atomic, but that's fine - a little inconsistency on this isn't a problem. + try { + const statusesResponse = await fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.RoomRequestsStatusTableName, + KeyConditionExpression: "requestId = :requestId", + ExpressionAttributeValues: { + ":requestId": { S: requestId }, + }, + ProjectionExpression: "#createdAt,#notes,#createdBy", + ExpressionAttributeNames: { + "#createdBy": "createdBy", + "#createdAt": "createdAt#status", + "#notes": "notes", + }, + }), + ); + const updates = statusesResponse.Items?.map((x) => { + const unmarshalled = unmarshall(x); + return { + createdBy: unmarshalled["createdBy"], + createdAt: unmarshalled["createdAt#status"].split("#")[0], + status: unmarshalled["createdAt#status"].split("#")[1], + notes: unmarshalled["notes"], + }; + }); + return reply + .status(200) + .send({ data: unmarshall(resp.Items[0]), updates }); + } catch (e) { + request.log.error(e); + throw new DatabaseFetchError({ + message: "Could not get request status.", + }); + } + } catch (e) { + request.log.error(e); + if (e instanceof BaseError) { + throw e; + } + throw new DatabaseInsertError({ + message: "Could not find by ID.", + }); + } + }, + ); +}; + +export default roomRequestRoutes; diff --git a/src/api/sqs/emailNotifications.ts b/src/api/sqs/emailNotifications.ts new file mode 100644 index 00000000..1b9bddd9 --- /dev/null +++ b/src/api/sqs/emailNotifications.ts @@ -0,0 +1,58 @@ +import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; +import { currentEnvironmentConfig, SQSHandlerFunction } from "./index.js"; +import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses"; +import { genericConfig } from "common/config.js"; + +const stripHtml = (html: string): string => { + return html + .replace(/<[^>]*>/g, "") // Remove HTML tags + .replace(/ /g, " ") // Replace non-breaking spaces + .replace(/\s+/g, " ") // Normalize whitespace + .trim(); +}; + +export const emailNotificationsHandler: SQSHandlerFunction< + AvailableSQSFunctions.EmailNotifications +> = async (payload, metadata, logger) => { + const { to, cc, bcc, content, subject } = payload; + const senderEmail = `ACM @ UIUC `; + logger.info("Constructing email..."); + const command = new SendEmailCommand({ + Source: senderEmail, + Destination: { + ToAddresses: to, + CcAddresses: cc || [], + BccAddresses: bcc || [], + }, + Message: { + Subject: { + Data: subject, + Charset: "UTF-8", + }, + Body: { + Html: { + Data: content, + Charset: "UTF-8", + }, + Text: { + Data: stripHtml(content), + Charset: "UTF-8", + }, + }, + }, + }); + const sesClient = new SESClient({ region: genericConfig.AwsRegion }); + const response = await sesClient.send(command); + logger.info("Sent!"); + logger.info( + { + type: "audit", + module: "emailNotification", + actor: metadata.initiator, + reqId: metadata.reqId, + target: to, + }, + `Sent email notification with subject "${subject}".`, + ); + return response; +}; diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts index 94318cf8..2d41ca40 100644 --- a/src/api/sqs/index.ts +++ b/src/api/sqs/index.ts @@ -21,6 +21,7 @@ import { ValidationError } from "../../common/errors/index.js"; import { RunEnvironment } from "../../common/roles.js"; import { environmentConfig } from "../../common/config.js"; import { sendSaleEmailhandler } from "./sales.js"; +import { emailNotificationsHandler } from "./emailNotifications.js"; export type SQSFunctionPayloadTypes = { [K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction; @@ -37,6 +38,7 @@ const handlers: SQSFunctionPayloadTypes = { [AvailableSQSFunctions.Ping]: pingHandler, [AvailableSQSFunctions.ProvisionNewMember]: provisionNewMemberHandler, [AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailhandler, + [AvailableSQSFunctions.EmailNotifications]: emailNotificationsHandler, }; export const runEnvironment = process.env.RunEnvironment as RunEnvironment; export const currentEnvironmentConfig = environmentConfig[runEnvironment]; diff --git a/src/common/config.ts b/src/common/config.ts index bb5eb8dc..86420366 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -9,6 +9,7 @@ type ValueOrArray = T | ArrayOfValueOrArray; type AzureRoleMapping = Record; export type ConfigType = { + UserFacingUrl: string; AzureRoleMapping: AzureRoleMapping; ValidCorsOrigins: ValueOrArray | OriginFunction; AadValidClientId: string; @@ -39,6 +40,8 @@ export type GenericConfigType = { MerchStoreMetadataTableName: string; IAMTablePrefix: string; ProtectedEntraIDGroups: string[]; // these groups are too privileged to be modified via this portal and must be modified directly in Entra ID. + RoomRequestsTableName: string; + RoomRequestsStatusTableName: string; }; type EnvironmentConfigType = { @@ -71,11 +74,14 @@ const genericConfig: GenericConfigType = { IAMTablePrefix: "infra-core-api-iam", ProtectedEntraIDGroups: [infraChairsGroupId, officersGroupId], MembershipTableName: "infra-core-api-membership-provisioning", - ExternalMembershipTableName: "infra-core-api-membership-external" + ExternalMembershipTableName: "infra-core-api-membership-external", + RoomRequestsTableName: "infra-core-api-room-requests", + RoomRequestsStatusTableName: "infra-core-api-room-requests-status" } as const; const environmentConfig: EnvironmentConfigType = { dev: { + UserFacingUrl: "https://core.aws.qa.acmuiuc.org", AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, ValidCorsOrigins: [ "https://merch-pwa.pages.dev", @@ -95,6 +101,7 @@ const environmentConfig: EnvironmentConfigType = { PaidMemberPriceId: "price_1R4TcTDGHrJxx3mKI6XF9cNG", }, prod: { + UserFacingUrl: "https://core.acm.illinois.edu", AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, ValidCorsOrigins: [ /^https:\/\/(?:.*\.)?acmuiuc-academic-web\.pages\.dev$/, @@ -134,4 +141,22 @@ const roleArns = { export const EVENT_CACHED_DURATION = 120; -export { genericConfig, environmentConfig, roleArns }; +type NotificationRecipientsType = { + [env in RunEnvironment]: { + OfficerBoard: string; + InfraChairs: string; + }; +}; + +const notificationRecipients: NotificationRecipientsType = { + dev: { + OfficerBoard: 'infra@acm.illinois.edu', + InfraChairs: 'infra@acm.illinois.edu', + }, + prod: { + OfficerBoard: 'officers@acm.illinois.edu', + InfraChairs: 'infra@acm.illinois.edu', + } +} + +export { genericConfig, environmentConfig, roleArns, notificationRecipients }; diff --git a/src/common/orgs.ts b/src/common/orgs.ts index ba1091eb..61d570d2 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -17,7 +17,7 @@ export const SIGList = [ "SIGARCH", "SIGRobotics", "SIGtricity", -] as const; +] as [string, ...string[]]; export const CommitteeList = [ "Infrastructure Committee", @@ -26,5 +26,5 @@ export const CommitteeList = [ "Academic Committee", "Corporate Committee", "Marketing Committee", -] as const; -export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList]; +] as [string, ...string[]]; +export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as [string, ...string[]]; diff --git a/src/common/roles.ts b/src/common/roles.ts index c61d572c..f432de2e 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -9,6 +9,8 @@ export enum AppRoles { IAM_INVITE_ONLY = "invite:iam", STRIPE_LINK_CREATOR = "create:stripeLink", BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola", + ROOM_REQUEST_CREATE = "create:roomRequest", + ROOM_REQUEST_UPDATE = "update:roomRequest" } export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", diff --git a/src/common/types/roomRequest.ts b/src/common/types/roomRequest.ts new file mode 100644 index 00000000..ced04c7e --- /dev/null +++ b/src/common/types/roomRequest.ts @@ -0,0 +1,400 @@ +import { z } from "zod"; +import { OrganizationList } from "../orgs.js"; + +export const eventThemeOptions = [ + "Arts & Music", + "Athletics", + "Cultural", + "Fundraising", + "Group Business", + "Learning", + "Service", + "Social", + "Spirituality", +] as [string, ...string[]]; + +export function getPreviousSemesters() { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + + let semesters = []; + let currentSemester = ""; + + if (currentMonth >= 1 && currentMonth <= 5) { + currentSemester = "Spring"; + } else if (currentMonth >= 6 && currentMonth <= 12) { + currentSemester = "Fall"; + } + + if (currentSemester === "Spring") { + semesters.push({ + value: `fa${(currentYear - 1).toString().slice(-2)}`, + label: `Fall ${currentYear - 1}`, + }); + semesters.push({ + value: `sp${(currentYear - 1).toString().slice(-2)}`, + label: `Spring ${currentYear - 1}`, + }); + semesters.push({ + value: `fa${(currentYear - 2).toString().slice(-2)}`, + label: `Fall ${currentYear - 2}`, + }); + } else if (currentSemester === "Fall") { + semesters.push({ + value: `sp${currentYear.toString().slice(-2)}`, + label: `Spring ${currentYear}`, + }); + semesters.push({ + value: `fa${(currentYear - 1).toString().slice(-2)}`, + label: `Fall ${currentYear - 1}`, + }); + semesters.push({ + value: `sp${(currentYear - 1).toString().slice(-2)}`, + label: `Spring ${currentYear - 1}`, + }); + } + + return semesters.reverse(); +} + +export function getSemesters() { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + + let semesters = []; + let currentSemester = ""; + + if (currentMonth >= 1 && currentMonth <= 5) { + currentSemester = "Spring"; + } else if (currentMonth >= 6 && currentMonth <= 12) { + currentSemester = "Fall"; + } + + if (currentSemester === "Spring") { + semesters.push({ + value: `sp${currentYear.toString().slice(-2)}`, + label: `Spring ${currentYear}`, + }); + semesters.push({ + value: `fa${currentYear.toString().slice(-2)}`, + label: `Fall ${currentYear}`, + }); + semesters.push({ + value: `sp${(currentYear + 1).toString().slice(-2)}`, + label: `Spring ${currentYear + 1}`, + }); + } else if (currentSemester === "Fall") { + semesters.push({ + value: `fa${currentYear.toString().slice(-2)}`, + label: `Fall ${currentYear}`, + }); + semesters.push({ + value: `sp${(currentYear + 1).toString().slice(-2)}`, + label: `Spring ${currentYear + 1}`, + }); + semesters.push({ + value: `fa${(currentYear + 1).toString().slice(-2)}`, + label: `Fall ${currentYear + 1}`, + }); + } + + return semesters; +} + +export const spaceTypeOptions = [ + { value: "campus_classroom", label: "Campus Classroom" }, + { value: "campus_performance", label: "Campus Performance Space *" }, + { value: "bif", label: "Business Instructional Facility (BIF)" }, + { + value: "campus_rec", + label: "Campus Rec (ARC, CRCE, Ice Arena, Illini Grove) *", + }, + { value: "illini_union", label: "Illini Union *" }, + { value: "stock_pavilion", label: "Stock Pavilion" }, +]; + +export const specificRoomSetupRooms = [ + "illini_union", + "campus_performance", + "campus_rec", +]; + +export enum RoomRequestStatus { + CREATED = "created", + MORE_INFORMATION_NEEDED = "more_information_needed", + REJECTED_BY_ACM = "rejected_by_acm", + SUBMITTED = "submitted", + APPROVED = "approved", + REJECTED_BY_UIUC = "rejected_by_uiuc", +} + +export const roomRequestStatusUpdateRequest = z.object({ + status: z.nativeEnum(RoomRequestStatus), + notes: z.optional(z.string().min(1).max(1000)), +}); + +export const roomRequestStatusUpdate = roomRequestStatusUpdateRequest.extend({ + createdAt: z.string().datetime(), + createdBy: z.string().email(), +}); + +export const roomRequestPostResponse = z.object({ + id: z.string().uuid(), + status: z.literal(RoomRequestStatus.CREATED), +}); + +export const roomRequestBaseSchema = z.object({ + host: z.enum(OrganizationList), + title: z.string().min(2, "Title must have at least 2 characters"), + semester: z + .string() + .regex(/^(fa|sp|su|wi)\d{2}$/, "Invalid semester provided"), +}); + +export const roomRequestSchema = roomRequestBaseSchema + .extend({ + eventStart: z.coerce.date({ + required_error: "Event start date and time is required", + invalid_type_error: "Event start must be a valid date and time", + }), + eventEnd: z.coerce.date({ + required_error: "Event end date and time is required", + invalid_type_error: "Event end must be a valid date and time", + }), + theme: z.enum(eventThemeOptions, { + required_error: "Event theme must be provided", + invalid_type_error: "Event theme must be provided", + }), + description: z + .string() + .min(10, "Description must have at least 10 words") + .max(1000, "Description cannot exceed 1000 characters") + .refine((val) => val.split(/\s+/).filter(Boolean).length >= 10, { + message: "Description must have at least 10 words", + }), + // Recurring event fields + isRecurring: z.boolean().default(false), + recurrencePattern: z.enum(["weekly", "biweekly", "monthly"]).optional(), + recurrenceEndDate: z.date().optional(), + // Setup time fields + setupNeeded: z.boolean().default(false), + setupMinutesBefore: z.number().min(5).max(60).optional(), + // Existing fields + hostingMinors: z.boolean(), + locationType: z.enum(["in-person", "virtual", "both"]), + spaceType: z.string().min(1), + specificRoom: z.string().min(1), + estimatedAttendees: z.number().positive(), + seatsNeeded: z.number().positive(), + setupDetails: z.string().min(1).nullable().optional(), + onCampusPartners: z.string().min(1).nullable(), + offCampusPartners: z.string().min(1).nullable(), + nonIllinoisSpeaker: z.string().min(1).nullable(), + nonIllinoisAttendees: z.number().min(1).nullable(), + foodOrDrink: z.boolean(), + crafting: z.boolean(), + comments: z.string().optional(), + }) + .refine( + (data) => { + // Check if end time is after start time + if (data.eventStart && data.eventEnd) { + return data.eventEnd > data.eventStart; + } + return true; + }, + { + message: "End date/time must be after start date/time", + path: ["eventEnd"], + }, + ) + .refine( + (data) => { + // If recurrence is enabled, recurrence pattern must be provided + if (data.isRecurring) { + return !!data.recurrencePattern; + } + return true; + }, + { + message: "Please select a recurrence pattern", + path: ["recurrencePattern"], + }, + ) + .refine( + (data) => { + // If recurrence is enabled, end date must be provided + if (data.isRecurring) { + return !!data.recurrenceEndDate; + } + return true; + }, + { + message: "Please select an end date for the recurring event", + path: ["recurrenceEndDate"], + }, + ) + .refine( + (data) => { + if (data.isRecurring && data.recurrenceEndDate && data.eventStart) { + const endDateWithTime = new Date(data.recurrenceEndDate); + endDateWithTime.setHours(23, 59, 59, 999); + return endDateWithTime >= data.eventStart; + } + return true; + }, + { + message: "End date must be on or after the event start date", + path: ["recurrenceEndDate"], + }, + ) + .refine( + (data) => { + // If setup is needed, setupMinutesBefore must be provided + if (data.setupNeeded) { + return !!data.setupMinutesBefore; + } + return true; + }, + { + message: + "Please specify how many minutes before the event you need for setup", + path: ["setupMinutesBefore"], + }, + ) + .refine( + (data) => { + if (data.setupDetails === undefined && specificRoomSetupRooms.includes(data.spaceType)) { + return false; + } + if (data.setupDetails && !specificRoomSetupRooms.includes(data.spaceType)) { + return false; + } + return true; + }, + { + message: "Invalid setup details response.", + path: ["setupDetails"], + }, + ) + .superRefine((data, ctx) => { + // Additional validation for conditional fields based on locationType + if (data.locationType === "in-person" || data.locationType === "both") { + if (!data.spaceType || data.spaceType.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please select a space type", + path: ["spaceType"], + }); + } + + if (!data.specificRoom || data.specificRoom.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please provide details about the room location", + path: ["specificRoom"], + }); + } + + if (!data.estimatedAttendees || data.estimatedAttendees <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please provide an estimated number of attendees", + path: ["estimatedAttendees"], + }); + } + + if (!data.seatsNeeded || data.seatsNeeded <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please specify how many seats you need", + path: ["seatsNeeded"], + }); + } else if ( + data.estimatedAttendees && + data.seatsNeeded < data.estimatedAttendees + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Number of seats must be greater than or equal to number of attendees", + path: ["seatsNeeded"], + }); + } + } + + // Validate conditional partner fields + if (data.onCampusPartners === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please provide details about on-campus partners", + path: ["onCampusPartners"], + }); + } + + if (data.offCampusPartners === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please provide details about off-campus partners", + path: ["offCampusPartners"], + }); + } + + if (data.nonIllinoisSpeaker === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Please provide details about non-UIUC speakers", + path: ["nonIllinoisSpeaker"], + }); + } + + if (data.nonIllinoisAttendees === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Percentage must be greater than 0", + path: ["nonIllinoisAttendees"], + }); + } + }); + +export type RoomRequestFormValues = z.infer; + +export const roomRequestGetResponse = z.object({ + data: roomRequestSchema, + updates: z.array(roomRequestStatusUpdate), +}); + +export type RoomRequestPostResponse = z.infer; + +export type RoomRequestStatusUpdate = z.infer; + +export type RoomRequestGetResponse = z.infer; + +export type RoomRequestStatusUpdatePostBody = z.infer< + typeof roomRequestStatusUpdateRequest +>; + +export const roomGetResponse = z.array( + roomRequestBaseSchema.extend({ + requestId: z.string().uuid(), + status: z.nativeEnum(RoomRequestStatus), + }), +); + +export type RoomRequestGetAllResponse = z.infer; + +export function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +export const formatStatus = (status: RoomRequestStatus) => { + if (status === RoomRequestStatus.SUBMITTED) { + return 'Submitted to UIUC'; + } + return capitalizeFirstLetter(status) + .replaceAll('_', ' ') + .replaceAll('uiuc', 'UIUC') + .replaceAll('acm', 'ACM'); +}; diff --git a/src/common/types/sqsMessage.ts b/src/common/types/sqsMessage.ts index bed7b13e..09fd2178 100644 --- a/src/common/types/sqsMessage.ts +++ b/src/common/types/sqsMessage.ts @@ -5,6 +5,7 @@ export enum AvailableSQSFunctions { EmailMembershipPass = "emailMembershipPass", ProvisionNewMember = "provisionNewMember", SendSaleEmail = "sendSaleEmail", + EmailNotifications = "emailNotifications" } const sqsMessageMetadataSchema = z.object({ @@ -55,6 +56,15 @@ export const sqsPayloadSchemas = { type: z.union([z.literal('event'), z.literal('merch')]) }), ), + [AvailableSQSFunctions.EmailNotifications]: createSQSSchema( + AvailableSQSFunctions.EmailNotifications, z.object({ + to: z.array(z.string().email()).min(1), + cc: z.optional(z.array(z.string().email()).min(1)), + bcc: z.optional(z.array(z.string().email()).min(1)), + subject: z.string().min(1), + content: z.string().min(1), + }) + ) } as const; export const sqsPayloadSchema = z.discriminatedUnion("function", [ @@ -62,6 +72,7 @@ export const sqsPayloadSchema = z.discriminatedUnion("function", [ sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass], sqsPayloadSchemas[AvailableSQSFunctions.ProvisionNewMember], sqsPayloadSchemas[AvailableSQSFunctions.SendSaleEmail], + sqsPayloadSchemas[AvailableSQSFunctions.EmailNotifications], ] as const); export type SQSPayload = z.infer< diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 5e8528e3..0c48d165 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -19,6 +19,8 @@ import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; import { ManageIamPage } from './pages/iam/ManageIam.page'; import { ManageProfilePage } from './pages/profile/ManageProfile.page'; import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page'; +import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page'; +import { ViewRoomRequest } from './pages/roomRequest/ViewRoomRequest.page'; const ProfileRediect: React.FC = () => { const location = useLocation(); @@ -162,6 +164,14 @@ const authenticatedRouter = createBrowserRouter([ path: '/stripe', element: , }, + { + path: '/roomRequests', + element: , + }, + { + path: '/roomRequests/:semesterId/:requestId', + element: , + }, // Catch-all route for authenticated users shows 404 page { path: '*', diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index 8b5183ab..fc89823e 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -17,6 +17,7 @@ import { IconPizza, IconTicket, IconLock, + IconDoor, } from '@tabler/icons-react'; import { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -65,6 +66,13 @@ export const navItems = [ description: null, validRoles: [AppRoles.STRIPE_LINK_CREATOR], }, + { + link: '/roomRequests', + name: 'Room Requests', + icon: IconDoor, + description: null, + validRoles: [AppRoles.ROOM_REQUEST_CREATE, AppRoles.ROOM_REQUEST_UPDATE], + }, ]; export const extLinks = [ diff --git a/src/ui/pages/roomRequest/ExistingRoomRequests.tsx b/src/ui/pages/roomRequest/ExistingRoomRequests.tsx new file mode 100644 index 00000000..1b2fcf8a --- /dev/null +++ b/src/ui/pages/roomRequest/ExistingRoomRequests.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import { RoomRequestGetAllResponse } from '@common/types/roomRequest'; +import { Badge, Loader, Table } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { getStatusColor } from './roomRequestUtils'; +import { formatStatus } from '@common/types/roomRequest'; + +interface ExistingRoomRequestsProps { + getRoomRequests: (semester: string) => Promise; + semester: string; +} +const ExistingRoomRequests: React.FC = ({ + getRoomRequests, + semester, +}) => { + const [data, setData] = useState(null); + const navigate = useNavigate(); + useEffect(() => { + const inner = async () => { + setData(await getRoomRequests(semester)); + }; + inner(); + }, [semester]); + return ( + <> + + + + Name + Host + Status + + + {!data && } + {data && ( + + {data.map((item) => { + return ( + + navigate(`/roomRequests/${item.semester}/${item.requestId}`)} + style={{ cursor: 'pointer', color: 'var(--mantine-color-blue-6)' }} + > + {item.title} + + {item.host} + + {formatStatus(item.status)} + + + ); + })} + + )} +
+ + ); +}; + +export default ExistingRoomRequests; diff --git a/src/ui/pages/roomRequest/NewRoomRequest.test.tsx b/src/ui/pages/roomRequest/NewRoomRequest.test.tsx new file mode 100644 index 00000000..d6f20242 --- /dev/null +++ b/src/ui/pages/roomRequest/NewRoomRequest.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { vi } from 'vitest'; +import NewRoomRequest from './NewRoomRequest'; +import { MantineProvider } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import userEvent from '@testing-library/user-event'; +import { RoomRequestStatus } from '@common/types/roomRequest'; + +// Mock the navigate function +const mockNavigate = vi.fn(); + +// Mock the react-router-dom module +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('NewRoomRequest component tests', () => { + const mockCreateRoomRequest = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = async (props = {}) => { + await act(async () => { + render( + + + + + + ); + }); + }; + + it('renders basic form elements', async () => { + await renderComponent(); + + expect(screen.getByText('Semester')).toBeInTheDocument(); + expect(screen.getByText('Event Host')).toBeInTheDocument(); + expect(screen.getByText('Event Title')).toBeInTheDocument(); + expect(screen.getByText('Event Theme')).toBeInTheDocument(); + expect(screen.getByText('Event Description')).toBeInTheDocument(); + expect(screen.getByText('Event Start')).toBeInTheDocument(); + expect(screen.getByText('Event End')).toBeInTheDocument(); + expect(screen.getByText('This is a recurring event')).toBeInTheDocument(); + expect(screen.getByText('I need setup time before the event')).toBeInTheDocument(); + }); + + it('shows recurring event form fields when checkbox is clicked', async () => { + const user = userEvent.setup(); + await renderComponent(); + + // Initially, recurring event fields should not be visible + expect(screen.queryByText('Recurrence Pattern')).not.toBeInTheDocument(); + expect(screen.queryByText('Recurrence End Date')).not.toBeInTheDocument(); + + // Click the recurring event checkbox + const recurringCheckbox = screen.getByLabelText('This is a recurring event'); + await user.click(recurringCheckbox); + + // Recurring event fields should now be visible + expect(screen.getByText('Recurrence Pattern')).toBeInTheDocument(); + expect(screen.getByText('Recurrence End Date')).toBeInTheDocument(); + }); + + it('shows setup time field when setup checkbox is clicked', async () => { + const user = userEvent.setup(); + await renderComponent(); + + // Initially, setup time field should not be visible + expect(screen.queryByText('Minutes needed for setup before event')).not.toBeInTheDocument(); + + // Click the setup time checkbox + const setupCheckbox = screen.getByLabelText('I need setup time before the event'); + await user.click(setupCheckbox); + + // Setup time field should now be visible + expect(screen.getByText('Minutes needed for setup before event')).toBeInTheDocument(); + }); + + it('should set initial values correctly in view-only mode', async () => { + const mockInitialValues = { + host: 'ACM', + title: 'Test Event', + semester: 'fa24', + description: 'This is a test event description that is at least ten words long.', + theme: 'Social', + eventStart: new Date('2024-12-15T15:00:00'), + eventEnd: new Date('2024-12-15T17:00:00'), + isRecurring: false, + setupNeeded: false, + hostingMinors: false, + locationType: 'in-person', + spaceType: 'campus_classroom', + specificRoom: 'Siebel 1404', + estimatedAttendees: 30, + seatsNeeded: 35, + onCampusPartners: null, + offCampusPartners: null, + nonIllinoisSpeaker: null, + nonIllinoisAttendees: null, + foodOrDrink: true, + crafting: false, + comments: 'No additional comments.', + }; + + await renderComponent({ + initialValues: mockInitialValues, + viewOnly: true, + }); + + // The title should be visible in view-only mode + expect(screen.getByDisplayValue('Test Event')).toBeInTheDocument(); + + // Submit button should not be visible in view-only mode + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + }); + + it('should show error notification on API failure', async () => { + const notificationsMock = vi.spyOn(notifications, 'show'); + mockCreateRoomRequest.mockRejectedValue(new Error('API Error')); + + // Simply verify the error notification behavior + await act(async () => { + try { + await mockCreateRoomRequest({}); + } catch (e) { + notifications.show({ + color: 'red', + title: 'Failed to submit room request', + message: 'Please try again or contact support.', + }); + } + }); + + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + color: 'red', + title: 'Failed to submit room request', + message: 'Please try again or contact support.', + }) + ); + + notificationsMock.mockRestore(); + }); +}); diff --git a/src/ui/pages/roomRequest/NewRoomRequest.tsx b/src/ui/pages/roomRequest/NewRoomRequest.tsx new file mode 100644 index 00000000..aef7eda4 --- /dev/null +++ b/src/ui/pages/roomRequest/NewRoomRequest.tsx @@ -0,0 +1,724 @@ +import { useState, useEffect, ReactNode } from 'react'; +import { + Stepper, + Button, + Group, + TextInput, + Code, + Select, + Textarea, + Radio, + NumberInput, + Stack, + Title, + Paper, + Text, + Loader, + Checkbox, +} from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { DateInput, DateTimePicker } from '@mantine/dates'; +import { OrganizationList } from '@common/orgs'; +import { + eventThemeOptions, + spaceTypeOptions, + RoomRequestFormValues, + RoomRequestPostResponse, + getSemesters, + roomRequestSchema, + specificRoomSetupRooms, +} from '@common/types/roomRequest'; +import { useNavigate } from 'react-router-dom'; +import { notifications } from '@mantine/notifications'; + +// Component for yes/no questions with conditional content +interface ConditionalFieldProps { + label: string; + description?: string; + field: string; + form: any; // The form object from useForm + conditionalContent: ReactNode; + required?: boolean; +} + +const ConditionalField: React.FC = ({ + label, + description, + field, + form, + conditionalContent, + required = true, +}) => { + // Get the current value to determine state + const value = form.values[field]; + // undefined = unanswered, null = "No", any value = "Yes" + const radioValue = value === undefined ? '' : value === null ? 'no' : 'yes'; + + return ( + + { + if (val === 'no') { + form.setFieldValue(field, null); + } else if (val === 'yes') { + if (field === 'nonIllinoisAttendees') { + form.setFieldValue(field, 0); + } else { + form.setFieldValue(field, ''); + } + } else { + form.setFieldValue(field, undefined); + } + }} + error={form.errors[field]} + > + + + + + + + {value !== null && value !== undefined && conditionalContent} + + ); +}; + +// Component for simple yes/no questions without additional details +interface YesNoFieldProps { + label: string; + description?: string; + field: string; + form: any; + required?: boolean; +} + +const YesNoField: React.FC = ({ + label, + description, + field, + form, + required = true, +}) => { + const value = form.values[field]; + const radioValue = value === undefined ? '' : value === true ? 'yes' : 'no'; + + return ( + { + if (val === 'yes') { + form.setFieldValue(field, true); + } else if (val === 'no') { + form.setFieldValue(field, false); + } else { + form.setFieldValue(field, undefined); + } + }} + error={form.errors[field]} + > + + + + + + ); +}; + +interface NewRoomRequestProps { + createRoomRequest?: (payload: RoomRequestFormValues) => Promise; + initialValues?: RoomRequestFormValues; + viewOnly?: boolean; +} + +const recurrencePatternOptions = [ + { value: 'weekly', label: 'Weekly' }, + { value: 'biweekly', label: 'Bi-weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +const NewRoomRequest: React.FC = ({ + createRoomRequest, + initialValues, + viewOnly, +}) => { + const [active, setActive] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const numSteps = 4; + const navigate = useNavigate(); + const semesterOptions = getSemesters(); + const semesterValues = semesterOptions.map((x) => x.value); + + // Initialize with today's date and times + let startingDate = new Date(); + startingDate = new Date(startingDate.setMinutes(0)); + startingDate = new Date(startingDate.setDate(startingDate.getDate() + 1)); + const oneHourAfterStarting = new Date(startingDate.getTime() + 60 * 60 * 1000); + + type InterimRoomRequestFormValues = { + [K in keyof RoomRequestFormValues]: RoomRequestFormValues[K] extends any + ? RoomRequestFormValues[K] | undefined + : RoomRequestFormValues[K]; + }; + + const form = useForm({ + enhanceGetInputProps: () => ({ readOnly: viewOnly }), + initialValues: + initialValues || + ({ + host: '', + title: '', + theme: '', + semester: '', + description: '', + eventStart: startingDate, + eventEnd: oneHourAfterStarting, + isRecurring: false, + recurrencePattern: undefined, + recurrenceEndDate: undefined, + setupNeeded: false, + hostingMinors: undefined, + locationType: undefined, + spaceType: '', + specificRoom: '', + estimatedAttendees: undefined, + seatsNeeded: undefined, + setupDetails: undefined, + onCampusPartners: undefined, + offCampusPartners: undefined, + nonIllinoisSpeaker: undefined, + nonIllinoisAttendees: undefined, + foodOrDrink: undefined, + crafting: undefined, + comments: '', + } as InterimRoomRequestFormValues), + + validate: (values) => { + // Get all validation errors from zod, which returns ReactNode + const allErrors: Record = zodResolver(roomRequestSchema)(values); + + // If in view mode, return no errors + if (viewOnly) { + return {}; + } + + // Define which fields belong to each step + const step0Fields = [ + 'host', + 'title', + 'theme', + 'semester', + 'description', + 'eventStart', + 'eventEnd', + 'isRecurring', + 'recurrencePattern', + 'recurrenceEndDate', + 'setupNeeded', + 'setupMinutesBefore', + ]; + + const step1Fields = [ + 'locationType', + 'hostingMinors', + 'onCampusPartners', + 'offCampusPartners', + 'nonIllinoisSpeaker', + 'nonIllinoisAttendees', + ]; + + const step2Fields = [ + 'spaceType', + 'specificRoom', + 'estimatedAttendees', + 'seatsNeeded', + 'setupDetails', + ]; + + const step3Fields = ['foodOrDrink', 'crafting', 'comments']; + + // Filter errors based on current step + const currentStepFields = + active === 0 + ? step0Fields + : active === 1 + ? step1Fields + : active === 2 + ? step2Fields + : active === 3 + ? step3Fields + : []; + + // Skip Room Requirements validation if the event is virtual + if (active === 2 && values.locationType === 'virtual') { + return {}; + } + + // Return only errors for the current step + // Using 'as' to tell TypeScript that we're intentionally returning ReactNode as errors + const filteredErrors = {} as Record; + for (const key in allErrors) { + if (currentStepFields.includes(key)) { + filteredErrors[key] = allErrors[key]; + } + } + if (Object.keys(filteredErrors).length > 0) { + console.warn(filteredErrors); + } + return filteredErrors; + }, + }); + + // Check if the room requirements section should be shown + const showRoomRequirements = + form.values.locationType === 'in-person' || form.values.locationType === 'both'; + + // Handle clearing field values when conditions change + useEffect(() => { + // Clear room requirements data if event is not in-person or hybrid + if (form.values.locationType !== 'in-person' && form.values.locationType !== 'both') { + form.setFieldValue('spaceType', undefined); + form.setFieldValue('specificRoom', undefined); + form.setFieldValue('estimatedAttendees', undefined); + form.setFieldValue('seatsNeeded', undefined); + form.setFieldValue('setupDetails', undefined); + } + }, [form.values.locationType]); + + // Handle clearing recurrence fields if isRecurring is toggled off + useEffect(() => { + if (!form.values.isRecurring) { + form.setFieldValue('recurrencePattern', undefined); + form.setFieldValue('recurrenceEndDate', undefined); + } + }, [form.values.isRecurring]); + + const handleSubmit = async () => { + if (viewOnly) { + return; + } + const apiFormValues = { ...form.values }; + Object.keys(apiFormValues).forEach((key) => { + const value = apiFormValues[key as keyof RoomRequestFormValues]; + if (value === '') { + console.warn(`Empty string found for ${key}. This field should have content.`); + } + }); + try { + if (!createRoomRequest) { + return; + } + setIsSubmitting(true); + let values; + try { + values = await roomRequestSchema.parseAsync(apiFormValues); + } catch (e) { + notifications.show({ + title: 'Submission failed to validate', + message: 'Check the browser console for more details.', + }); + throw e; + } + const response = await createRoomRequest(values); + notifications.show({ + title: 'Room Request Submitted', + message: `The request ID is ${response.id}.`, + }); + setIsSubmitting(false); + navigate('/roomRequests'); + } catch (e) { + notifications.show({ + color: 'red', + title: 'Failed to submit room request', + message: 'Please try again or contact support.', + }); + setIsSubmitting(false); + throw e; + } + }; + + const nextStep = () => + setActive((current) => { + if (form.validate().hasErrors) { + return current; + } + + // Skip Room Requirements step if the event is virtual only + if (current === 1 && form.values.locationType === 'virtual') { + return current + 2; + } + + return current < numSteps ? current + 1 : current; + }); + + const prevStep = () => + setActive((current) => { + // If coming back from step 3 to step 2 and event is virtual, skip Room Requirements step + if (current === 3 && form.values.locationType === 'virtual') { + return current - 2; + } + return current > 0 ? current - 1 : current; + }); + + return ( + <> + + + ({ value: org, label: org }))} + {...form.getInputProps('host')} + /> + +