Skip to content

Commit 0583032

Browse files
committed
basics
1 parent dc1253a commit 0583032

File tree

8 files changed

+346
-28
lines changed

8 files changed

+346
-28
lines changed

src/api/routes/roomRequests.ts

Lines changed: 170 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FastifyPluginAsync } from "fastify";
22
import rateLimiter from "api/plugins/rateLimiter.js";
33
import {
4+
roomGetResponse,
45
roomRequestBaseSchema,
56
RoomRequestFormValues,
67
roomRequestPostResponse,
@@ -17,7 +18,11 @@ import {
1718
DatabaseInsertError,
1819
InternalServerError,
1920
} from "common/errors/index.js";
20-
import { PutItemCommand, QueryCommand } from "@aws-sdk/client-dynamodb";
21+
import {
22+
PutItemCommand,
23+
QueryCommand,
24+
TransactWriteItemsCommand,
25+
} from "@aws-sdk/client-dynamodb";
2126
import { genericConfig } from "common/config.js";
2227
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
2328
import { z } from "zod";
@@ -81,11 +86,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
8186
{
8287
schema: {
8388
response: {
84-
200: zodToJsonSchema(
85-
z.array(
86-
roomRequestBaseSchema.extend({ requestId: z.string().uuid() }),
87-
),
88-
),
89+
200: zodToJsonSchema(roomGetResponse),
8990
},
9091
},
9192
onRequest: async (request, reply) => {
@@ -116,7 +117,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
116117
ExpressionAttributeNames: {
117118
"#hashKey": "userId#requestId",
118119
},
119-
ProjectionExpression: "requestId, host, title",
120+
ProjectionExpression: "requestId, host, title, semester",
120121
ExpressionAttributeValues: {
121122
":semesterValue": { S: semesterId },
122123
":username": { S: request.username },
@@ -129,8 +130,49 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
129130
message: "Could not get room requests.",
130131
});
131132
}
132-
const items = response.Items.map((x) => unmarshall(x));
133-
return reply.status(200).send(items);
133+
const items = response.Items.map((x) => {
134+
const item = unmarshall(x) as {
135+
host: string;
136+
title: string;
137+
requestId: string;
138+
status: string;
139+
};
140+
const statusPromise = fastify.dynamoClient.send(
141+
new QueryCommand({
142+
TableName: genericConfig.RoomRequestsStatusTableName,
143+
KeyConditionExpression: "requestId = :requestId",
144+
ExpressionAttributeValues: {
145+
":requestId": { S: item.requestId },
146+
},
147+
ProjectionExpression: "#status",
148+
ExpressionAttributeNames: {
149+
"#status": "status",
150+
},
151+
ScanIndexForward: false,
152+
Limit: 1,
153+
}),
154+
);
155+
156+
return statusPromise.then((statusResponse) => {
157+
if (
158+
!statusResponse ||
159+
!statusResponse.Items ||
160+
statusResponse.Items.length == 0
161+
) {
162+
return "unknown";
163+
}
164+
const statuses = statusResponse.Items.map((s) => unmarshall(s));
165+
const latestStatus = statuses.length > 0 ? statuses[0].status : null;
166+
return {
167+
...item,
168+
status: latestStatus,
169+
};
170+
});
171+
});
172+
173+
const itemsWithStatus = await Promise.all(items);
174+
175+
return reply.status(200).send(itemsWithStatus);
134176
},
135177
);
136178
fastify.post<{ Body: RoomRequestFormValues }>(
@@ -161,12 +203,31 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
161203
semesterId: request.body.semester,
162204
};
163205
try {
164-
await fastify.dynamoClient.send(
165-
new PutItemCommand({
166-
TableName: genericConfig.RoomRequestsTableName,
167-
Item: marshall(body),
168-
}),
169-
);
206+
const createdAt = new Date().toISOString();
207+
const transactionCommand = new TransactWriteItemsCommand({
208+
TransactItems: [
209+
{
210+
Put: {
211+
TableName: genericConfig.RoomRequestsTableName,
212+
Item: marshall(body),
213+
},
214+
},
215+
{
216+
Put: {
217+
TableName: genericConfig.RoomRequestsStatusTableName,
218+
Item: marshall({
219+
requestId,
220+
semesterId: request.body.semester,
221+
"createdAt#status": `${createdAt}#${RoomRequestStatus.CREATED}`,
222+
createdBy: request.username,
223+
status: RoomRequestStatus.CREATED,
224+
notes: "This request was created by the user.",
225+
}),
226+
},
227+
},
228+
],
229+
});
230+
await fastify.dynamoClient.send(transactionCommand);
170231
} catch (e) {
171232
if (e instanceof BaseError) {
172233
throw e;
@@ -182,6 +243,100 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
182243
});
183244
},
184245
);
246+
fastify.get<{
247+
Body: undefined;
248+
Params: { requestId: string; semesterId: string };
249+
}>(
250+
"/:semesterId/:requestId",
251+
{
252+
onRequest: async (request, reply) => {
253+
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
254+
},
255+
},
256+
async (request, reply) => {
257+
const requestId = request.params.requestId;
258+
const semesterId = request.params.semesterId;
259+
let command;
260+
if (request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)) {
261+
command = new QueryCommand({
262+
TableName: genericConfig.RoomRequestsTableName,
263+
IndexName: "RequestIdIndex",
264+
KeyConditionExpression: "requestId = :requestId",
265+
FilterExpression: "semesterId = :semesterId",
266+
ExpressionAttributeValues: {
267+
":requestId": { S: requestId },
268+
":semesterId": { S: semesterId },
269+
},
270+
Limit: 1,
271+
});
272+
} else {
273+
command = new QueryCommand({
274+
TableName: genericConfig.RoomRequestsTableName,
275+
KeyConditionExpression:
276+
"semesterId = :semesterId AND #userIdRequestId = :userRequestId",
277+
ExpressionAttributeValues: {
278+
":userRequestId": { S: `${request.username}#${requestId}` },
279+
":semesterId": { S: semesterId },
280+
},
281+
ExpressionAttributeNames: {
282+
"#userIdRequestId": "userId#requestId",
283+
},
284+
Limit: 1,
285+
});
286+
}
287+
try {
288+
const resp = await fastify.dynamoClient.send(command);
289+
if (!resp.Items || resp.Count != 1) {
290+
throw new DatabaseFetchError({
291+
message: "Recieved no response.",
292+
});
293+
}
294+
// this isn't atomic, but that's fine - a little inconsistency on this isn't a problem.
295+
try {
296+
const statusesResponse = await fastify.dynamoClient.send(
297+
new QueryCommand({
298+
TableName: genericConfig.RoomRequestsStatusTableName,
299+
KeyConditionExpression: "requestId = :requestId",
300+
ExpressionAttributeValues: {
301+
":requestId": { S: requestId },
302+
},
303+
ProjectionExpression: "#createdAt,#notes,#createdBy",
304+
ExpressionAttributeNames: {
305+
"#createdBy": "createdBy",
306+
"#createdAt": "createdAt#status",
307+
"#notes": "notes",
308+
},
309+
}),
310+
);
311+
const updates = statusesResponse.Items?.map((x) => {
312+
const unmarshalled = unmarshall(x);
313+
return {
314+
createdBy: unmarshalled["createdBy"],
315+
createdAt: unmarshalled["createdAt#status"].split("#")[0],
316+
status: unmarshalled["createdAt#status"].split("#")[1],
317+
notes: unmarshalled["notes"],
318+
};
319+
});
320+
return reply
321+
.status(200)
322+
.send({ data: unmarshall(resp.Items[0]), updates });
323+
} catch (e) {
324+
request.log.error(e);
325+
throw new DatabaseFetchError({
326+
message: "Could not get request status.",
327+
});
328+
}
329+
} catch (e) {
330+
request.log.error(e);
331+
if (e instanceof BaseError) {
332+
throw e;
333+
}
334+
throw new DatabaseInsertError({
335+
message: "Could not find by ID.",
336+
});
337+
}
338+
},
339+
);
185340
};
186341

187342
export default roomRequestRoutes;

src/common/types/roomRequest.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,9 @@ export const roomRequestStatusUpdate = roomRequestStatusUpdateRequest.extend({
7171
export const roomRequestBaseSchema = z.object({
7272
host: z.enum(OrganizationList),
7373
title: z.string().min(2, "Title must have at least 2 characters"),
74+
semester: z.string().regex(/^(fa|sp|su|wi)\d{2}$/, "Invalid semester provided"),
7475
})
7576
export const roomRequestSchema = roomRequestBaseSchema.extend({
76-
host: z.enum(OrganizationList),
77-
semester: z.string().regex(/^(fa|sp|su|wi)\d{2}$/, "Invalid semester provided"),
78-
title: z.string().min(2, "Title must have at least 2 characters"),
7977
theme: z.enum(eventThemeOptions),
8078
description: z.string()
8179
.min(10, "Description must have at least 10 words")
@@ -108,7 +106,7 @@ export const roomRequestPostResponse = z.object({
108106

109107
export const roomRequestGetResponse = z.object({
110108
data: roomRequestSchema,
111-
statusUpdates: z.array(roomRequestStatusUpdate),
109+
updates: z.array(roomRequestStatusUpdate),
112110
})
113111

114112
export type RoomRequestPostResponse = z.infer<typeof roomRequestPostResponse>;
@@ -118,3 +116,9 @@ export type RoomRequestStatusUpdate = z.infer<typeof roomRequestStatusUpdate>;
118116
export type RoomRequestGetResponse = z.infer<typeof roomRequestGetResponse>;
119117

120118
export type RoomRequestStatusUpdatePostBody = z.infer<typeof roomRequestStatusUpdateRequest>;
119+
120+
export const roomGetResponse = z.array(
121+
roomRequestBaseSchema.extend({ requestId: z.string().uuid(), status: z.nativeEnum(RoomRequestStatus) }),
122+
);
123+
124+
export type RoomRequestGetAllResponse = z.infer<typeof roomGetResponse>;

src/ui/Router.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ManageIamPage } from './pages/iam/ManageIam.page';
2020
import { ManageProfilePage } from './pages/profile/ManageProfile.page';
2121
import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page';
2222
import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page';
23+
import { ViewRoomRequest } from './pages/roomRequest/ViewRoomRequest.page';
2324

2425
const ProfileRediect: React.FC = () => {
2526
const location = useLocation();
@@ -167,6 +168,10 @@ const authenticatedRouter = createBrowserRouter([
167168
path: '/roomRequests',
168169
element: <ManageRoomRequestsPage />,
169170
},
171+
{
172+
path: '/roomRequests/:semesterId/:requestId',
173+
element: <ViewRoomRequest />,
174+
},
170175
// Catch-all route for authenticated users shows 404 page
171176
{
172177
path: '*',
Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,57 @@
1-
const ExistingRoomRequests: React.FC = () => {
2-
return null;
1+
import React, { useEffect, useState } from 'react';
2+
import { RoomRequestGetAllResponse } from '@common/types/roomRequest';
3+
import { Anchor, Loader, Table } from '@mantine/core';
4+
import { useNavigate } from 'react-router-dom';
5+
import { formatStatus } from './roomRequestUtils';
6+
7+
interface ExistingRoomRequestsProps {
8+
getRoomRequests: (semester: string) => Promise<RoomRequestGetAllResponse>;
9+
semester: string;
10+
}
11+
const ExistingRoomRequests: React.FC<ExistingRoomRequestsProps> = ({
12+
getRoomRequests,
13+
semester,
14+
}) => {
15+
const [data, setData] = useState<RoomRequestGetAllResponse | null>(null);
16+
const navigate = useNavigate();
17+
useEffect(() => {
18+
const inner = async () => {
19+
setData(await getRoomRequests(semester));
20+
};
21+
inner();
22+
}, [semester]);
23+
return (
24+
<>
25+
<Table>
26+
<Table.Thead>
27+
<Table.Tr>
28+
<Table.Th>Name</Table.Th>
29+
<Table.Th>Host</Table.Th>
30+
<Table.Th>Status</Table.Th>
31+
</Table.Tr>
32+
</Table.Thead>
33+
{!data && <Loader size={32} />}
34+
{data && (
35+
<Table.Tbody>
36+
{data.map((item) => {
37+
return (
38+
<Table.Tr key={item.requestId}>
39+
<Table.Td
40+
onClick={() => navigate(`/roomRequests/${item.semester}/${item.requestId}`)}
41+
style={{ cursor: 'pointer', color: 'var(--mantine-color-blue-6)' }}
42+
>
43+
{item.title}
44+
</Table.Td>
45+
<Table.Td>{item.host}</Table.Td>
46+
<Table.Td>{formatStatus(item.status)}</Table.Td>
47+
</Table.Tr>
48+
);
49+
})}
50+
</Table.Tbody>
51+
)}
52+
</Table>
53+
</>
54+
);
355
};
456

557
export default ExistingRoomRequests;

src/ui/pages/roomRequest/NewRoomRequest.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const YesNoField: React.FC<YesNoFieldProps> = ({
129129
};
130130

131131
interface NewRoomRequestProps {
132-
createRoomRequest: (payload: RoomRequestFormValues) => Promise<RoomRequestPostResponse>;
132+
createRoomRequest?: (payload: RoomRequestFormValues) => Promise<RoomRequestPostResponse>;
133133
initialValues?: RoomRequestFormValues;
134134
disabled?: boolean;
135135
}
@@ -147,7 +147,7 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
147147
const semesterValues = semesterOptions.map((x) => x.value);
148148

149149
const form = useForm<RoomRequestFormValues>({
150-
enhanceGetInputProps: () => ({ disabled }),
150+
enhanceGetInputProps: () => ({ readOnly: disabled }),
151151
initialValues: initialValues || {
152152
host: '',
153153
title: '',
@@ -290,6 +290,9 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
290290
}
291291
});
292292
try {
293+
if (!createRoomRequest) {
294+
return;
295+
}
293296
setIsSubmitting(true);
294297
const response = await createRoomRequest(apiFormValues);
295298
notifications.show({
@@ -575,9 +578,10 @@ const NewRoomRequest: React.FC<NewRoomRequestProps> = ({
575578
Back
576579
</Button>
577580
)}
578-
{active !== numSteps && (
579-
<Button onClick={nextStep}>{active === numSteps - 1 ? 'Review' : 'Next'}</Button>
580-
)}
581+
{active !== numSteps &&
582+
(disabled && active === numSteps - 1 ? null : (
583+
<Button onClick={nextStep}>{active === numSteps - 1 ? 'Review' : 'Next'}</Button>
584+
))}
581585
{active === numSteps && !disabled && (
582586
<Button onClick={handleSubmit} color="green">
583587
{isSubmitting ? (

0 commit comments

Comments
 (0)