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
51 changes: 1 addition & 50 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,55 +25,6 @@ jobs:
- name: Run unit testing
run: make test_unit

deploy-test-dev:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.event.repository.name }}-dev
cancel-in-progress: false
permissions:
id-token: write
contents: read
environment: "AWS DEV"
name: Deploy to DEV and Run Tests
needs:
- test-unit
steps:
- name: Set up Node for testing
uses: actions/setup-node@v4
with:
node-version: 22.x

- uses: actions/checkout@v4
env:
HUSKY: "0"
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::427040638965:role/GitHubActionsRole
role-session-name: Core_Dev_Prod_Deployment_${{ github.run_id }}
aws-region: us-east-1
- name: Publish to AWS
run: make deploy_dev
env:
HUSKY: "0"
VITE_RUN_ENVIRONMENT: dev

- name: Run live testing
run: make test_live_integration
env:
JWT_KEY: ${{ secrets.JWT_KEY }}
- name: Run E2E testing
run: make test_e2e
env:
PLAYWRIGHT_USERNAME: ${{ secrets.PLAYWRIGHT_USERNAME }}
PLAYWRIGHT_PASSWORD: ${{ secrets.PLAYWRIGHT_PASSWORD }}

deploy-prod:
runs-on: ubuntu-latest
name: Deploy to Prod and Run Health Check
Expand Down Expand Up @@ -105,7 +56,7 @@ jobs:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::298118738376:role/GitHubActionsRole
role-session-name: Core_Dev_Prod_Deployment_${{ github.run_id }}
role-session-name: Core_Prod_Deployment_${{ github.run_id }}
aws-region: us-east-1
- name: Publish to AWS
run: make deploy_prod
Expand Down
109 changes: 59 additions & 50 deletions src/common/types/roomRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,58 +152,67 @@ export const roomRequestBaseSchema = z.object({
.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",
export const roomRequestDataSchema = 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",
}).transform((date) => {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
}),
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",
}).transform((date) => {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
}),
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",
}),
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.coerce.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.optional(z.string().min(1)),
specificRoom: z.optional(z.string().min(1)),
estimatedAttendees: z.optional(z.number().positive()),
seatsNeeded: z.optional(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(),
})
// Recurring event fields
isRecurring: z.boolean().default(false),
recurrencePattern: z.enum(["weekly", "biweekly", "monthly"]).optional(),
recurrenceEndDate: z.coerce.date().optional().transform((date) => {
if (!date) { return date; }
const d = new Date(date);
d.setSeconds(0, 0);
return d;
}),
// 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.optional(z.string().min(1)),
specificRoom: z.optional(z.string().min(1)),
estimatedAttendees: z.optional(z.number().positive()),
seatsNeeded: z.optional(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(),
})

export const roomRequestSchema = roomRequestDataSchema
.refine(
(data) => {
// Check if end time is after start time
if (data.eventStart && data.eventEnd) {
return data.eventEnd > data.eventStart;
}
return true;
return data.eventEnd > data.eventStart;
},
{
message: "End date/time must be after start date/time",
Expand Down Expand Up @@ -241,7 +250,7 @@ export const roomRequestSchema = roomRequestBaseSchema
if (data.isRecurring && data.recurrenceEndDate && data.eventStart) {
const endDateWithTime = new Date(data.recurrenceEndDate);
endDateWithTime.setHours(23, 59, 59, 999);
return endDateWithTime >= data.eventStart;
return endDateWithTime.getTime() >= data.eventStart.getTime();
}
return true;
},
Expand Down
3 changes: 2 additions & 1 deletion src/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"react-qr-reader": "^3.0.0-beta-1",
"react-router-dom": "^7.5.2",
"zod": "^3.24.3",
"zod-openapi": "^4.2.4"
"zod-openapi": "^4.2.4",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@eslint/compat": "^1.2.8",
Expand Down
18 changes: 12 additions & 6 deletions src/ui/pages/roomRequest/NewRoomRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
} from "@common/types/roomRequest";
import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import { fromError } from "zod-validation-error";
import { ZodError } from "zod";

// Component for yes/no questions with conditional content
interface ConditionalFieldProps {
Expand Down Expand Up @@ -208,7 +210,6 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
// Get all validation errors from zod, which returns ReactNode
const allErrors: Record<string, React.ReactNode> =
zodResolver(roomRequestSchema)(values);

// If in view mode, return no errors
if (viewOnly) {
return {};
Expand Down Expand Up @@ -310,7 +311,7 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
}, [form.values.isRecurring]);

const handleSubmit = async () => {
if (viewOnly) {
if (viewOnly || isSubmitting) {
return;
}
const apiFormValues = { ...form.values };
Expand All @@ -331,19 +332,24 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
try {
values = await roomRequestSchema.parseAsync(apiFormValues);
} catch (e) {
let message = "Check the browser console for more details.";
if (e instanceof ZodError) {
message = fromError(e).toString();
}
notifications.show({
title: "Submission failed to validate",
message: "Check the browser console for more details.",
message,
color: "red",
});
throw e;
setIsSubmitting(false);
return;
}
const response = await createRoomRequest(values);
await navigate("/roomRequests");
notifications.show({
title: "Room Request Submitted",
message: `The request ID is ${response.id}.`,
});
setIsSubmitting(false);
navigate("/roomRequests");
} catch (e) {
notifications.show({
color: "red",
Expand Down
23 changes: 18 additions & 5 deletions src/ui/pages/roomRequest/ViewRoomRequest.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RoomRequestStatusUpdatePostBody,
roomRequestStatusUpdateRequest,
formatStatus,
roomRequestDataSchema,
} from "@common/types/roomRequest";
import { useParams } from "react-router-dom";
import { getStatusColor, getStatusIcon } from "./roomRequestUtils";
Expand Down Expand Up @@ -55,11 +56,23 @@ export const ViewRoomRequest: React.FC = () => {
const response = await api.get(
`/api/v1/roomRequests/${semesterId}/${requestId}`,
);
const parsed = {
data: await roomRequestSchema.parseAsync(response.data.data),
updates: response.data.updates,
};
setData(parsed);
try {
const parsed = {
data: await roomRequestSchema.parseAsync(response.data.data),
updates: response.data.updates,
};
setData(parsed);
} catch (e) {
notifications.show({
title: "Failed to validate room reservation",
message: "Data may not render correctly or may be invalid.",
color: "red",
});
setData({
data: await roomRequestDataSchema.parseAsync(response.data.data),
updates: response.data.updates,
});
}
};
const submitStatusChange = async () => {
try {
Expand Down
35 changes: 33 additions & 2 deletions tests/unit/roomRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,37 @@ describe("Test Room Request Creation", async () => {
);
expect(ddbMock.calls.length).toEqual(0);
});
test("Validation failure: eventEnd equals eventStart", async () => {
const testJwt = createJwt();
ddbMock.rejects();
const response = await supertest(app.server)
.post("/api/v1/roomRequests")
.set("authorization", `Bearer ${testJwt}`)
.send({
host: "Infrastructure Committee",
title: "Valid Title",
semester: "sp25",
theme: "Athletics",
description: "This is a valid description with at least ten words.",
eventStart: "2025-04-25T10:00:00Z",
eventEnd: "2025-04-25T10:00:00Z",
isRecurring: false,
setupNeeded: false,
hostingMinors: false,
locationType: "virtual",
foodOrDrink: false,
crafting: false,
onCampusPartners: null,
offCampusPartners: null,
nonIllinoisSpeaker: null,
nonIllinoisAttendees: null,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain(
"End date/time must be after start date/time",
);
expect(ddbMock.calls.length).toEqual(0);
});
test("Validation failure: isRecurring without recurrencePattern and endDate", async () => {
const testJwt = createJwt();
ddbMock.rejects();
Expand Down Expand Up @@ -368,8 +399,8 @@ describe("Test Room Request Creation", async () => {
theme: "Athletics",
description:
"A well-formed description that has at least ten total words.",
eventStart: new Date("2025-04-24T12:00:00Z"),
eventEnd: new Date("2025-04-24T13:00:00Z"),
eventStart: "2025-04-24T12:00:00Z",
eventEnd: "2025-04-24T13:00:00Z",
isRecurring: false,
setupNeeded: false,
hostingMinors: false,
Expand Down
Loading
Loading