-
Notifications
You must be signed in to change notification settings - Fork 0
events-rsvp get all rsvps for an event route created #427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
86a2126
440fae7
9e2f331
9e6ba13
96a29a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||||||
| import { FastifyPluginAsync } from "fastify"; | ||||||||||||||||||||||
| import rateLimiter from "api/plugins/rateLimiter.js"; | ||||||||||||||||||||||
| import { withRoles, withTags } from "api/components/index.js"; | ||||||||||||||||||||||
| import { QueryCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; | ||||||||||||||||||||||
| import { unmarshall, marshall } from "@aws-sdk/util-dynamodb"; | ||||||||||||||||||||||
| import { | ||||||||||||||||||||||
| DatabaseFetchError, | ||||||||||||||||||||||
| UnauthenticatedError, | ||||||||||||||||||||||
| UnauthorizedError, | ||||||||||||||||||||||
| ValidationError, | ||||||||||||||||||||||
| } from "common/errors/index.js"; | ||||||||||||||||||||||
| import * as z from "zod/v4"; | ||||||||||||||||||||||
| import { verifyUiucAccessToken } from "api/functions/uin.js"; | ||||||||||||||||||||||
| import { checkPaidMembership } from "api/functions/membership.js"; | ||||||||||||||||||||||
| import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; | ||||||||||||||||||||||
| import { genericConfig } from "common/config.js"; | ||||||||||||||||||||||
| import { AppRoles } from "common/roles.js"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const rsvpRoutes: FastifyPluginAsync = async (fastify, _options) => { | ||||||||||||||||||||||
| await fastify.register(rateLimiter, { | ||||||||||||||||||||||
| limit: 30, | ||||||||||||||||||||||
| duration: 30, | ||||||||||||||||||||||
| rateLimitIdentifier: "rsvp", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post( | ||||||||||||||||||||||
| "/:orgId/event/:eventId", | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| schema: withTags(["RSVP"], { | ||||||||||||||||||||||
| summary: "Submit an RSVP for an event.", | ||||||||||||||||||||||
| params: z.object({ | ||||||||||||||||||||||
| eventId: z.string().min(1).meta({ | ||||||||||||||||||||||
| description: "The previously-created event ID in the events API.", | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| orgId: z.string().min(1).meta({ | ||||||||||||||||||||||
| description: "The organization ID the event belongs to.", | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| headers: z.object({ | ||||||||||||||||||||||
| "x-uiuc-token": z.jwt().min(1).meta({ | ||||||||||||||||||||||
| description: | ||||||||||||||||||||||
| "An access token for the user in the UIUC Entra ID tenant.", | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| async (request, reply) => { | ||||||||||||||||||||||
| const accessToken = request.headers["x-uiuc-token"]; | ||||||||||||||||||||||
| const verifiedData = await verifyUiucAccessToken({ | ||||||||||||||||||||||
| accessToken, | ||||||||||||||||||||||
| logger: request.log, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const { userPrincipalName: upn, givenName, surname } = verifiedData; | ||||||||||||||||||||||
| const netId = upn.replace("@illinois.edu", ""); | ||||||||||||||||||||||
| if (netId.includes("@")) { | ||||||||||||||||||||||
| request.log.error( | ||||||||||||||||||||||
| `Found UPN ${upn} which cannot be turned into NetID via simple replacement.`, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| throw new ValidationError({ | ||||||||||||||||||||||
| message: "ID token could not be parsed.", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| const isPaidMember = await checkPaidMembership({ | ||||||||||||||||||||||
| netId, | ||||||||||||||||||||||
| dynamoClient: fastify.dynamoClient, | ||||||||||||||||||||||
| redisClient: fastify.redisClient, | ||||||||||||||||||||||
| logger: request.log, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const entry = { | ||||||||||||||||||||||
| partitionKey: `${request.params.eventId}#${upn}`, | ||||||||||||||||||||||
| eventId: request.params.eventId, | ||||||||||||||||||||||
| userId: upn, | ||||||||||||||||||||||
| isPaidMember, | ||||||||||||||||||||||
| createdAt: "", | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| const putCommand = new PutItemCommand({ | ||||||||||||||||||||||
| TableName: genericConfig.RSVPDynamoTableName, | ||||||||||||||||||||||
| Item: marshall(entry), | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| await fastify.dynamoClient.send(putCommand); | ||||||||||||||||||||||
| return reply.status(201).send(entry); | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get( | ||||||||||||||||||||||
| "/:orgId/event/:eventId", | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| schema: withRoles( | ||||||||||||||||||||||
| [AppRoles.VIEW_RSVPS], | ||||||||||||||||||||||
| withTags(["RSVP"], { | ||||||||||||||||||||||
| summary: "Get all RSVPs for an event.", | ||||||||||||||||||||||
| params: z.object({ | ||||||||||||||||||||||
| eventId: z.string().min(1).meta({ | ||||||||||||||||||||||
| description: "The previously-created event ID in the events API.", | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| orgId: z.string().min(1).meta({ | ||||||||||||||||||||||
| description: "The organization ID the event belongs to.", | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| onRequest: fastify.authorizeFromSchema, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| async (request, reply) => { | ||||||||||||||||||||||
| const command = new QueryCommand({ | ||||||||||||||||||||||
| TableName: genericConfig.RSVPDynamoTableName, | ||||||||||||||||||||||
| IndexName: "EventIdIndex", | ||||||||||||||||||||||
| KeyConditionExpression: "eventId = :eid", | ||||||||||||||||||||||
| ExpressionAttributeValues: { | ||||||||||||||||||||||
| ":eid": { S: request.params.eventId }, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const response = await fastify.dynamoClient.send(command); | ||||||||||||||||||||||
| if (!response || !response.Items) { | ||||||||||||||||||||||
| throw new DatabaseFetchError({ | ||||||||||||||||||||||
| message: "Failed to get all member lists.", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+112
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix misleading error message. The error message says "Failed to get all member lists" but this endpoint retrieves RSVPs. if (!response || !response.Items) {
throw new DatabaseFetchError({
- message: "Failed to get all member lists.",
+ message: "Failed to fetch RSVPs for event.",
});
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| const rsvps = response.Items.map((x) => unmarshall(x)); | ||||||||||||||||||||||
| const uniqueRsvps = [ | ||||||||||||||||||||||
| ...new Map(rsvps.map((item) => [item.userId, item])).values(), | ||||||||||||||||||||||
| ]; | ||||||||||||||||||||||
| return reply.send(uniqueRsvps); | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export default rsvpRoutes; | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import * as z from "zod/v4"; | ||
|
|
||
| export const rsvpItemSchema = z.object({ | ||
| eventId: z.string(), | ||
| userId: z.string(), | ||
| isPaidMember: z.boolean(), | ||
| createdAt: z.string(), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { expect, test, vi, describe, beforeEach } from "vitest"; | ||
| import { | ||
| DynamoDBClient, | ||
| PutItemCommand, | ||
| QueryCommand, | ||
| } from "@aws-sdk/client-dynamodb"; | ||
| import { marshall } from "@aws-sdk/util-dynamodb"; | ||
| import { mockClient } from "aws-sdk-client-mock"; | ||
| import init from "../../src/api/index.js"; | ||
| import { createJwt } from "./auth.test.js"; | ||
| import { testSecretObject } from "./secret.testdata.js"; | ||
| import { Redis } from "../../src/api/types.js"; | ||
| import { FastifyBaseLogger } from "fastify"; | ||
|
|
||
| const DUMMY_JWT = | ||
| "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; | ||
|
|
||
| vi.mock("../../src/api/functions/uin.js", async () => { | ||
| const actual = await vi.importActual("../../src/api/functions/uin.js"); | ||
| return { | ||
| ...actual, | ||
| verifyUiucAccessToken: vi | ||
| .fn() | ||
| .mockImplementation( | ||
| async ({ | ||
| token, | ||
| logger, | ||
| }: { | ||
| token: string; | ||
| logger: FastifyBaseLogger; | ||
| }) => { | ||
| if (token === DUMMY_JWT) { | ||
| console.log("DUMMY_JWT matched in mock implementation"); | ||
| } | ||
| return { | ||
| userPrincipalName: "jd3@illinois.edu", | ||
| givenName: "John", | ||
| surname: "Doe", | ||
| mail: "johndoe@gmail.com", | ||
| }; | ||
| }, | ||
| ), | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock("../../src/api/functions/membership.js", async () => { | ||
| const actual = await vi.importActual("../../src/api/functions/membership.js"); | ||
| return { | ||
| ...actual, | ||
| checkPaidMembership: vi | ||
| .fn() | ||
| .mockImplementation( | ||
| async ({ | ||
| netId, | ||
| redisClient, | ||
| dynamoClient, | ||
| logger, | ||
| }: { | ||
| netId: string; | ||
| redisClient: Redis; | ||
| dynamoClient: DynamoDBClient; | ||
| logger: FastifyBaseLogger; | ||
| }) => { | ||
| if (netId === "jd3") { | ||
| return true; | ||
| } | ||
| return false; | ||
| }, | ||
| ), | ||
| }; | ||
| }); | ||
|
|
||
| const ddbMock = mockClient(DynamoDBClient); | ||
| const jwt_secret = testSecretObject["jwt_key"]; | ||
| vi.stubEnv("JwtSigningKey", jwt_secret); | ||
|
|
||
| const app = await init(); | ||
|
|
||
| describe("RSVP API tests", () => { | ||
| beforeEach(() => { | ||
| ddbMock.reset(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| test("Test posting an RSVP for an event", async () => { | ||
| ddbMock.on(PutItemCommand).resolves({}); | ||
|
|
||
| const testJwt = createJwt(); | ||
| const mockUpn = "jd3@illinois.edu"; | ||
| const eventId = "Make Your Own Database"; | ||
| const orgId = "SIGDatabase"; | ||
|
|
||
| const response = await app.inject({ | ||
| method: "POST", | ||
| url: `/api/v1/rsvp/${orgId}/event/${encodeURIComponent(eventId)}`, | ||
| headers: { | ||
| Authorization: `Bearer ${testJwt}`, | ||
| "x-uiuc-token": DUMMY_JWT, | ||
| }, | ||
| }); | ||
|
|
||
| if (response.statusCode !== 201) { | ||
| console.log("Test Failed Response:", response.body); | ||
| } | ||
|
|
||
| expect(response.statusCode).toBe(201); | ||
|
|
||
| const body = JSON.parse(response.body); | ||
| expect(body.userId).toBe(mockUpn); | ||
| expect(body.eventId).toBe(eventId); | ||
| expect(body.isPaidMember).toBe(true); | ||
| expect(body.partitionKey).toBe(`${eventId}#${mockUpn}`); | ||
|
|
||
| expect(ddbMock.calls()).toHaveLength(1); | ||
| const putItemInput = ddbMock.call(0).args[0].input as any; | ||
| expect(putItemInput.TableName).toBe("infra-core-api-events-rsvp"); | ||
| }); | ||
|
|
||
| test("Test getting RSVPs for an event (Mocking Query Response)", async () => { | ||
| const eventId = "Make Your Own Database"; | ||
| const orgId = "SIGDatabase"; | ||
| const mockRsvps = [ | ||
| { | ||
| eventId, | ||
| userId: "user1@illinois.edu", | ||
| isPaidMember: true, | ||
| createdAt: "2023-01-01", | ||
| }, | ||
| { | ||
| eventId, | ||
| userId: "user2@illinois.edu", | ||
| isPaidMember: false, | ||
| createdAt: "2023-01-02", | ||
| }, | ||
| ]; | ||
| ddbMock.on(QueryCommand).resolves({ | ||
| Items: mockRsvps.map((item) => marshall(item)), | ||
| }); | ||
|
|
||
| const adminJwt = await createJwt(); | ||
|
|
||
| const response = await app.inject({ | ||
| method: "GET", | ||
| url: `/api/v1/rsvp/${orgId}/event/${encodeURIComponent(eventId)}`, | ||
| headers: { | ||
| Authorization: `Bearer ${adminJwt}`, | ||
| }, | ||
| }); | ||
|
|
||
| expect(response.statusCode).toBe(200); | ||
| const body = JSON.parse(response.body); | ||
|
|
||
| expect(body).toHaveLength(2); | ||
| expect(body[0].userId).toBe("user1@illinois.edu"); | ||
| expect(body[1].userId).toBe("user2@illinois.edu"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 1
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 1
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stderr:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 1
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 127
stderr:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stderr:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Script executed:
Repository: acm-uiuc/core
<shell_result>
Repository: acm-uiuc/core
Exit code: 0
stdout:
</shell_result>
Zod v4 jwt method
<web_search_result>
Short answer: Zod v4 adds a string validator for JWTs: use z.string().jwt() (optionally z.string().jwt({ alg: "RS256" }) to require a specific alg). It only checks the three-part/base64url structure (and decodes header/payload), it does NOT verify signatures — use a JWT library (e.g., jsonwebtoken or jose) for cryptographic verification. [1][2]
</web_search_result>
Fix
z.jwt()toz.string().jwt()—z.jwt()is not a valid Zod v4 method.Zod v4 provides
z.string().jwt()to validate JWT strings, notz.jwt(). The current code will fail at runtime. Replacez.jwt().min(1)withz.string().jwt().min(1).Note:
z.string().jwt()validates the JWT structure (three-part base64url format) but does not verify signatures—use a JWT library likejosefor cryptographic verification.🤖 Prompt for AI Agents