diff --git a/src/app/api/event/route.ts b/src/app/api/event/route.ts new file mode 100644 index 0000000..fb734b1 --- /dev/null +++ b/src/app/api/event/route.ts @@ -0,0 +1,178 @@ +import { prisma } from '@/prisma'; +import { auth } from '@/auth'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { ErrorResponseSchema, ZodErrorResponseSchema } from '../validation'; +import { + createEventSchema, + EventResponseSchema, + EventsResponseSchema, +} from './validation'; + +export const GET = auth(async (req) => { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + }); + + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { success: false, message: 'User not found' }, + { status: 404 }, + ); + + const userEvents = await prisma.meeting.findMany({ + where: { + OR: [ + { organizer_id: dbUser.id }, + { participants: { some: { user_id: dbUser.id } } }, + ], + }, + select: { + id: true, + title: true, + description: true, + start_time: true, + end_time: true, + status: true, + location: true, + created_at: true, + updated_at: true, + organizer: { + select: { + id: true, + name: true, + first_name: true, + last_name: true, + image: true, + timezone: true, + }, + }, + participants: { + select: { + user: { + select: { + id: true, + name: true, + first_name: true, + last_name: true, + image: true, + timezone: true, + }, + }, + status: true, + }, + }, + }, + }); + + return returnZodTypeCheckedResponse( + EventsResponseSchema, + { + success: true, + events: userEvents, + }, + { status: 200 }, + ); +}); + +export const POST = auth(async (req) => { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw = await req.json(); + const data = await createEventSchema.safeParseAsync(dataRaw); + if (!data.success) { + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + } + const { title, description, start_time, end_time, location, participants } = + data.data; + + const newEvent = await prisma.meeting.create({ + data: { + title, + description, + start_time, + end_time, + location, + organizer_id: authCheck.user.id!, + participants: participants + ? { + create: participants.map((userId) => ({ + user: { connect: { id: userId } }, + })), + } + : undefined, + }, + select: { + id: true, + title: true, + description: true, + start_time: true, + end_time: true, + status: true, + location: true, + created_at: true, + updated_at: true, + organizer: { + select: { + id: true, + name: true, + first_name: true, + last_name: true, + image: true, + timezone: true, + }, + }, + participants: { + select: { + user: { + select: { + id: true, + name: true, + first_name: true, + last_name: true, + image: true, + timezone: true, + }, + }, + status: true, + }, + }, + }, + }); + + return returnZodTypeCheckedResponse( + EventResponseSchema, + { + success: true, + event: newEvent, + }, + { status: 201 }, + ); +}); diff --git a/src/app/api/event/swagger.ts b/src/app/api/event/swagger.ts new file mode 100644 index 0000000..b78afef --- /dev/null +++ b/src/app/api/event/swagger.ts @@ -0,0 +1,62 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { + EventResponseSchema, + EventsResponseSchema, + createEventSchema, +} from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/event', + responses: { + 200: { + description: 'List of events for the authenticated user', + content: { + 'application/json': { + schema: EventsResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Event'], + }); + + registry.registerPath({ + method: 'post', + path: '/api/event', + request: { + body: { + content: { + 'application/json': { + schema: createEventSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Event created successfully.', + content: { + 'application/json': { + schema: EventResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Event'], + }); +} diff --git a/src/app/api/event/validation.ts b/src/app/api/event/validation.ts new file mode 100644 index 0000000..b8e176b --- /dev/null +++ b/src/app/api/event/validation.ts @@ -0,0 +1,167 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { + existingUserIdServerSchema, + PublicUserSchema, +} from '../user/validation'; +import { ParticipantSchema } from './[eventID]/participant/validation'; + +extendZodWithOpenApi(zod); + +// ---------------------------------------- +// +// Event ID Validation +// +// ---------------------------------------- +export const eventIdSchema = zod.string().min(1, 'Event ID is required'); + +// ---------------------------------------- +// +// Event Title Validation +// +// ---------------------------------------- +export const eventTitleSchema = zod + .string() + .min(1, 'Title is required') + .max(100, 'Title must be at most 100 characters long'); + +// ---------------------------------------- +// +// Event Description Validation +// +// ---------------------------------------- +export const eventDescriptionSchema = zod + .string() + .max(500, 'Description must be at most 500 characters long'); + +// ---------------------------------------- +// +// Event start time Validation +// +// ---------------------------------------- +export const eventStartTimeSchema = zod.iso + .datetime() + .or(zod.date().transform((date) => date.toISOString())) + .refine((date) => !isNaN(new Date(date).getTime()), { + message: 'Invalid start time', + }); + +// ---------------------------------------- +// +// Event end time Validation +// +// ---------------------------------------- +export const eventEndTimeSchema = zod.iso.datetime().or( + zod + .date() + .transform((date) => date.toISOString()) + .refine((date) => !isNaN(new Date(date).getTime()), { + message: 'Invalid end time', + }), +); + +// ---------------------------------------- +// +// Event Location Validation +// +// ---------------------------------------- +export const eventLocationSchema = zod + .string() + .max(200, 'Location must be at most 200 characters long'); + +// ---------------------------------------- +// +// Event Participants Validation +// +// ---------------------------------------- +export const eventParticipantsSchema = zod.array(existingUserIdServerSchema); + +// ---------------------------------------- +// +// Event Status Validation +// +// ---------------------------------------- +export const eventStatusSchema = zod.enum([ + 'TENTATIVE', + 'CONFIRMED', + 'CANCELLED', +]); + +// ---------------------------------------- +// +// Create Event Schema +// +// ---------------------------------------- +export const createEventSchema = zod + .object({ + title: eventTitleSchema, + description: eventDescriptionSchema.optional(), + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + location: eventLocationSchema.optional().default(''), + participants: eventParticipantsSchema.optional(), + status: eventStatusSchema.optional().default('TENTATIVE'), + }) + .refine((data) => new Date(data.start_time) < new Date(data.end_time), { + message: 'Start time must be before end time', + }); + +// ---------------------------------------- +// +// Update Event Schema +// +// ---------------------------------------- +export const updateEventSchema = zod + .object({ + title: eventTitleSchema.optional(), + description: eventDescriptionSchema.optional(), + start_time: eventStartTimeSchema.optional(), + end_time: eventEndTimeSchema.optional(), + location: eventLocationSchema.optional().default(''), + participants: eventParticipantsSchema.optional(), + status: eventStatusSchema.optional(), + }) + .refine( + (data) => { + if (data.start_time && data.end_time) { + return new Date(data.start_time) < new Date(data.end_time); + } + return true; + }, + { + message: 'Start time must be before end time', + }, + ); + +// ---------------------------------------- +// +// Event Schema Validation (for API responses) +// +// ---------------------------------------- +export const EventSchema = zod + .object({ + id: eventIdSchema, + title: eventTitleSchema, + description: eventDescriptionSchema.nullish(), + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + location: eventLocationSchema.nullish(), + status: eventStatusSchema, + created_at: zod.date(), + updated_at: zod.date(), + organizer: PublicUserSchema, + participants: zod.array(ParticipantSchema).nullish(), + }) + .openapi('Event', { + description: 'Event information including all fields', + }); + +export const EventResponseSchema = zod.object({ + success: zod.boolean(), + event: EventSchema, +}); + +export const EventsResponseSchema = zod.object({ + success: zod.boolean(), + events: zod.array(EventSchema), +});