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..f6b6098 --- /dev/null +++ b/src/app/api/user/[user]/calendar/route.ts @@ -0,0 +1,212 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + userCalendarQuerySchema, + UserCalendarResponseSchema, + UserCalendarSchema, +} from './validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} 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 dataRaw = Object.fromEntries(new URL(req.url).searchParams); + const data = await userCalendarQuerySchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { end, start } = data.data; + + const requestUserId = authCheck.user.id; + + const requestedUserId = (await params).user; + + const requestedUser = await prisma.user.findFirst({ + where: { + id: requestedUserId, + }, + select: { + meetingParts: { + where: { + meeting: { + start_time: { + lte: end, + }, + end_time: { + gte: start, + }, + }, + }, + orderBy: { + meeting: { + start_time: 'asc', + }, + }, + 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: { + where: { + start_time: { + lte: end, + }, + end_time: { + gte: start, + }, + }, + orderBy: { + start_time: 'asc', + }, + 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: { + where: { + start_time: { + lte: end, + }, + end_time: { + gte: start, + }, + }, + orderBy: { + start_time: 'asc', + }, + 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..fb48629 --- /dev/null +++ b/src/app/api/user/[user]/calendar/swagger.ts @@ -0,0 +1,37 @@ +import { + userCalendarQuerySchema, + 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, + }), + query: userCalendarQuerySchema, + }, + 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..a0d179f --- /dev/null +++ b/src/app/api/user/[user]/calendar/validation.ts @@ -0,0 +1,99 @@ +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, +}); + +export const userCalendarQuerySchema = zod + .object({ + start: zod.iso + .datetime() + .optional() + .transform((val) => { + if (val) return new Date(val); + const now = new Date(); + const startOfWeek = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - now.getDay(), + ); + return startOfWeek; + }), + end: zod.iso + .datetime() + .optional() + .transform((val) => { + if (val) return new Date(val); + const now = new Date(); + const endOfWeek = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + (6 - now.getDay()), + ); + return endOfWeek; + }), + }) + .openapi('UserCalendarQuerySchema', { + description: 'Query parameters for filtering the user calendar', + properties: { + start: { + type: 'string', + format: 'date-time', + description: 'Start date for filtering the calendar events', + }, + end: { + type: 'string', + format: 'date-time', + description: 'End date for filtering the calendar events', + }, + }, + });