= [];
+
+ 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({
+ id: event.meeting.id,
+ 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({
+ id: event.id,
+ start_time: event.start_time,
+ end_time: event.end_time,
+ type: 'blocked_private',
+ });
+ }
+ }
+
+ for (const slot of requestedUser.blockedSlots) {
+ if (requestUserId === requestedUserId) {
+ 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: 'blocked_owned',
+ });
+ } else {
+ calendar.push({
+ start_time: slot.start_time,
+ end_time: slot.end_time,
+ id: slot.id,
+ type: '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..996307f
--- /dev/null
+++ b/src/app/api/user/[user]/calendar/validation.ts
@@ -0,0 +1,100 @@
+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'),
+ id: zod.string(),
+ })
+ .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',
+ },
+ },
+ });
diff --git a/src/app/api/user/[user]/route.ts b/src/app/api/user/[user]/route.ts
new file mode 100644
index 0000000..b90b1f8
--- /dev/null
+++ b/src/app/api/user/[user]/route.ts
@@ -0,0 +1,55 @@
+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,
+ timezone: 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 68e51bf..c201134 100644
--- a/src/app/home/page.tsx
+++ b/src/app/home/page.tsx
@@ -1,5 +1,14 @@
+'use client';
+import { useSession } from 'next-auth/react';
+
import Calendar from '@/components/calendar';
-export default function home() {
- return ;
+export default function Home() {
+ const { data: session } = useSession();
+
+ if (!session?.user?.id) {
+ return Please log in to view your calendar.
;
+ }
+
+ return ;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e30808f..7234ef0 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,9 @@
-import { ThemeProvider } from '@/components/theme-provider';
+import { ThemeProvider } from '@/components/wrappers/theme-provider';
import type { Metadata } from 'next';
import './globals.css';
+import { QueryProvider } from '@/components/query-provider';
+import { SessionProvider } from 'next-auth/react';
export const metadata: Metadata = {
title: 'MeetUp',
@@ -55,7 +57,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
- {children}
+
+ {children}
+