diff --git a/.gitignore b/.gitignore
index 918f651..cda64ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,5 +43,5 @@ next-env.d.ts
# database
/prisma/*.db*
-src/generated/prisma
+src/generated/*
data
diff --git a/Dockerfile b/Dockerfile
index ffd911a..b60e118 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,8 @@ RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn prisma:generate
+RUN yarn swagger:generate
+RUN yarn orval:generate
RUN yarn build
# ----- Runner -----
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 3aa4174..feae683 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -13,6 +13,7 @@ services:
- .env.local
volumes:
- ./data:/data
+ - ./src/generated:/app/src/generated
develop:
watch:
- action: sync
@@ -20,8 +21,12 @@ services:
target: /app/src
ignore:
- node_modules/
+ - generated/
- action: rebuild
path: package.json
- action: sync+restart
path: prisma
target: /app/prisma
+ - action: sync+restart
+ path: ./src/app/api
+ target: /app/src/app/api
diff --git a/docker-compose.yml b/docker-compose.yml
index cee59f7..2412d46 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,6 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
+ network: host
image: git.dominikstahl.dev/dhbw-we/meetup:main
ports:
- '3000:3000'
diff --git a/entrypoint.dev.sh b/entrypoint.dev.sh
index ec103bf..64b45df 100644
--- a/entrypoint.dev.sh
+++ b/entrypoint.dev.sh
@@ -7,4 +7,7 @@ if [ -d "prisma" ]; then
yarn prisma:db:push
fi
+yarn swagger:generate
+yarn orval:generate
+
exec yarn dev
diff --git a/exportSwagger.ts b/exportSwagger.ts
new file mode 100644
index 0000000..1eb2837
--- /dev/null
+++ b/exportSwagger.ts
@@ -0,0 +1,62 @@
+import { registry } from '@/lib/swagger';
+import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
+import fs from 'fs';
+import path from 'path';
+
+function recursiveFileSearch(dir: string, fileList: string[] = []): string[] {
+ const files = fs.readdirSync(dir);
+ files.forEach((file) => {
+ const filePath = path.join(dir, file);
+ if (fs.statSync(filePath).isDirectory()) {
+ recursiveFileSearch(filePath, fileList);
+ } else if (file.match(/swagger\.ts$/)) {
+ fileList.push(filePath);
+ }
+ });
+ return fileList;
+}
+
+async function exportSwagger() {
+ const filesToImport = recursiveFileSearch(
+ path.join(process.cwd(), 'src', 'app', 'api'),
+ );
+
+ await Promise.all(
+ filesToImport.map((file) => {
+ return import(file)
+ .then((module) => {
+ if (module.default) {
+ module.default(registry);
+ }
+ })
+ .catch((error) => {
+ console.error(`Error importing ${file}:`, error);
+ });
+ }),
+ );
+
+ await import('./src/app/api/validation');
+
+ const generator = new OpenApiGeneratorV3(registry.definitions);
+ const spec = generator.generateDocument({
+ openapi: '3.0.0',
+ info: {
+ version: '1.0.0',
+ title: 'MeetUP',
+ description: 'API documentation for MeetUP application',
+ },
+ });
+
+ const outputPath = path.join(
+ process.cwd(),
+ 'src',
+ 'generated',
+ 'swagger.json',
+ );
+ fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2), 'utf8');
+ console.log(`Swagger JSON generated at ${outputPath}`);
+}
+
+exportSwagger().catch((error) => {
+ console.error('Error exporting Swagger:', error);
+});
diff --git a/orval.config.js b/orval.config.js
new file mode 100644
index 0000000..718a86a
--- /dev/null
+++ b/orval.config.js
@@ -0,0 +1,10 @@
+module.exports = {
+ meetup: {
+ input: './src/generated/swagger.json',
+ output: {
+ mode: 'tags-split',
+ target: './src/generated/api/meetup.ts',
+ client: 'react-query',
+ },
+ },
+};
diff --git a/package.json b/package.json
index 47fb175..df974cb 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,12 @@
"prisma:studio": "dotenv -e .env.local -- prisma studio",
"prisma:db:push": "dotenv -e .env.local -- prisma db push",
"prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset",
- "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build"
+ "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build",
+ "swagger:generate": "ts-node -r tsconfig-paths/register exportSwagger.ts",
+ "orval:generate": "orval"
},
"dependencies": {
+ "@asteasolutions/zod-to-openapi": "^8.0.0-beta.4",
"@auth/prisma-adapter": "^2.9.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
@@ -33,16 +36,18 @@
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
+ "@tanstack/react-query": "^5.80.7",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
- "next": "15.3.4",
+ "next": "15.4.0-canary.85",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.4",
+ "swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0",
"zod": "^3.25.60"
},
@@ -52,16 +57,21 @@
"@types/node": "22.15.32",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
+ "@types/swagger-ui-react": "^5",
+ "@types/webpack-env": "^1.18.8",
"dotenv-cli": "8.0.0",
"eslint": "9.29.0",
"eslint-config-next": "15.3.4",
"eslint-config-prettier": "10.1.5",
+ "orval": "^7.10.0",
"postcss": "8.5.6",
"prettier": "3.5.3",
"prisma": "6.9.0",
"tailwindcss": "4.1.10",
+ "ts-node": "^10.9.2",
+ "tsconfig-paths": "^4.2.0",
"tw-animate-css": "1.3.4",
- "typescript": "5.8.3"
+ "typescript": "^5.8.3"
},
"packageManager": "yarn@4.9.2"
}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 712a068..ffa1c86 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -158,8 +158,8 @@ model Friendship {
requested_at DateTime @default(now())
accepted_at DateTime?
- user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id])
- user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id])
+ user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id], onDelete: Cascade)
+ user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id], onDelete: Cascade)
@@id([user_id_1, user_id_2])
@@index([user_id_2, status], name: "idx_friendships_user2_status")
@@ -187,8 +187,8 @@ model GroupMember {
role group_member_role @default(MEMBER)
added_at DateTime @default(now())
- group Group @relation(fields: [group_id], references: [id])
- user User @relation(fields: [user_id], references: [id])
+ group Group @relation(fields: [group_id], references: [id], onDelete: Cascade)
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([group_id, user_id])
@@index([user_id])
@@ -207,7 +207,7 @@ model BlockedSlot {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, start_time, end_time])
@@index([user_id, is_recurring])
@@ -241,8 +241,8 @@ model MeetingParticipant {
status participant_status @default(PENDING)
added_at DateTime @default(now())
- meeting Meeting @relation(fields: [meeting_id], references: [id])
- user User @relation(fields: [user_id], references: [id])
+ meeting Meeting @relation(fields: [meeting_id], references: [id], onDelete: Cascade)
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([meeting_id, user_id])
@@index([user_id, status], name: "idx_participants_user_status")
@@ -259,7 +259,7 @@ model Notification {
is_read Boolean @default(false)
created_at DateTime @default(now())
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id, is_read, created_at], name: "idx_notifications_user_read_time")
@@map("notifications")
@@ -271,7 +271,7 @@ model UserNotificationPreference {
email_enabled Boolean @default(false)
updated_at DateTime @default(now())
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@id([user_id, notification_type])
@@map("user_notification_preferences")
@@ -292,7 +292,7 @@ model EmailQueue {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([status, scheduled_at], name: "idx_email_queue_pending_jobs")
@@index([user_id, created_at], name: "idx_email_queue_user_history")
@@ -308,7 +308,7 @@ model CalendarExportToken {
created_at DateTime @default(now())
last_accessed_at DateTime?
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@map("calendar_export_tokens")
@@ -327,7 +327,7 @@ model CalendarSubscription {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
- user User @relation(fields: [user_id], references: [id])
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
externalEvents ExternalEvent[]
@@index([user_id, is_enabled])
@@ -350,7 +350,7 @@ model ExternalEvent {
show_as_free Boolean @default(false)
last_fetched_at DateTime @default(now())
- subscription CalendarSubscription @relation(fields: [subscription_id], references: [id])
+ subscription CalendarSubscription @relation(fields: [subscription_id], references: [id], onDelete: Cascade)
@@unique([subscription_id, ical_uid], name: "uq_external_event_sub_uid")
@@index([subscription_id, start_time, end_time])
diff --git a/src/app/api-doc/page.tsx b/src/app/api-doc/page.tsx
new file mode 100644
index 0000000..c6e9694
--- /dev/null
+++ b/src/app/api-doc/page.tsx
@@ -0,0 +1,11 @@
+import { getApiDocs } from '@/lib/swagger';
+import ReactSwagger from './react-swagger';
+
+export default async function IndexPage() {
+ const spec = await getApiDocs();
+ return (
+
+ );
+}
diff --git a/src/app/api-doc/react-swagger.tsx b/src/app/api-doc/react-swagger.tsx
new file mode 100644
index 0000000..0dd3931
--- /dev/null
+++ b/src/app/api-doc/react-swagger.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import SwaggerUI from 'swagger-ui-react';
+import 'swagger-ui-react/swagger-ui.css';
+
+type Props = {
+ spec: object;
+};
+
+function ReactSwagger({ spec }: Props) {
+ return ;
+}
+
+export default ReactSwagger;
diff --git a/src/app/api/event/[eventID]/participant/[user]/route.ts b/src/app/api/event/[eventID]/participant/[user]/route.ts
new file mode 100644
index 0000000..890308c
--- /dev/null
+++ b/src/app/api/event/[eventID]/participant/[user]/route.ts
@@ -0,0 +1,276 @@
+import { prisma } from '@/prisma';
+import { auth } from '@/auth';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import {
+ ErrorResponseSchema,
+ SuccessResponseSchema,
+ ZodErrorResponseSchema,
+} from '@/app/api/validation';
+import {
+ ParticipantResponseSchema,
+ updateParticipantSchema,
+} from '../validation';
+
+export const GET = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+ const user = (await params).user;
+
+ const isParticipant = await prisma.meetingParticipant.findFirst({
+ where: {
+ meeting_id: eventID,
+ user_id: dbUser.id,
+ },
+ });
+
+ const isOrganizer = await prisma.meeting.findFirst({
+ where: {
+ id: eventID,
+ organizer_id: dbUser.id,
+ },
+ });
+
+ if (!isParticipant && !isOrganizer)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User is not a participant or organizer of this event',
+ },
+ { status: 403 },
+ );
+
+ const participant = await prisma.meetingParticipant.findUnique({
+ where: {
+ meeting_id_user_id: {
+ meeting_id: eventID,
+ user_id: user,
+ },
+ },
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+ },
+ },
+ status: true,
+ },
+ });
+
+ if (!participant)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Participant not found' },
+ { status: 404 },
+ );
+
+ return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
+ success: true,
+ participant,
+ });
+});
+
+export const DELETE = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+ const user = (await params).user;
+
+ const isOrganizer = await prisma.meeting.findFirst({
+ where: {
+ id: eventID,
+ organizer_id: dbUser.id,
+ },
+ });
+
+ if (!isOrganizer)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Only organizer can remove participants' },
+ { status: 403 },
+ );
+
+ const participant = await prisma.meetingParticipant.findUnique({
+ where: {
+ meeting_id_user_id: {
+ meeting_id: eventID,
+ user_id: user,
+ },
+ },
+ });
+
+ if (!participant)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Participant not found' },
+ { status: 404 },
+ );
+
+ await prisma.meetingParticipant.delete({
+ where: {
+ meeting_id_user_id: {
+ meeting_id: eventID,
+ user_id: user,
+ },
+ },
+ });
+
+ return returnZodTypeCheckedResponse(
+ SuccessResponseSchema,
+ { success: true, message: 'Participant removed successfully' },
+ { status: 200 },
+ );
+});
+
+export const PATCH = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+ const user = (await params).user;
+
+ if (dbUser.id !== user && dbUser.name !== user)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'You can only update your own participation' },
+ { status: 403 },
+ );
+
+ const participant = await prisma.meetingParticipant.findUnique({
+ where: {
+ meeting_id_user_id: {
+ meeting_id: eventID,
+ user_id: dbUser.id,
+ },
+ },
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+ },
+ },
+ status: true,
+ },
+ });
+
+ if (!participant)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Participant not found' },
+ { status: 404 },
+ );
+
+ const body = await req.json();
+ const parsedBody = await updateParticipantSchema.safeParseAsync(body);
+ if (!parsedBody.success)
+ return returnZodTypeCheckedResponse(
+ ZodErrorResponseSchema,
+ {
+ success: false,
+ message: 'Invalid request body',
+ errors: parsedBody.error.issues,
+ },
+ { status: 400 },
+ );
+ const { status } = parsedBody.data;
+
+ const updatedParticipant = await prisma.meetingParticipant.update({
+ where: {
+ meeting_id_user_id: {
+ meeting_id: eventID,
+ user_id: dbUser.id,
+ },
+ },
+ data: {
+ status,
+ },
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+ },
+ },
+ status: true,
+ },
+ });
+
+ return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
+ success: true,
+ participant: updatedParticipant,
+ });
+});
diff --git a/src/app/api/event/[eventID]/participant/[user]/swagger.ts b/src/app/api/event/[eventID]/participant/[user]/swagger.ts
new file mode 100644
index 0000000..aaf0f5a
--- /dev/null
+++ b/src/app/api/event/[eventID]/participant/[user]/swagger.ts
@@ -0,0 +1,102 @@
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import zod from 'zod/v4';
+import {
+ ParticipantResponseSchema,
+ updateParticipantSchema,
+} from '../validation';
+import {
+ invalidRequestDataResponse,
+ notAuthenticatedResponse,
+ serverReturnedDataValidationErrorResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+import {
+ EventIdParamSchema,
+ UserIdParamSchema,
+ SuccessResponseSchema,
+} from '@/app/api/validation';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/event/{eventID}/participant/{user}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ user: UserIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Get a participant for the event',
+ content: {
+ 'application/json': {
+ schema: ParticipantResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event Participant'],
+ });
+
+ registry.registerPath({
+ method: 'delete',
+ path: '/event/{eventID}/participant/{user}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ user: UserIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Participant removed successfully',
+ content: {
+ 'application/json': {
+ schema: SuccessResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event Participant'],
+ });
+
+ registry.registerPath({
+ method: 'patch',
+ path: '/event/{eventID}/participant/{user}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ user: UserIdParamSchema,
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: updateParticipantSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Participant updated successfully',
+ content: {
+ 'application/json': {
+ schema: ParticipantResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ ...invalidRequestDataResponse,
+ },
+ tags: ['Event Participant'],
+ });
+}
diff --git a/src/app/api/event/[eventID]/participant/route.ts b/src/app/api/event/[eventID]/participant/route.ts
new file mode 100644
index 0000000..91ce965
--- /dev/null
+++ b/src/app/api/event/[eventID]/participant/route.ts
@@ -0,0 +1,200 @@
+import { prisma } from '@/prisma';
+import { auth } from '@/auth';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import {
+ ErrorResponseSchema,
+ ZodErrorResponseSchema,
+} from '@/app/api/validation';
+import {
+ inviteParticipantSchema,
+ ParticipantResponseSchema,
+ ParticipantsResponseSchema,
+} from './validation';
+
+export const GET = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+
+ const isParticipant = await prisma.meetingParticipant.findFirst({
+ where: {
+ meeting_id: eventID,
+ user_id: dbUser.id,
+ },
+ select: {
+ status: true,
+ },
+ });
+
+ const isOrganizer = await prisma.meeting.findFirst({
+ where: {
+ id: eventID,
+ organizer_id: dbUser.id,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (!isParticipant && !isOrganizer)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User is not a participant or organizer of this event',
+ },
+ { status: 403 },
+ );
+
+ const participants = await prisma.meetingParticipant.findMany({
+ where: {
+ meeting_id: eventID,
+ },
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+ },
+ },
+ status: true,
+ },
+ });
+
+ return returnZodTypeCheckedResponse(ParticipantsResponseSchema, {
+ success: true,
+ participants,
+ });
+});
+
+export const POST = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+
+ const isOrganizer = await prisma.meeting.findFirst({
+ where: {
+ id: eventID,
+ organizer_id: dbUser.id,
+ },
+ });
+
+ if (!isOrganizer)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Only organizers can add participants' },
+ { status: 403 },
+ );
+
+ const dataRaw = await req.json();
+ const data = await inviteParticipantSchema.safeParseAsync(dataRaw);
+ if (!data.success) {
+ return returnZodTypeCheckedResponse(
+ ZodErrorResponseSchema,
+ {
+ success: false,
+ message: 'Invalid request data',
+ errors: data.error.issues,
+ },
+ { status: 400 },
+ );
+ }
+ const { user_id } = data.data;
+
+ const participantExists = await prisma.meetingParticipant.findFirst({
+ where: {
+ meeting_id: eventID,
+ user_id,
+ },
+ });
+
+ if (participantExists)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User is already a participant of this event',
+ },
+ { status: 409 },
+ );
+
+ const newParticipant = await prisma.meetingParticipant.create({
+ data: {
+ meeting_id: eventID,
+ user_id,
+ },
+ select: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+ },
+ },
+ status: true,
+ },
+ });
+
+ return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
+ success: true,
+ participant: {
+ user: {
+ id: newParticipant.user.id,
+ name: newParticipant.user.name,
+ first_name: newParticipant.user.first_name,
+ last_name: newParticipant.user.last_name,
+ image: newParticipant.user.image,
+ timezone: newParticipant.user.timezone,
+ },
+ status: newParticipant.status,
+ },
+ });
+});
diff --git a/src/app/api/event/[eventID]/participant/swagger.ts b/src/app/api/event/[eventID]/participant/swagger.ts
new file mode 100644
index 0000000..919cfda
--- /dev/null
+++ b/src/app/api/event/[eventID]/participant/swagger.ts
@@ -0,0 +1,72 @@
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import zod from 'zod/v4';
+import {
+ ParticipantsResponseSchema,
+ ParticipantResponseSchema,
+ inviteParticipantSchema,
+} from './validation';
+import {
+ invalidRequestDataResponse,
+ notAuthenticatedResponse,
+ serverReturnedDataValidationErrorResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+import { EventIdParamSchema } from '@/app/api/validation';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/event/{eventID}/participant',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'List participants for the event',
+ content: {
+ 'application/json': {
+ schema: ParticipantsResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event Participant'],
+ });
+
+ registry.registerPath({
+ method: 'post',
+ path: '/event/{eventID}/participant',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: inviteParticipantSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Participant invited successfully',
+ content: {
+ 'application/json': {
+ schema: ParticipantResponseSchema,
+ },
+ },
+ },
+ ...invalidRequestDataResponse,
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event Participant'],
+ });
+}
diff --git a/src/app/api/event/[eventID]/participant/validation.ts b/src/app/api/event/[eventID]/participant/validation.ts
new file mode 100644
index 0000000..bacb9ac
--- /dev/null
+++ b/src/app/api/event/[eventID]/participant/validation.ts
@@ -0,0 +1,50 @@
+import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
+import zod from 'zod/v4';
+import {
+ existingUserIdServerSchema,
+ PublicUserSchema,
+} from '@/app/api/user/validation';
+
+extendZodWithOpenApi(zod);
+
+export const participantStatusSchema = zod.enum([
+ 'ACCEPTED',
+ 'DECLINED',
+ 'TENTATIVE',
+ 'PENDING',
+]);
+
+export const inviteParticipantSchema = zod
+ .object({
+ user_id: existingUserIdServerSchema,
+ })
+ .openapi('InviteParticipant', {
+ description: 'Schema for inviting a participant to an event',
+ });
+
+export const updateParticipantSchema = zod
+ .object({
+ status: participantStatusSchema,
+ })
+ .openapi('UpdateParticipant', {
+ description: 'Schema for updating participant status in an event',
+ });
+
+export const ParticipantSchema = zod
+ .object({
+ user: PublicUserSchema,
+ status: participantStatusSchema,
+ })
+ .openapi('Participant', {
+ description: 'Participant information including user and status',
+ });
+
+export const ParticipantResponseSchema = zod.object({
+ success: zod.boolean(),
+ participant: ParticipantSchema,
+});
+
+export const ParticipantsResponseSchema = zod.object({
+ success: zod.boolean(),
+ participants: zod.array(ParticipantSchema),
+});
diff --git a/src/app/api/event/[eventID]/route.ts b/src/app/api/event/[eventID]/route.ts
new file mode 100644
index 0000000..807c31b
--- /dev/null
+++ b/src/app/api/event/[eventID]/route.ts
@@ -0,0 +1,284 @@
+import { prisma } from '@/prisma';
+import { auth } from '@/auth';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import {
+ ErrorResponseSchema,
+ SuccessResponseSchema,
+ ZodErrorResponseSchema,
+} from '../../validation';
+import { EventResponseSchema } from '../validation';
+import { updateEventSchema } from '../validation';
+
+export const GET = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+
+ const event = await prisma.meeting.findUnique({
+ where: {
+ id: eventID,
+ 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,
+ organizer_id: 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,
+ },
+ },
+ },
+ });
+
+ if (!event)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Event not found' },
+ { status: 404 },
+ );
+
+ return returnZodTypeCheckedResponse(
+ EventResponseSchema,
+ { success: true, event },
+ { status: 200 },
+ );
+});
+
+export const DELETE = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+
+ const event = await prisma.meeting.findUnique({
+ where: {
+ id: eventID,
+ OR: [
+ { organizer_id: dbUser.id },
+ { participants: { some: { user_id: dbUser.id } } },
+ ],
+ },
+ });
+
+ if (!event)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Event not found' },
+ { status: 404 },
+ );
+
+ if (event.organizer_id !== dbUser.id)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'You are not the organizer of this event' },
+ { status: 403 },
+ );
+
+ await prisma.meeting.delete({
+ where: {
+ id: eventID,
+ },
+ });
+
+ return returnZodTypeCheckedResponse(
+ SuccessResponseSchema,
+ { success: true, message: 'Event deleted successfully' },
+ { status: 200 },
+ );
+});
+
+export const PATCH = auth(async (req, { params }) => {
+ 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 eventID = (await params).eventID;
+
+ const event = await prisma.meeting.findUnique({
+ where: {
+ id: eventID,
+ OR: [
+ { organizer_id: dbUser.id },
+ { participants: { some: { user_id: dbUser.id } } },
+ ],
+ },
+ });
+
+ if (!event)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'Event not found' },
+ { status: 404 },
+ );
+
+ if (event.organizer_id !== dbUser.id)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'You are not the organizer of this event' },
+ { status: 403 },
+ );
+
+ const dataRaw = await req.json();
+ const data = await updateEventSchema.safeParseAsync(dataRaw);
+ if (!data.success) {
+ return returnZodTypeCheckedResponse(
+ ZodErrorResponseSchema,
+ {
+ success: false,
+ message: 'Invalid input data',
+ errors: data.error.issues,
+ },
+ { status: 400 },
+ );
+ }
+ const { title, description, start_time, end_time, location, status } =
+ data.data;
+
+ const updatedEvent = await prisma.meeting.update({
+ where: {
+ id: eventID,
+ },
+ data: {
+ title,
+ description,
+ start_time,
+ end_time,
+ location,
+ status,
+ },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ start_time: true,
+ end_time: true,
+ status: true,
+ location: true,
+ organizer_id: 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: updatedEvent,
+ },
+ { status: 200 },
+ );
+});
diff --git a/src/app/api/event/[eventID]/swagger.ts b/src/app/api/event/[eventID]/swagger.ts
new file mode 100644
index 0000000..a293775
--- /dev/null
+++ b/src/app/api/event/[eventID]/swagger.ts
@@ -0,0 +1,94 @@
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import { EventResponseSchema, updateEventSchema } from '../validation';
+import {
+ invalidRequestDataResponse,
+ notAuthenticatedResponse,
+ serverReturnedDataValidationErrorResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+import {
+ EventIdParamSchema,
+ SuccessResponseSchema,
+} from '@/app/api/validation';
+import zod from 'zod/v4';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/event/{eventID}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Event retrieved successfully',
+ content: {
+ 'application/json': {
+ schema: EventResponseSchema,
+ },
+ },
+ },
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event'],
+ });
+
+ registry.registerPath({
+ method: 'delete',
+ path: '/event/{eventID}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Event deleted successfully',
+ content: {
+ 'application/json': {
+ schema: SuccessResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event'],
+ });
+
+ registry.registerPath({
+ method: 'patch',
+ path: '/event/{eventID}',
+ request: {
+ params: zod.object({
+ eventID: EventIdParamSchema,
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: updateEventSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Event updated successfully',
+ content: {
+ 'application/json': {
+ schema: EventResponseSchema,
+ },
+ },
+ },
+ ...invalidRequestDataResponse,
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Event'],
+ });
+}
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..da5912f
--- /dev/null
+++ b/src/app/api/event/validation.ts
@@ -0,0 +1,168 @@
+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({
+ id: eventIdSchema,
+ 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),
+});
diff --git a/src/app/api/search/user/route.ts b/src/app/api/search/user/route.ts
new file mode 100644
index 0000000..a8b6414
--- /dev/null
+++ b/src/app/api/search/user/route.ts
@@ -0,0 +1,79 @@
+import { auth } from '@/auth';
+import { prisma } from '@/prisma';
+import { searchUserSchema, searchUserResponseSchema } from './validation';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import {
+ ErrorResponseSchema,
+ ZodErrorResponseSchema,
+} from '@/app/api/validation';
+
+export const GET = auth(async function GET(req) {
+ 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 searchUserSchema.safeParseAsync(dataRaw);
+ if (!data.success)
+ return returnZodTypeCheckedResponse(
+ ZodErrorResponseSchema,
+ {
+ success: false,
+ message: 'Invalid request data',
+ errors: data.error.issues,
+ },
+ { status: 400 },
+ );
+ const { query, count, page, sort_by, sort_order } = data.data;
+
+ const dbUsers = await prisma.user.findMany({
+ where: {
+ OR: [
+ { name: { contains: query } },
+ { first_name: { contains: query } },
+ { last_name: { contains: query } },
+ ],
+ },
+ orderBy: {
+ [sort_by]: sort_order,
+ },
+ skip: (page - 1) * count,
+ take: count,
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ timezone: true,
+ image: true,
+ },
+ });
+
+ const userCount = await prisma.user.count({
+ where: {
+ OR: [
+ { name: { contains: query } },
+ { first_name: { contains: query } },
+ { last_name: { contains: query } },
+ ],
+ },
+ });
+
+ return returnZodTypeCheckedResponse(
+ searchUserResponseSchema,
+ {
+ success: true,
+ users: dbUsers,
+ total_count: userCount,
+ total_pages: Math.ceil(userCount / count),
+ },
+ { status: 200 },
+ );
+});
diff --git a/src/app/api/search/user/swagger.ts b/src/app/api/search/user/swagger.ts
new file mode 100644
index 0000000..90ca54e
--- /dev/null
+++ b/src/app/api/search/user/swagger.ts
@@ -0,0 +1,33 @@
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import { searchUserResponseSchema, searchUserSchema } from './validation';
+import {
+ invalidRequestDataResponse,
+ notAuthenticatedResponse,
+ serverReturnedDataValidationErrorResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/api/search/user',
+ request: {
+ query: searchUserSchema,
+ },
+ responses: {
+ 200: {
+ description: 'User search results',
+ content: {
+ 'application/json': {
+ schema: searchUserResponseSchema,
+ },
+ },
+ },
+ ...invalidRequestDataResponse,
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['Search'],
+ });
+}
diff --git a/src/app/api/search/user/validation.ts b/src/app/api/search/user/validation.ts
new file mode 100644
index 0000000..c1662b0
--- /dev/null
+++ b/src/app/api/search/user/validation.ts
@@ -0,0 +1,20 @@
+import zod from 'zod/v4';
+import { PublicUserSchema } from '../../user/validation';
+
+export const searchUserSchema = zod.object({
+ query: zod.string().optional().default(''),
+ count: zod.coerce.number().min(1).max(100).default(10),
+ page: zod.coerce.number().min(1).default(1),
+ sort_by: zod
+ .enum(['created_at', 'name', 'first_name', 'last_name', 'id'])
+ .optional()
+ .default('created_at'),
+ sort_order: zod.enum(['asc', 'desc']).optional().default('desc'),
+});
+
+export const searchUserResponseSchema = zod.object({
+ success: zod.boolean(),
+ users: zod.array(PublicUserSchema),
+ total_count: zod.number(),
+ total_pages: zod.number(),
+});
diff --git a/src/app/api/user/[user]/route.ts b/src/app/api/user/[user]/route.ts
new file mode 100644
index 0000000..5773f61
--- /dev/null
+++ b/src/app/api/user/[user]/route.ts
@@ -0,0 +1,54 @@
+import { auth } from '@/auth';
+import { prisma } from '@/prisma';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import { PublicUserResponseSchema } from '../validation';
+import { ErrorResponseSchema } from '@/app/api/validation';
+
+export const GET = auth(async function GET(req, { params }) {
+ const authCheck = userAuthenticated(req);
+ if (!authCheck.continue)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ authCheck.response,
+ authCheck.metadata,
+ );
+
+ const requestedUser = (await params).user;
+ const dbUser = await prisma.user.findFirst({
+ where: {
+ OR: [{ id: requestedUser }, { name: requestedUser }],
+ },
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ email: true,
+ created_at: true,
+ updated_at: true,
+ image: true,
+ },
+ });
+
+ if (!dbUser)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User not found',
+ },
+ { status: 404 },
+ );
+
+ return returnZodTypeCheckedResponse(
+ PublicUserResponseSchema,
+ {
+ success: true,
+ user: dbUser,
+ },
+ { status: 200 },
+ );
+});
diff --git a/src/app/api/user/[user]/swagger.ts b/src/app/api/user/[user]/swagger.ts
new file mode 100644
index 0000000..741cbf9
--- /dev/null
+++ b/src/app/api/user/[user]/swagger.ts
@@ -0,0 +1,33 @@
+import { PublicUserResponseSchema } from '../validation';
+import {
+ notAuthenticatedResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import zod from 'zod/v4';
+import { UserIdParamSchema } from '../../validation';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/api/user/{user}',
+ request: {
+ params: zod.object({
+ user: UserIdParamSchema,
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'User information retrieved successfully.',
+ content: {
+ 'application/json': {
+ schema: PublicUserResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ },
+ tags: ['User'],
+ });
+}
diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts
new file mode 100644
index 0000000..5ba9792
--- /dev/null
+++ b/src/app/api/user/me/route.ts
@@ -0,0 +1,119 @@
+import { auth } from '@/auth';
+import { prisma } from '@/prisma';
+import { updateUserServerSchema } from './validation';
+import {
+ returnZodTypeCheckedResponse,
+ userAuthenticated,
+} from '@/lib/apiHelpers';
+import { FullUserResponseSchema } from '../validation';
+import {
+ ErrorResponseSchema,
+ ZodErrorResponseSchema,
+} from '@/app/api/validation';
+
+export const GET = auth(async function GET(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,
+ },
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ email: true,
+ image: true,
+ timezone: true,
+ created_at: true,
+ updated_at: true,
+ },
+ });
+ if (!dbUser)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User not found',
+ },
+ { status: 404 },
+ );
+
+ return returnZodTypeCheckedResponse(FullUserResponseSchema, {
+ success: true,
+ user: dbUser,
+ });
+});
+
+export const PATCH = auth(async function PATCH(req) {
+ const authCheck = userAuthenticated(req);
+ if (!authCheck.continue)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ authCheck.response,
+ authCheck.metadata,
+ );
+
+ const dataRaw = await req.json();
+ const data = await updateUserServerSchema.safeParseAsync(dataRaw);
+ if (!data.success) {
+ return returnZodTypeCheckedResponse(
+ ZodErrorResponseSchema,
+ {
+ success: false,
+ message: 'Invalid request data',
+ errors: data.error.issues,
+ },
+ { status: 400 },
+ );
+ }
+ if (Object.keys(data.data).length === 0) {
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ { success: false, message: 'No data to update' },
+ { status: 400 },
+ );
+ }
+
+ const updatedUser = await prisma.user.update({
+ where: {
+ id: authCheck.user.id,
+ },
+ data: data.data,
+ select: {
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ email: true,
+ image: true,
+ timezone: true,
+ created_at: true,
+ updated_at: true,
+ },
+ });
+ if (!updatedUser)
+ return returnZodTypeCheckedResponse(
+ ErrorResponseSchema,
+ {
+ success: false,
+ message: 'User not found',
+ },
+ { status: 404 },
+ );
+ return returnZodTypeCheckedResponse(
+ FullUserResponseSchema,
+ {
+ success: true,
+ user: updatedUser,
+ },
+ { status: 200 },
+ );
+});
diff --git a/src/app/api/user/me/swagger.ts b/src/app/api/user/me/swagger.ts
new file mode 100644
index 0000000..e0a36a1
--- /dev/null
+++ b/src/app/api/user/me/swagger.ts
@@ -0,0 +1,63 @@
+import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
+import { FullUserResponseSchema } from '../validation';
+import { updateUserServerSchema } from './validation';
+import {
+ invalidRequestDataResponse,
+ notAuthenticatedResponse,
+ serverReturnedDataValidationErrorResponse,
+ userNotFoundResponse,
+} from '@/lib/defaultApiResponses';
+
+export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
+ registry.registerPath({
+ method: 'get',
+ path: '/api/user/me',
+ description: 'Get the currently authenticated user',
+ responses: {
+ 200: {
+ description: 'User information retrieved successfully',
+ content: {
+ 'application/json': {
+ schema: FullUserResponseSchema,
+ },
+ },
+ },
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['User'],
+ });
+
+ registry.registerPath({
+ method: 'patch',
+ path: '/api/user/me',
+ description: 'Update the currently authenticated user',
+ request: {
+ body: {
+ description: 'User information to update',
+ required: true,
+ content: {
+ 'application/json': {
+ schema: updateUserServerSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'User information updated successfully',
+ content: {
+ 'application/json': {
+ schema: FullUserResponseSchema,
+ },
+ },
+ },
+ ...invalidRequestDataResponse,
+ ...notAuthenticatedResponse,
+ ...userNotFoundResponse,
+ ...serverReturnedDataValidationErrorResponse,
+ },
+ tags: ['User'],
+ });
+}
diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts
new file mode 100644
index 0000000..49c6219
--- /dev/null
+++ b/src/app/api/user/me/validation.ts
@@ -0,0 +1,21 @@
+import zod from 'zod/v4';
+import {
+ firstNameSchema,
+ lastNameSchema,
+ newUserEmailServerSchema,
+ newUserNameServerSchema,
+} from '@/app/api/user/validation';
+
+// ----------------------------------------
+//
+// Update User Validation
+//
+// ----------------------------------------
+export const updateUserServerSchema = zod.object({
+ name: newUserNameServerSchema.optional(),
+ first_name: firstNameSchema.optional(),
+ last_name: lastNameSchema.optional(),
+ email: newUserEmailServerSchema.optional(),
+ image: zod.string().optional(),
+ timezone: zod.string().optional(),
+});
diff --git a/src/app/api/user/validation.ts b/src/app/api/user/validation.ts
new file mode 100644
index 0000000..79b1e7e
--- /dev/null
+++ b/src/app/api/user/validation.ts
@@ -0,0 +1,149 @@
+import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
+import { prisma } from '@/prisma';
+import zod from 'zod/v4';
+
+extendZodWithOpenApi(zod);
+
+// ----------------------------------------
+//
+// Email Validation
+//
+// ----------------------------------------
+export const emailSchema = zod
+ .email('Invalid email address')
+ .min(3, 'Email is required');
+
+export const newUserEmailServerSchema = emailSchema.refine(async (val) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { email: val },
+ });
+ return !existingUser;
+}, 'Email in use by another account');
+
+export const existingUserEmailServerSchema = emailSchema.refine(async (val) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { email: val },
+ });
+ return !!existingUser;
+}, 'Email not found');
+
+// ----------------------------------------
+//
+// First Name Validation
+//
+// ----------------------------------------
+export const firstNameSchema = zod
+ .string()
+ .min(1, 'First name is required')
+ .max(32, 'First name must be at most 32 characters long');
+
+// ----------------------------------------
+//
+// Last Name Validation
+//
+// ----------------------------------------
+export const lastNameSchema = zod
+ .string()
+ .min(1, 'Last name is required')
+ .max(32, 'Last name must be at most 32 characters long');
+
+// ----------------------------------------
+//
+// Username Validation
+//
+// ----------------------------------------
+export const userNameSchema = zod
+ .string()
+ .min(3, 'Username is required')
+ .max(32, 'Username must be at most 32 characters long')
+ .regex(
+ /^[a-zA-Z0-9_]+$/,
+ 'Username can only contain letters, numbers, and underscores',
+ );
+
+export const newUserNameServerSchema = userNameSchema.refine(async (val) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { name: val },
+ });
+ return !existingUser;
+}, 'Username in use by another account');
+
+export const existingUserNameServerSchema = userNameSchema.refine(
+ async (val) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { name: val },
+ });
+ return !!existingUser;
+ },
+ 'Username not found',
+);
+
+// ----------------------------------------
+//
+// User ID Validation
+//
+// ----------------------------------------
+export const existingUserIdServerSchema = zod
+ .string()
+ .min(1, 'User ID is required')
+ .refine(async (val) => {
+ const user = await prisma.user.findUnique({
+ where: { id: val },
+ });
+ return !!user;
+ }, 'User not found');
+
+// ----------------------------------------
+//
+// Password Validation
+//
+// ----------------------------------------
+export const passwordSchema = zod
+ .string()
+ .min(8, 'Password must be at least 8 characters long')
+ .max(128, 'Password must be at most 128 characters long')
+ .regex(
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={}\[\]:;"'<>,.?\/\\-]).{8,}$/,
+ 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
+ );
+
+// ----------------------------------------
+//
+// User Schema Validation (for API responses)
+//
+// ----------------------------------------
+export const FullUserSchema = zod
+ .object({
+ id: zod.string(),
+ name: zod.string(),
+ first_name: zod.string().nullish(),
+ last_name: zod.string().nullish(),
+ email: zod.email(),
+ image: zod.string().nullish(),
+ timezone: zod.string(),
+ created_at: zod.date(),
+ updated_at: zod.date(),
+ })
+ .openapi('FullUser', {
+ description: 'Full user information including all fields',
+ });
+
+export const PublicUserSchema = FullUserSchema.pick({
+ id: true,
+ name: true,
+ first_name: true,
+ last_name: true,
+ image: true,
+ timezone: true,
+}).openapi('PublicUser', {
+ description: 'Public user information excluding sensitive data',
+});
+
+export const FullUserResponseSchema = zod.object({
+ success: zod.boolean(),
+ user: FullUserSchema,
+});
+export const PublicUserResponseSchema = zod.object({
+ success: zod.boolean(),
+ user: PublicUserSchema,
+});
diff --git a/src/app/api/validation.ts b/src/app/api/validation.ts
new file mode 100644
index 0000000..38b95bd
--- /dev/null
+++ b/src/app/api/validation.ts
@@ -0,0 +1,87 @@
+import { registry } from '@/lib/swagger';
+import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
+import zod from 'zod/v4';
+
+extendZodWithOpenApi(zod);
+
+export const ErrorResponseSchema = zod
+ .object({
+ success: zod.boolean(),
+ message: zod.string(),
+ })
+ .openapi('ErrorResponseSchema', {
+ description: 'Error response schema',
+ example: {
+ success: false,
+ message: 'An error occurred',
+ },
+ });
+
+export const ZodErrorResponseSchema = ErrorResponseSchema.extend({
+ errors: zod.array(
+ zod.object({
+ expected: zod.string().optional(),
+ code: zod.string(),
+ path: zod.array(
+ zod
+ .string()
+ .or(zod.number())
+ .or(
+ zod.symbol().openapi({
+ type: 'string',
+ }),
+ ),
+ ),
+ message: zod.string(),
+ }),
+ ),
+}).openapi('ZodErrorResponseSchema', {
+ description: 'Zod error response schema',
+ example: {
+ success: false,
+ message: 'Invalid request data',
+ errors: [
+ {
+ expected: 'string',
+ code: 'invalid_type',
+ path: ['first_name'],
+ message: 'Invalid input: expected string, received number',
+ },
+ ],
+ },
+});
+
+export const SuccessResponseSchema = zod
+ .object({
+ success: zod.boolean(),
+ message: zod.string().optional(),
+ })
+ .openapi('SuccessResponseSchema', {
+ description: 'Success response schema',
+ example: {
+ success: true,
+ message: 'Operation completed successfully',
+ },
+ });
+
+export const UserIdParamSchema = registry.registerParameter(
+ 'UserIdOrNameParam',
+ zod.string().openapi({
+ param: {
+ name: 'user',
+ in: 'path',
+ },
+ example: '12345',
+ }),
+);
+
+export const EventIdParamSchema = registry.registerParameter(
+ 'EventIdParam',
+ zod.string().openapi({
+ param: {
+ name: 'eventID',
+ in: 'path',
+ },
+ example: '67890',
+ }),
+);
diff --git a/src/app/globals.css b/src/app/globals.css
index ec97843..f85cb2f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -364,3 +364,8 @@
@apply bg-background text-foreground;
}
}
+
+/* Fix for swagger ui readability */
+body:has(.swagger-ui) {
+ @apply bg-white text-black;
+}
diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx
index 4e6773b..0ede283 100644
--- a/src/app/home/page.tsx
+++ b/src/app/home/page.tsx
@@ -1,12 +1,20 @@
+'use client';
+
import { RedirectButton } from '@/components/user/redirect-button';
import { ThemePicker } from '@/components/user/theme-picker';
+import { useGetApiUserMe } from '@/generated/api/user/user';
export default function Home() {
+ const { data, isLoading } = useGetApiUserMe();
+
return (
{}
-
Home
+
+ Hello{' '}
+ {isLoading ? 'Loading...' : data?.data.user?.name || 'Unknown User'}
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e30808f..ff2040d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import { ThemeProvider } from '@/components/theme-provider';
import type { Metadata } from 'next';
import './globals.css';
+import { QueryProvider } from '@/components/query-provider';
export const metadata: Metadata = {
title: 'MeetUp',
@@ -55,7 +56,7 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
- {children}
+
{children}