From 4a749a9b4a2912434fa76a133d9ccf21dafc175e Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Fri, 20 Jun 2025 13:29:22 +0200 Subject: [PATCH] feat(api): implement /api/user/[user]/calendar endpoint --- src/app/api/user/[user]/calendar/route.ts | 153 ++++++++++++++++++ src/app/api/user/[user]/calendar/swagger.ts | 33 ++++ .../api/user/[user]/calendar/validation.ts | 52 ++++++ 3 files changed, 238 insertions(+) create mode 100644 src/app/api/user/[user]/calendar/route.ts create mode 100644 src/app/api/user/[user]/calendar/swagger.ts create mode 100644 src/app/api/user/[user]/calendar/validation.ts diff --git a/src/app/api/user/[user]/calendar/route.ts b/src/app/api/user/[user]/calendar/route.ts new file mode 100644 index 0000000..cf714f3 --- /dev/null +++ b/src/app/api/user/[user]/calendar/route.ts @@ -0,0 +1,153 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { UserCalendarResponseSchema, UserCalendarSchema } from './validation'; +import { ErrorResponseSchema } from '@/app/api/validation'; +import { z } from 'zod/v4'; + +export const GET = auth(async function GET(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const requestUserId = authCheck.user.id; + + const requestedUserId = (await params).user; + + const requestedUser = await prisma.user.findFirst({ + where: { + id: requestedUserId, + }, + select: { + meetingParts: { + select: { + meeting: { + select: { + id: true, + title: true, + description: true, + start_time: true, + end_time: true, + status: true, + location: true, + created_at: true, + updated_at: true, + organizer_id: true, + participants: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, + }, + }, + }, + }, + meetingsOrg: { + select: { + id: true, + title: true, + description: true, + start_time: true, + end_time: true, + status: true, + location: true, + created_at: true, + updated_at: true, + organizer_id: true, + participants: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, + }, + }, + blockedSlots: { + select: { + id: requestUserId === requestedUserId ? true : false, + reason: requestUserId === requestedUserId ? true : false, + start_time: true, + end_time: true, + is_recurring: requestUserId === requestedUserId ? true : false, + recurrence_end_date: requestUserId === requestedUserId ? true : false, + rrule: requestUserId === requestedUserId ? true : false, + created_at: requestUserId === requestedUserId ? true : false, + updated_at: requestUserId === requestedUserId ? true : false, + }, + }, + }, + }); + + if (!requestedUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { success: false, message: 'User not found' }, + { status: 404 }, + ); + + const calendar: z.input = []; + + for (const event of requestedUser.meetingParts) { + if ( + event.meeting.participants.some((p) => p.user.id === requestUserId) || + event.meeting.organizer_id === requestUserId + ) { + calendar.push({...event.meeting, type: 'event' }); + } else { + calendar.push({ + start_time: event.meeting.start_time, + end_time: event.meeting.end_time, + type: 'blocked_private', + }); + } + } + + for (const event of requestedUser.meetingsOrg) { + if ( + event.participants.some((p) => p.user.id === requestUserId) || + event.organizer_id === requestUserId + ) { + calendar.push({...event, type: 'event' }); + } else { + calendar.push({ + start_time: event.start_time, + end_time: event.end_time, + type: 'blocked_private', + }); + } + } + + for (const slot of requestedUser.blockedSlots) { + calendar.push({ + start_time: slot.start_time, + end_time: slot.end_time, + id: slot.id, + reason: slot.reason, + is_recurring: slot.is_recurring, + recurrence_end_date: slot.recurrence_end_date, + rrule: slot.rrule, + created_at: slot.created_at, + updated_at: slot.updated_at, + type: requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private', + }); + } + + return returnZodTypeCheckedResponse(UserCalendarResponseSchema, { + success: true, + calendar, + }); +}); diff --git a/src/app/api/user/[user]/calendar/swagger.ts b/src/app/api/user/[user]/calendar/swagger.ts new file mode 100644 index 0000000..eced132 --- /dev/null +++ b/src/app/api/user/[user]/calendar/swagger.ts @@ -0,0 +1,33 @@ +import { UserCalendarResponseSchema } from './validation'; +import { + notAuthenticatedResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { UserIdParamSchema } from '@/app/api/validation'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/user/{user}/calendar', + request: { + params: zod.object({ + user: UserIdParamSchema, + }), + }, + responses: { + 200: { + description: 'User calendar retrieved successfully.', + content: { + 'application/json': { + schema: UserCalendarResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + }, + tags: ['User'], + }); +} diff --git a/src/app/api/user/[user]/calendar/validation.ts b/src/app/api/user/[user]/calendar/validation.ts new file mode 100644 index 0000000..67a46d7 --- /dev/null +++ b/src/app/api/user/[user]/calendar/validation.ts @@ -0,0 +1,52 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { + eventEndTimeSchema, + EventSchema, + eventStartTimeSchema, +} from '@/app/api/event/validation'; + +extendZodWithOpenApi(zod); + +export const BlockedSlotSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + type: zod.literal('blocked_private'), + }) + .openapi('BlockedSlotSchema', { + description: 'Blocked time slot in the user calendar', + }); + +export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({ + id: zod.string(), + reason: zod.string().nullish(), + is_recurring: zod.boolean().default(false), + recurrence_end_date: zod.date().nullish(), + rrule: zod.string().nullish(), + created_at: zod.date().nullish(), + updated_at: zod.date().nullish(), + type: zod.literal('blocked_owned'), +}).openapi('OwnedBlockedSlotSchema', { + description: 'Blocked slot owned by the user', +}); + +export const VisibleSlotSchema = EventSchema.omit({ + organizer: true, + participants: true, +}).extend({ + type: zod.literal('event'), +}).openapi('VisibleSlotSchema', { + description: 'Visible time slot in the user calendar', +}); + +export const UserCalendarSchema = zod + .array(VisibleSlotSchema.or(BlockedSlotSchema).or(OwnedBlockedSlotSchema)) + .openapi('UserCalendarSchema', { + description: 'Array of events in the user calendar', + }); + +export const UserCalendarResponseSchema = zod.object({ + success: zod.boolean().default(true), + calendar: UserCalendarSchema, +});