Skip to content

Commit e401969

Browse files
committed
Use Location header and support PATCH method to update
1 parent c51cbb5 commit e401969

File tree

4 files changed

+330
-98
lines changed

4 files changed

+330
-98
lines changed

src/api/routes/events.ts

Lines changed: 193 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PutItemCommand,
99
QueryCommand,
1010
ScanCommand,
11+
UpdateItemCommand,
1112
} from "@aws-sdk/client-dynamodb";
1213
import { EVENT_CACHED_DURATION, genericConfig } from "../../common/config.js";
1314
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
@@ -288,24 +289,16 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
288289
},
289290
);
290291
};
291-
292-
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
293-
"/:id?",
292+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().patch(
293+
"/:id",
294294
{
295295
schema: withRoles(
296296
[AppRoles.EVENTS_MANAGER],
297297
withTags(["Events"], {
298-
// response: {
299-
// 201: z.object({
300-
// id: z.string(),
301-
// resource: z.string(),
302-
// }),
303-
// },
304-
body: postRequestSchema,
298+
body: postRequestSchema.partial(),
305299
params: z.object({
306-
id: z.string().min(1).optional().meta({
307-
description:
308-
"Event ID to modify (leave empty to create a new event).",
300+
id: z.string().min(1).meta({
301+
description: "Event ID to modify.",
309302
example: "6667e095-8b04-4877-b361-f636f459ba42",
310303
}),
311304
}),
@@ -319,35 +312,168 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
319312
throw new UnauthenticatedError({ message: "Username not found." });
320313
}
321314
try {
322-
let originalEvent;
323-
const userProvidedId = request.params.id;
324-
const entryUUID = userProvidedId || randomUUID();
325-
if (userProvidedId) {
326-
const response = await fastify.dynamoClient.send(
327-
new GetItemCommand({
328-
TableName: genericConfig.EventsDynamoTableName,
329-
Key: { id: { S: userProvidedId } },
330-
}),
331-
);
332-
originalEvent = response.Item;
333-
if (!originalEvent) {
334-
throw new ValidationError({
335-
message: `${userProvidedId} is not a valid event ID.`,
315+
const entryUUID = request.params.id;
316+
const updateData = {
317+
...request.body,
318+
updatedAt: new Date().toISOString(),
319+
};
320+
321+
Object.keys(updateData).forEach(
322+
(key) =>
323+
(updateData as Record<string, any>)[key] === undefined &&
324+
delete (updateData as Record<string, any>)[key],
325+
);
326+
327+
if (Object.keys(updateData).length === 0) {
328+
throw new ValidationError({
329+
message: "At least one field must be updated.",
330+
});
331+
}
332+
333+
const updateExpressionParts: string[] = [];
334+
const expressionAttributeNames: Record<string, string> = {};
335+
const expressionAttributeValues: Record<string, any> = {};
336+
337+
for (const [key, value] of Object.entries(updateData)) {
338+
updateExpressionParts.push(`#${key} = :${key}`);
339+
expressionAttributeNames[`#${key}`] = key;
340+
expressionAttributeValues[`:${key}`] = value;
341+
}
342+
343+
const updateExpression = `SET ${updateExpressionParts.join(", ")}`;
344+
345+
const command = new UpdateItemCommand({
346+
TableName: genericConfig.EventsDynamoTableName,
347+
Key: { id: { S: entryUUID } },
348+
UpdateExpression: updateExpression,
349+
ExpressionAttributeNames: expressionAttributeNames,
350+
ConditionExpression: "attribute_exists(id)",
351+
ExpressionAttributeValues: marshall(expressionAttributeValues),
352+
ReturnValues: "ALL_OLD",
353+
});
354+
let oldAttributes;
355+
let updatedEntry;
356+
try {
357+
oldAttributes = (await fastify.dynamoClient.send(command)).Attributes;
358+
359+
if (!oldAttributes) {
360+
throw new DatabaseInsertError({
361+
message: "Item not found or update failed.",
336362
});
337363
}
338-
originalEvent = unmarshall(originalEvent);
364+
365+
const oldEntry = oldAttributes ? unmarshall(oldAttributes) : null;
366+
// we know updateData has no undefines because we filtered them out.
367+
updatedEntry = {
368+
...oldEntry,
369+
...updateData,
370+
} as unknown as IUpdateDiscord;
371+
} catch (e: unknown) {
372+
if (
373+
e instanceof Error &&
374+
e.name === "ConditionalCheckFailedException"
375+
) {
376+
throw new NotFoundError({ endpointName: request.url });
377+
}
378+
if (e instanceof BaseError) {
379+
throw e;
380+
}
381+
request.log.error(e);
382+
throw new DiscordEventError({});
339383
}
340-
let verb = "created";
341-
if (userProvidedId && userProvidedId === entryUUID) {
342-
verb = "modified";
384+
if (updatedEntry.featured && !updatedEntry.repeats) {
385+
try {
386+
await updateDiscord(
387+
{
388+
botToken: fastify.secretConfig.discord_bot_token,
389+
guildId: fastify.environmentConfig.DiscordGuildId,
390+
},
391+
updatedEntry,
392+
request.username,
393+
false,
394+
request.log,
395+
);
396+
} catch (e) {
397+
await fastify.dynamoClient.send(
398+
new PutItemCommand({
399+
TableName: genericConfig.EventsDynamoTableName,
400+
Item: oldAttributes!,
401+
}),
402+
);
403+
404+
if (e instanceof Error) {
405+
request.log.error(`Failed to publish event to Discord: ${e} `);
406+
}
407+
}
408+
}
409+
const postUpdatePromises = [
410+
atomicIncrementCacheCounter(
411+
fastify.dynamoClient,
412+
`events-etag-${entryUUID}`,
413+
1,
414+
false,
415+
),
416+
atomicIncrementCacheCounter(
417+
fastify.dynamoClient,
418+
"events-etag-all",
419+
1,
420+
false,
421+
),
422+
createAuditLogEntry({
423+
dynamoClient: fastify.dynamoClient,
424+
entry: {
425+
module: Modules.EVENTS,
426+
actor: request.username,
427+
target: entryUUID,
428+
message: "Updated target event.",
429+
requestId: request.id,
430+
},
431+
}),
432+
];
433+
await Promise.all(postUpdatePromises);
434+
435+
reply
436+
.status(201)
437+
.header(
438+
"Location",
439+
`${fastify.environmentConfig.UserFacingUrl}/api/v1/events/${entryUUID}`,
440+
)
441+
.send();
442+
} catch (e: unknown) {
443+
if (e instanceof Error) {
444+
request.log.error(`Failed to update DynamoDB: ${e.toString()}`);
445+
}
446+
if (e instanceof BaseError) {
447+
throw e;
343448
}
449+
throw new DatabaseInsertError({
450+
message: "Failed to update event in Dynamo table.",
451+
});
452+
}
453+
},
454+
);
455+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
456+
"/",
457+
{
458+
schema: withRoles(
459+
[AppRoles.EVENTS_MANAGER],
460+
withTags(["Events"], {
461+
body: postRequestSchema,
462+
summary: "Create a calendar event.",
463+
}),
464+
) satisfies FastifyZodOpenApiSchema,
465+
onRequest: fastify.authorizeFromSchema,
466+
},
467+
async (request, reply) => {
468+
if (!request.username) {
469+
throw new UnauthenticatedError({ message: "Username not found." });
470+
}
471+
try {
472+
const entryUUID = randomUUID();
344473
const entry = {
345474
...request.body,
346475
id: entryUUID,
347-
createdAt:
348-
originalEvent && originalEvent.createdAt
349-
? originalEvent.createdAt
350-
: new Date().toISOString(),
476+
createdAt: new Date().toISOString(),
351477
updatedAt: new Date().toISOString(),
352478
};
353479
await fastify.dynamoClient.send(
@@ -377,14 +503,6 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
377503
Key: { id: { S: entryUUID } },
378504
}),
379505
);
380-
if (userProvidedId) {
381-
await fastify.dynamoClient.send(
382-
new PutItemCommand({
383-
TableName: genericConfig.EventsDynamoTableName,
384-
Item: originalEvent,
385-
}),
386-
);
387-
}
388506

389507
if (e instanceof Error) {
390508
request.log.error(`Failed to publish event to Discord: ${e} `);
@@ -394,32 +512,38 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
394512
}
395513
throw new DiscordEventError({});
396514
}
397-
await atomicIncrementCacheCounter(
398-
fastify.dynamoClient,
399-
`events-etag-${entryUUID}`,
400-
1,
401-
false,
402-
);
403-
await atomicIncrementCacheCounter(
404-
fastify.dynamoClient,
405-
"events-etag-all",
406-
1,
407-
false,
408-
);
409-
await createAuditLogEntry({
410-
dynamoClient: fastify.dynamoClient,
411-
entry: {
412-
module: Modules.EVENTS,
413-
actor: request.username,
414-
target: entryUUID,
415-
message: `${verb} event "${entryUUID}"`,
416-
requestId: request.id,
417-
},
418-
});
419-
reply.status(201).send({
420-
id: entryUUID,
421-
resource: `/api/v1/events/${entryUUID}`,
422-
});
515+
const postUpdatePromises = [
516+
atomicIncrementCacheCounter(
517+
fastify.dynamoClient,
518+
`events-etag-${entryUUID}`,
519+
1,
520+
false,
521+
),
522+
atomicIncrementCacheCounter(
523+
fastify.dynamoClient,
524+
"events-etag-all",
525+
1,
526+
false,
527+
),
528+
createAuditLogEntry({
529+
dynamoClient: fastify.dynamoClient,
530+
entry: {
531+
module: Modules.EVENTS,
532+
actor: request.username,
533+
target: entryUUID,
534+
message: "Created target event.",
535+
requestId: request.id,
536+
},
537+
}),
538+
];
539+
await Promise.all(postUpdatePromises);
540+
reply
541+
.status(201)
542+
.header(
543+
"Location",
544+
`${fastify.environmentConfig.UserFacingUrl}/api/v1/events/${entryUUID}`,
545+
)
546+
.send();
423547
} catch (e: unknown) {
424548
if (e instanceof Error) {
425549
request.log.error(`Failed to insert to DynamoDB: ${e.toString()}`);

tests/live/events.test.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test("metadata is not included when includeMetadata query parameter is unset", a
5151

5252
describe("Event lifecycle tests", async () => {
5353
let createdEventUuid: string;
54-
test("creating an event", { timeout: 30000 }, async () => {
54+
test("Creating an event", { timeout: 30000 }, async () => {
5555
const token = await createJwt();
5656
const response = await fetch(`${baseEndpoint}/api/v1/events`, {
5757
method: "POST",
@@ -70,13 +70,16 @@ describe("Event lifecycle tests", async () => {
7070
repeats: "weekly",
7171
}),
7272
});
73-
const responseJson = await response.json();
74-
expect(response.status).toBe(201);
75-
expect(responseJson).toHaveProperty("id");
76-
expect(responseJson).toHaveProperty("resource");
77-
createdEventUuid = responseJson.id;
73+
if (response.headers.get("location")) {
74+
createdEventUuid = response.headers
75+
.get("location")!
76+
.split("/")
77+
.at(-1) as string;
78+
}
79+
expect(response.headers.get("location")).toBeDefined();
80+
expect(response.headers.get("location")).not.toBeNull();
7881
});
79-
test("getting a created event", { timeout: 30000 }, async () => {
82+
test("Getting an event", { timeout: 30000 }, async () => {
8083
if (!createdEventUuid) {
8184
throw new Error("Event UUID not found");
8285
}
@@ -96,6 +99,47 @@ describe("Event lifecycle tests", async () => {
9699
expect(responseJson["repeatEnds"]).toBeUndefined();
97100
createdEventUuid = responseJson.id;
98101
});
102+
test("Modifying an event", { timeout: 30000 }, async () => {
103+
const token = await createJwt();
104+
if (!createdEventUuid) {
105+
throw new Error("Event UUID not found");
106+
}
107+
const response = await fetch(
108+
`${baseEndpoint}/api/v1/events/${createdEventUuid}?ts=${Date.now()}`,
109+
{
110+
method: "PATCH",
111+
headers: {
112+
Authorization: `Bearer ${token}`,
113+
"Content-Type": "application/json",
114+
},
115+
body: JSON.stringify({
116+
description: "An event of all time THAT HAS BEEN MODIFIED",
117+
}),
118+
},
119+
);
120+
expect(response.status).toBe(201);
121+
});
122+
test("Getting a modified event", { timeout: 30000 }, async () => {
123+
if (!createdEventUuid) {
124+
throw new Error("Event UUID not found");
125+
}
126+
const response = await fetch(
127+
`${baseEndpoint}/api/v1/events/${createdEventUuid}?ts=${Date.now()}`,
128+
{
129+
method: "GET",
130+
headers: {
131+
"Content-Type": "application/json",
132+
},
133+
},
134+
);
135+
const responseJson = await response.json();
136+
expect(response.status).toBe(200);
137+
expect(responseJson).toHaveProperty("id");
138+
expect(responseJson).toHaveProperty("description");
139+
expect(responseJson["description"]).toStrictEqual(
140+
"An event of all time THAT HAS BEEN MODIFIED",
141+
);
142+
});
99143

100144
test("deleting a previously-created event", { timeout: 30000 }, async () => {
101145
if (!createdEventUuid) {

0 commit comments

Comments
 (0)