From e77e636e9f9c26f694c534ebd79592ce143a8446 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 5 Jul 2025 11:06:39 -0500 Subject: [PATCH 1/3] don't return whole payload for admins --- src/api/routes/roomRequests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index e77197ee..967f80a0 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -198,6 +198,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { command = new QueryCommand({ TableName: genericConfig.RoomRequestsTableName, KeyConditionExpression: "semesterId = :semesterValue", + ProjectionExpression: "requestId, host, title, semester", ExpressionAttributeValues: { ":semesterValue": { S: semesterId }, }, From b19291b3ca2add58312d0eff00470d04cc711868 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 5 Jul 2025 12:09:25 -0500 Subject: [PATCH 2/3] enable select queries --- src/api/routes/roomRequests.ts | 18 ++++++++- src/common/utils.ts | 40 +++++++++++++++++++ .../roomRequest/RoomRequestLanding.page.tsx | 12 +++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 967f80a0..3ab56ca8 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -30,6 +30,11 @@ import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; import { buildAuditLogTransactPut } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; +import { + generateProjectionParams, + getDefaultFilteringQuerystring, + nonEmptyCommaSeparatedStringSchema, +} from "common/utils.js"; const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { @@ -182,12 +187,19 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { example: "sp25", }), }), + querystring: z.object( + getDefaultFilteringQuerystring({ + defaultSelect: ["requestId", "title"], + }), + ), }), ), onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { const semesterId = request.params.semesterId; + const { ProjectionExpression, ExpressionAttributeNames } = + generateProjectionParams({ userFields: request.query.select }); if (!request.username) { throw new InternalServerError({ message: "Could not retrieve username.", @@ -198,7 +210,8 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { command = new QueryCommand({ TableName: genericConfig.RoomRequestsTableName, KeyConditionExpression: "semesterId = :semesterValue", - ProjectionExpression: "requestId, host, title, semester", + ProjectionExpression, + ExpressionAttributeNames, ExpressionAttributeValues: { ":semesterValue": { S: semesterId }, }, @@ -210,8 +223,9 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { "semesterId = :semesterValue AND begins_with(#sortKey, :username)", ExpressionAttributeNames: { "#sortKey": "userId#requestId", + ...ExpressionAttributeNames, }, - ProjectionExpression: "requestId, host, title, semester", + ProjectionExpression, ExpressionAttributeValues: { ":semesterValue": { S: semesterId }, ":username": { S: request.username }, diff --git a/src/common/utils.ts b/src/common/utils.ts index 786c998f..3e1746e6 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; export function transformCommaSeperatedName(name: string) { if (name.includes(",")) { try { @@ -12,3 +13,42 @@ export function transformCommaSeperatedName(name: string) { } return name; } + +type GenerateProjectionParamsInput = { + userFields?: string[]; +} +/** + * Generates DynamoDB projection parameters for select filters, while safely handle reserved keywords. + */ +export const generateProjectionParams = ({ userFields }: GenerateProjectionParamsInput) => { + const attributes = userFields || []; + const expressionAttributeNames: Record = {}; + const projectionExpression = attributes + .map((attr, index) => { + const placeholder = `#proj${index}`; + expressionAttributeNames[placeholder] = attr; + return placeholder; + }) + .join(','); + return { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + }; +}; + + +export const nonEmptyCommaSeparatedStringSchema = z.preprocess( + (val) => String(val).split(',').map(item => item.trim()), + z.array(z.string()).nonempty() +); + +type GettDefaultFilteringQuerystringInput = { + defaultSelect: string[]; +} +export const getDefaultFilteringQuerystring = ({ defaultSelect }: GettDefaultFilteringQuerystringInput) => { + return { + select: z.optional(nonEmptyCommaSeparatedStringSchema).default(defaultSelect.join(',')).openapi({ + description: "Comma-seperated list of attributes to return", + }) + } +} diff --git a/src/ui/pages/roomRequest/RoomRequestLanding.page.tsx b/src/ui/pages/roomRequest/RoomRequestLanding.page.tsx index 3f127f02..d033b6cd 100644 --- a/src/ui/pages/roomRequest/RoomRequestLanding.page.tsx +++ b/src/ui/pages/roomRequest/RoomRequestLanding.page.tsx @@ -11,6 +11,7 @@ import { RoomRequestFormValues, RoomRequestGetAllResponse, RoomRequestPostResponse, + type RoomRequestStatus, } from "@common/types/roomRequest"; export const ManageRoomRequestsPage: React.FC = () => { @@ -29,8 +30,15 @@ export const ManageRoomRequestsPage: React.FC = () => { const getRoomRequests = async ( semester: string, ): Promise => { - const response = await api.get(`/api/v1/roomRequests/${semester}`); - return response.data; + const response = await api.get< + { + requestId: string; + title: string; + host: string; + status: RoomRequestStatus; + }[] + >(`/api/v1/roomRequests/${semester}?select=requestId,title,host,status`); + return response.data.map((x) => ({ ...x, semester })); }; useEffect(() => { From 18a1edb6e9e985a0f67a6a81cdc720fee5002370 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 5 Jul 2025 12:30:39 -0500 Subject: [PATCH 3/3] update functions --- src/api/routes/roomRequests.ts | 14 ++++++++++++++ src/common/utils.ts | 14 ++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 3ab56ca8..ff5cd8c2 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -239,6 +239,9 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const items = response.Items.map((x) => { + if (!request.query.select.includes("status")) { + return unmarshall(x); + } const item = unmarshall(x) as { host: string; title: string; @@ -418,6 +421,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { example: "sp25", }), }), + querystring: z.object( + getDefaultFilteringQuerystring({ + defaultSelect: ["requestId", "title"], + }), + ), }), ), onRequest: fastify.authorizeFromSchema, @@ -425,6 +433,8 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const requestId = request.params.requestId; const semesterId = request.params.semesterId; + const { ProjectionExpression, ExpressionAttributeNames } = + generateProjectionParams({ userFields: request.query.select }); let command; if (request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)) { command = new QueryCommand({ @@ -432,6 +442,8 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { IndexName: "RequestIdIndex", KeyConditionExpression: "requestId = :requestId", FilterExpression: "semesterId = :semesterId", + ProjectionExpression, + ExpressionAttributeNames, ExpressionAttributeValues: { ":requestId": { S: requestId }, ":semesterId": { S: semesterId }, @@ -441,6 +453,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { } else { command = new QueryCommand({ TableName: genericConfig.RoomRequestsTableName, + ProjectionExpression, KeyConditionExpression: "semesterId = :semesterId AND #userIdRequestId = :userRequestId", ExpressionAttributeValues: { @@ -449,6 +462,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { }, ExpressionAttributeNames: { "#userIdRequestId": "userId#requestId", + ...ExpressionAttributeNames, }, Limit: 1, }); diff --git a/src/common/utils.ts b/src/common/utils.ts index 3e1746e6..31c845b6 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -37,18 +37,20 @@ export const generateProjectionParams = ({ userFields }: GenerateProjectionParam }; -export const nonEmptyCommaSeparatedStringSchema = z.preprocess( - (val) => String(val).split(',').map(item => item.trim()), - z.array(z.string()).nonempty() -); +export const nonEmptyCommaSeparatedStringSchema = z + .string({ invalid_type_error: "Filter expression must be a string." }) + .min(1, { message: "Filter expression must be at least 1 character long." }) + .transform((val) => val.split(',').map(item => item.trim())) + .pipe(z.array(z.string()).nonempty()); -type GettDefaultFilteringQuerystringInput = { +type GetDefaultFilteringQuerystringInput = { defaultSelect: string[]; } -export const getDefaultFilteringQuerystring = ({ defaultSelect }: GettDefaultFilteringQuerystringInput) => { +export const getDefaultFilteringQuerystring = ({ defaultSelect }: GetDefaultFilteringQuerystringInput) => { return { select: z.optional(nonEmptyCommaSeparatedStringSchema).default(defaultSelect.join(',')).openapi({ description: "Comma-seperated list of attributes to return", + ...(defaultSelect.length === 0 ? { default: "" } : {}), }) } }