88 PutItemCommand ,
99 QueryCommand ,
1010 ScanCommand ,
11+ UpdateItemCommand ,
1112} from "@aws-sdk/client-dynamodb" ;
1213import { EVENT_CACHED_DURATION , genericConfig } from "../../common/config.js" ;
1314import { 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 ( ) } ` ) ;
0 commit comments