Compare commits

..

36 commits

Author SHA1 Message Date
7404f16557
feat(calendar): add calendar database integration and drag and drop
Some checks failed
container-scan / Container Scan (pull_request) Has been cancelled
docker-build / docker (pull_request) Has been cancelled
2025-06-22 22:33:45 +02:00
d6c50c2c58
style: format code 2025-06-22 22:33:44 +02:00
3ca646e9ac
feat: Calendar Layout and Function Update 2025-06-22 22:33:43 +02:00
4e65d0bfd9
feat: Custom Calendar Toolbar
Add Custom Calendar Toolbar
2025-06-22 22:33:42 +02:00
f3eadcb373
feat: Calendar Update 1.1 2025-06-22 22:33:41 +02:00
7ba51a2af0
feat: Calendar Update 2025-06-22 22:33:40 +02:00
a3bfeb4e82
feat: Basic Calendar without any functions 2025-06-22 22:33:39 +02:00
f240bf525d fix(deps): update dependency @auth/prisma-adapter to v2.10.0
All checks were successful
container-scan / Container Scan (push) Successful in 12m38s
docker-build / docker (push) Successful in 2m20s
2025-06-22 14:02:45 +00:00
525b8597f2 fix(deps): update dependency next-auth to v5.0.0-beta.29
All checks were successful
container-scan / Container Scan (push) Successful in 11m8s
docker-build / docker (push) Successful in 13m15s
2025-06-22 13:01:47 +00:00
0da8e35b9b fix(deps): update dependency next to v15.4.0-canary.91
All checks were successful
container-scan / Container Scan (push) Successful in 11m26s
docker-build / docker (push) Successful in 2m9s
2025-06-22 00:03:20 +00:00
cd1ad5dbc4 fix(deps): update dependency @tanstack/react-query to v5.81.2
All checks were successful
container-scan / Container Scan (push) Successful in 12m24s
docker-build / docker (push) Successful in 1m59s
2025-06-21 23:00:48 +00:00
5fbd7ac091 fix(deps): update dependency @tanstack/react-query to v5.81.1
All checks were successful
container-scan / Container Scan (push) Successful in 11m46s
docker-build / docker (push) Successful in 2m9s
2025-06-21 20:01:54 +00:00
5e6feb39eb fix(deps): update dependency @tanstack/react-query to v5.81.0
All checks were successful
container-scan / Container Scan (push) Successful in 10m45s
docker-build / docker (push) Successful in 1m50s
2025-06-21 13:00:48 +00:00
b652499788 fix(deps): update dependency next to v15.4.0-canary.90
All checks were successful
container-scan / Container Scan (push) Successful in 12m6s
docker-build / docker (push) Successful in 1m48s
2025-06-21 02:01:51 +00:00
96ff00f120 chore(deps): update dependency prisma to v6.10.1
All checks were successful
container-scan / Container Scan (push) Successful in 11m1s
docker-build / docker (push) Successful in 12m29s
2025-06-21 00:01:44 +00:00
58cf178968 fix(deps): update dependency next to v15.4.0-canary.89
All checks were successful
container-scan / Container Scan (push) Successful in 9m57s
docker-build / docker (push) Successful in 12m5s
2025-06-20 23:01:56 +00:00
360f0788dd Merge pull request 'feat: core api endpoints' (#95)
All checks were successful
container-scan / Container Scan (push) Successful in 4m0s
docker-build / docker (push) Successful in 11m56s
Reviewed-on: #95
Reviewed-by: micha <MichaBokelmann@web.de>
2025-06-20 22:13:01 +00:00
445a15ccc7
feat(api): add username to homepage
All checks were successful
container-scan / Container Scan (pull_request) Successful in 8m8s
docker-build / docker (pull_request) Successful in 8m33s
example for the api usage
2025-06-20 14:00:13 +02:00
40d13101a3
feat(api): implement /api/event/[eventID]/participant/[user] endpoint 2025-06-20 14:00:12 +02:00
68cafccec7
feat(api): implement /api/event/[eventID]/participant endpoint 2025-06-20 14:00:11 +02:00
f5a5704be3
feat(api): implement /api/event/[eventID] endpoint 2025-06-20 14:00:10 +02:00
50d915854f
feat(api): implement /api/event endpoint 2025-06-20 14:00:09 +02:00
b10b374b84
feat(api): implement /api/search/user endpoint 2025-06-20 14:00:08 +02:00
c71de4a14c
feat(api): implement /api/user/[user]/calendar endpoint 2025-06-20 14:00:07 +02:00
eb04c276ab
feat(api): implement /api/user/[user] endpoint 2025-06-20 13:59:45 +02:00
3e890d4363
feat(api): implement /api/user/me endpoint 2025-06-20 13:59:44 +02:00
87dc6162f4
feat(api): upgrade zod to v4 and implement api docs and client generation 2025-06-20 13:59:43 +02:00
98776aacb2 Merge pull request 'refactor: organized component folder structure' (#98)
All checks were successful
container-scan / Container Scan (push) Successful in 5m13s
docker-build / docker (push) Successful in 12m22s
Reviewed-on: #98
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-18 21:16:50 +00:00
a412d0710b refactor: organized component folder structure
All checks were successful
container-scan / Container Scan (pull_request) Successful in 4m39s
docker-build / docker (pull_request) Successful in 10m19s
fix: scrolling in login page
2025-06-18 22:25:27 +02:00
050a1d2bf5 fix(deps): update nextjs monorepo to v15.3.4
All checks were successful
container-scan / Container Scan (push) Successful in 10m24s
docker-build / docker (push) Successful in 2m3s
2025-06-18 18:01:50 +00:00
138970f4c3 chore(deps): update docker/setup-buildx-action digest to e468171
Some checks failed
container-scan / Container Scan (push) Failing after 7m27s
docker-build / docker (push) Successful in 2m21s
2025-06-18 09:03:11 +00:00
77653bcc69 fix(deps): update dependency react-hook-form to v7.58.1
All checks were successful
container-scan / Container Scan (push) Successful in 7m18s
docker-build / docker (push) Successful in 1m49s
2025-06-17 14:01:11 +00:00
c49c654f9f fix(deps): update dependency zod to v3.25.67
All checks were successful
container-scan / Container Scan (push) Successful in 8m16s
docker-build / docker (push) Successful in 4m21s
2025-06-16 23:01:28 +00:00
34a2956399 Merge pull request 'chore(deps): update docker/setup-buildx-action digest to 18ce135' (#97)
All checks were successful
container-scan / Container Scan (push) Successful in 6m41s
docker-build / docker (push) Successful in 2m31s
Reviewed-on: #97
Reviewed-by: Dominik <mail@dominikstahl.dev>
2025-06-16 21:10:13 +00:00
d054fe1079 chore(deps): update docker/setup-buildx-action digest to 18ce135
All checks were successful
docker-build / docker (push) Successful in 4m26s
container-scan / Container Scan (pull_request) Successful in 3m11s
docker-build / docker (pull_request) Successful in 1m39s
2025-06-16 20:01:09 +00:00
8d3aa9ec85 fix(deps): update dependency zod to v3.25.65
All checks were successful
container-scan / Container Scan (push) Successful in 6m19s
docker-build / docker (push) Successful in 1m35s
2025-06-16 19:00:47 +00:00
67 changed files with 7515 additions and 527 deletions

View file

@ -26,7 +26,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: Get the Ref
id: get-ref

2
.gitignore vendored
View file

@ -43,5 +43,5 @@ next-env.d.ts
# database
/prisma/*.db*
src/generated/prisma
src/generated/*
data

View file

@ -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 -----

View file

@ -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

View file

@ -7,4 +7,7 @@ if [ -d "prisma" ]; then
yarn prisma:db:push
fi
yarn swagger:generate
yarn orval:generate
exec yarn dev

62
exportSwagger.ts Normal file
View file

@ -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);
});

10
orval.config.js Normal file
View file

@ -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',
},
},
};

View file

@ -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,12 +36,13 @@
"@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",
"date-fns": "^4.1.0",
"lucide-react": "^0.511.0",
"next": "15.3.3",
"next": "15.4.0-canary.91",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"react": "^19.1.0",
@ -46,6 +50,7 @@
"react-datepicker": "^8.4.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"
},
@ -56,14 +61,19 @@
"@types/react": "19.1.8",
"@types/react-big-calendar": "^1",
"@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.3",
"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",
"prisma": "6.10.1",
"tailwindcss": "4.1.10",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"tw-animate-css": "1.3.4",
"typescript": "5.8.3"
},

View file

@ -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])

11
src/app/api-doc/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { getApiDocs } from '@/lib/swagger';
import ReactSwagger from './react-swagger';
export default async function IndexPage() {
const spec = await getApiDocs();
return (
<section className='container'>
<ReactSwagger spec={spec} />
</section>
);
}

View file

@ -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 <SwaggerUI spec={spec} />;
}
export default ReactSwagger;

View file

@ -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,
});
});

View file

@ -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: '/api/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: '/api/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: '/api/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'],
});
}

View file

@ -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,
},
});
});

View file

@ -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: '/api/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: '/api/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'],
});
}

View file

@ -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),
});

View file

@ -0,0 +1,318 @@
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,
participants,
} = data.data;
if (participants !== undefined)
for (const participant of participants) {
await prisma.meetingParticipant.upsert({
where: {
meeting_id_user_id: {
user_id: participant,
meeting_id: eventID,
},
},
create: {
user_id: participant,
meeting_id: eventID,
},
update: {},
});
}
const updatedEvent = await prisma.meeting.update({
where: {
id: eventID,
},
data: {
title,
description,
start_time,
end_time,
location,
status,
participants:
participants !== undefined
? {
deleteMany: {
user_id: {
notIn: participants || [],
},
},
}
: {},
},
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 },
);
});

View file

@ -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: '/api/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: '/api/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: '/api/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'],
});
}

178
src/app/api/event/route.ts Normal file
View file

@ -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 },
);
});

View file

@ -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'],
});
}

View file

@ -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),
});

View file

@ -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 },
);
});

View file

@ -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'],
});
}

View file

@ -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(),
});

View file

@ -0,0 +1,222 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
userCalendarQuerySchema,
UserCalendarResponseSchema,
UserCalendarSchema,
} from './validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import { z } from 'zod/v4';
export const GET = auth(async function GET(req, { params }) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = Object.fromEntries(new URL(req.url).searchParams);
const data = await userCalendarQuerySchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 },
);
const { end, start } = data.data;
const requestUserId = authCheck.user.id;
const requestedUserId = (await params).user;
const requestedUser = await prisma.user.findFirst({
where: {
id: requestedUserId,
},
select: {
meetingParts: {
where: {
meeting: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
},
orderBy: {
meeting: {
start_time: 'asc',
},
},
select: {
meeting: {
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer_id: true,
participants: {
select: {
user: {
select: {
id: true,
},
},
},
},
},
},
},
},
meetingsOrg: {
where: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
orderBy: {
start_time: 'asc',
},
select: {
id: true,
title: true,
description: true,
start_time: true,
end_time: true,
status: true,
location: true,
created_at: true,
updated_at: true,
organizer_id: true,
participants: {
select: {
user: {
select: {
id: true,
},
},
},
},
},
},
blockedSlots: {
where: {
start_time: {
lte: end,
},
end_time: {
gte: start,
},
},
orderBy: {
start_time: 'asc',
},
select: {
id: true,
reason: true,
start_time: true,
end_time: true,
is_recurring: true,
recurrence_end_date: true,
rrule: true,
created_at: true,
updated_at: true,
},
},
},
});
if (!requestedUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
const calendar: z.input<typeof UserCalendarSchema> = [];
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,
});
});

View file

@ -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'],
});
}

View file

@ -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',
},
},
});

View file

@ -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 },
);
});

View file

@ -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'],
});
}

View file

@ -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 },
);
});

View file

@ -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'],
});
}

View file

@ -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(),
});

View file

@ -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,
});

87
src/app/api/validation.ts Normal file
View file

@ -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',
}),
);

View file

@ -364,3 +364,8 @@
@apply bg-background text-foreground;
}
}
/* Fix for swagger ui readability */
body:has(.swagger-ui) {
@apply bg-white text-black;
}

View file

@ -1,5 +1,14 @@
'use client';
import { useSession } from 'next-auth/react';
import Calendar from '@/components/calendar';
export default function home() {
return <Calendar />;
export default function Home() {
const { data: session } = useSession();
if (!session?.user?.id) {
return <div>Please log in to view your calendar.</div>;
}
return <Calendar userId={session.user.id} />;
}

View file

@ -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}
<SessionProvider>
<QueryProvider>{children}</QueryProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>

View file

@ -1,17 +1,17 @@
import { auth, providerMap } from '@/auth';
import SSOLogin from '@/components/user/sso-login-button';
import LoginForm from '@/components/user/login-form';
import SSOLogin from '@/components/buttons/sso-login-button';
import LoginForm from '@/components/forms/login-form';
import { redirect } from 'next/navigation';
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import Image from 'next/image';
import { Separator } from '@/components/custom-ui/separator';
import Logo from '@/components/logo';
import { Separator } from '@/components/ui/separator';
import Logo from '@/components/misc/logo';
import {
Card,
CardContent,
CardHeader,
} from '@/components/custom-ui/login-card';
import { ThemePicker } from '@/components/user/theme-picker';
import { ThemePicker } from '@/components/misc/theme-picker';
import {
HoverCard,
HoverCardTrigger,
@ -26,7 +26,7 @@ export default async function LoginPage() {
}
return (
<div className='flex flex-col items-center min-h-screen'>
<div className='flex flex-col items-center max-h-screen overflow-y-auto'>
<div className='flex flex-col items-center min-h-screen'>
<div className='fixed top-4 right-4'>
<ThemePicker />
@ -51,20 +51,20 @@ export default async function LoginPage() {
</CardContent>
</Card>
</div>
<HoverCard>
<HoverCardTrigger>
<Button variant='link'>made with love</Button>
</HoverCardTrigger>
<HoverCardContent className='flex items-center justify-center'>
<Image
src='https://img1.wikia.nocookie.net/__cb20140808110649/clubpenguin/images/a/a1/Action_Dance_Light_Blue.gif'
width='150'
height='150'
alt='dancing penguin'
></Image>
</HoverCardContent>
</HoverCard>
</div>
<HoverCard>
<HoverCardTrigger>
<Button variant='link'>made with love</Button>
</HoverCardTrigger>
<HoverCardContent className='flex items-center justify-center'>
<Image
src='https://img1.wikia.nocookie.net/__cb20140808110649/clubpenguin/images/a/a1/Action_Dance_Light_Blue.gif'
width='150'
height='150'
alt='dancing penguin'
></Image>
</HoverCardContent>
</HoverCard>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { signOut } from '@/auth';
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,

View file

@ -1,4 +1,4 @@
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,

View file

@ -8,9 +8,8 @@ import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma';
import { loginSchema } from './lib/validation/user';
import { ZodError } from 'zod';
import { loginSchema } from '@/lib/auth/validation';
import { ZodError } from 'zod/v4';
class InvalidLoginError extends CredentialsSignin {
constructor(code: string) {
@ -25,7 +24,11 @@ const providers: Provider[] = [
Credentials({
credentials: { password: { label: 'Password', type: 'password' } },
async authorize(c) {
if (process.env.NODE_ENV === 'development' && c.password === 'password')
if (
process.env.NODE_ENV === 'development' &&
process.env.DISABLE_AUTH_TEST_USER !== 'true' &&
c.password === 'password'
)
return {
id: 'test',
name: 'Test User',
@ -37,7 +40,7 @@ const providers: Provider[] = [
const { email, password } = await loginSchema.parseAsync(c);
const user = await prisma.user.findFirst({
where: { email },
where: { OR: [{ email }, { name: email }] },
include: { accounts: true },
});
@ -113,6 +116,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
authorized({ auth }) {
return !!auth?.user;
},
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.sub as string;
}
return session;
},
jwt: async ({ user, token }) => {
if (user) {
token.uid = user.id;
}
return token;
},
},
debug: process.env.NODE_ENV === 'development',
});

View file

@ -1,4 +1,4 @@
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

View file

@ -1,4 +1,4 @@
import { Button } from '../custom-ui/button';
import { Button } from '../ui/button';
import Link from 'next/link';
export function RedirectButton({

View file

@ -1,5 +1,5 @@
import { signIn } from '@/auth';
import { IconButton } from '@/components/icon-button';
import { IconButton } from '@/components/buttons/icon-button';
import { faOpenid } from '@fortawesome/free-brands-svg-icons';
export default function SSOLogin({

View file

@ -1,10 +1,16 @@
'use client';
import { Calendar, momentLocalizer } from 'react-big-calendar';
import { Calendar as RBCalendar, momentLocalizer } from 'react-big-calendar';
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop';
import moment from 'moment';
import '@/components/react-big-calendar.css';
import 'react-big-calendar/lib/addons/dragAndDrop/styles.css';
import CustomToolbar from '@/components/custom-toolbar';
import React from 'react';
import { useGetApiUserUserCalendar } from '@/generated/api/user/user';
import { useRouter } from 'next/navigation';
import { usePatchApiEventEventID } from '@/generated/api/event/event';
import { useSession } from 'next-auth/react';
moment.updateLocale('en', {
week: {
@ -13,25 +19,122 @@ moment.updateLocale('en', {
},
});
const DaDRBCalendar = withDragAndDrop<{
id: string;
start: Date;
end: Date;
}, {
id: string;
title: string;
}>(RBCalendar);
const localizer = momentLocalizer(moment);
const MyCalendar = (props) => (
<div>
<Calendar
export default function Calendar({ userId }: { userId: string }) {
const sesstion = useSession();
const [currentView, setCurrentView] = React.useState<
'month' | 'week' | 'day' | 'agenda' | 'work_week'
>('week');
const [currentDate, setCurrentDate] = React.useState<Date>(new Date());
const router = useRouter();
const { data, refetch } = useGetApiUserUserCalendar(userId, {
start: moment(currentDate)
.startOf(
currentView === 'agenda'
? 'month'
: currentView === 'work_week'
? 'week'
: currentView,
)
.toISOString(),
end: moment(currentDate)
.endOf(
currentView === 'agenda'
? 'month'
: currentView === 'work_week'
? 'week'
: currentView,
)
.toISOString(),
});
const { mutate: patchEvent } = usePatchApiEventEventID();
return (
<DaDRBCalendar
localizer={localizer}
//events={myEventsList}
startAccessor='start'
endAccessor='end'
style={{ height: 500 }}
culture='de-DE'
defaultView='week'
/*CustomToolbar*/
components={{
toolbar: CustomToolbar,
}}
/*CustomToolbar*/
onView={setCurrentView}
view={currentView}
date={currentDate}
onNavigate={(date) => {
setCurrentDate(date);
}}
events={
data?.data.calendar.map((event) => ({
id: event.id,
title: event.type === 'event' ? event.title : 'Blocker',
start: new Date(event.start_time),
end: new Date(event.end_time),
})) ?? []
}
onSelectEvent={(event) => {
router.push(`/events/${event.id}`);
}}
onSelectSlot={(slotInfo) => {
router.push(
`/events/new?start=${slotInfo.start.toISOString()}&end=${slotInfo.end.toISOString()}`,
);
}}
resourceIdAccessor={(event) => event.id}
resourceTitleAccessor={(event) => event.title}
startAccessor={(event) => event.start}
endAccessor={(event) => event.end}
selectable={sesstion.data?.user?.id === userId}
onEventDrop={(event) => {
const { start, end, event: droppedEvent } = event;
const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString();
patchEvent({
eventID: droppedEvent.id,
data: {
start_time: startISO,
end_time: endISO,
}
}, {
onSuccess: () => {
refetch();
},
onError: (error) => {
console.error('Error updating event:', error);
},
});
}}
onEventResize={(event) => {
const { start, end, event: resizedEvent } = event;
const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString();
patchEvent({
eventID: resizedEvent.id,
data: {
start_time: startISO,
end_time: endISO,
}
}, {
onSuccess: () => {
refetch();
},
onError: (error) => {
console.error('Error resizing event:', error);
},
});
}}
/>
</div>
);
export default MyCalendar;
);
}

View file

@ -1,57 +1,20 @@
/* custom-toolbar.css */
/* Container der Toolbar */
.custom-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
/*border-radius: 8px;*/
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/*.custom-toolbar .view-change .view-switcher {
display: flex;
gap: 8px;
justify-content: center;
}
.custom-toolbar .view-change .view-switcher button {
padding: 8px 16px;
background-color: #c1830d;
/*border: 1px solid #ccc;*/
/* border-radius: 11px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
height: 30px;
margin-top: 3.5px;
color: #ffffff;
}
.custom-toolbar .view-change .view-switcher button:hover:not(:disabled) {
background-color: #e0e0e0;
border-color: #999;
}
.custom-toolbar .view-change .view-switcher button:disabled {
background-color: #d0d0d0;
border-color: #aaa;
cursor: default;
}*/
/* Anzeige des aktuellen Datums (Monat und Jahr) */
.custom-toolbar .current-date {
font-weight: bold;
font-size: 12px;
text-align: center;
color: #ffffff;
/*margin: 4px 0;*/
background-color: #717171;
width: 178px;
height: 37px;
border-radius: 11px;
}
@ -65,7 +28,6 @@
.custom-toolbar .navigation-controls button {
padding: 8px 12px;
/*background-color: #2196F3;*/
color: #ffffff;
border: none;
border-radius: 11px;
@ -95,7 +57,6 @@
.custom-toolbar .dropdowns select {
padding: 8px 12px;
/*border: 1px solid #ccc;*/
border-radius: 11px;
font-size: 10px;
background-color: #555555;
@ -108,9 +69,8 @@
border-color: #999;
}
.right-section {
.right-section, .view-switcher {
background-color: #717171;
width: 393px;
height: 48px;
border-radius: 11px;
justify-items: center;
@ -124,18 +84,10 @@
margin-bottom: 3.5px;
}
/*.custom-toolbar .navigation-controls .today button {
background-color: #c6c6c6;
height: 30px;
width: 100px;
color: #000000;
margin-top: 3.5px;
}*/
.view-change {
.view-change, .right-section {
background-color: #717171;
height: 48px;
width: 323px;
padding: 0 8px;
border-radius: 11px;
justify-items: center;
}
@ -144,7 +96,6 @@
color: #000000;
background-color: #c6c6c6;
height: 36px;
width: 85px;
border-radius: 11px;
font-size: 12px;
align-self: center;
@ -152,7 +103,6 @@
.datepicker {
text-align: center;
width: 85px;
height: 30px;
}

View file

@ -1,19 +1,19 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import './custom-toolbar.css';
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { NavigateAction } from 'react-big-calendar';
interface CustomToolbarProps {
//Aktuell angezeigtes Datum
date: Date;
//Aktuelle Ansicht
view: 'month' | 'week' | 'day' | 'agenda';
view: 'month' | 'week' | 'day' | 'agenda' | 'work_week';
onNavigate: (action: string, newDate?: Date) => void;
onNavigate: (action: NavigateAction, newDate?: Date) => void;
//Ansichtwechsel
onView: (newView: 'month' | 'week' | 'day' | 'agenda') => void;
onView: (newView: 'month' | 'week' | 'day' | 'agenda' | 'work_week') => void;
}
const CustomToolbar: React.FC<CustomToolbarProps> = ({
@ -76,27 +76,11 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
setSelectedYear(getISOWeekYear(date));
}, [date]);
//Dropdown-Liste der Wochen
const totalWeeks = getISOWeeksInYear(selectedYear);
const weekOptions = Array.from({ length: totalWeeks }, (_, i) => i + 1);
//Jahresliste
const yearOptions = Array.from(
{ length: 21 },
(_, i) => selectedYear - 10 + i,
);
//Start (Montag) und Ende (Sonntag) der aktuell angezeigten Woche berechnen
const weekStartDate = getDateOfISOWeek(selectedWeek, selectedYear);
const weekEndDate = new Date(weekStartDate);
weekEndDate.setDate(weekStartDate.getDate() + 6);
//Monat und Jahr von Start- und Enddatum ermitteln
const monthStart = format(weekStartDate, 'MMMM');
const monthEnd = format(weekEndDate, 'MMMM');
const yearAtStart = format(weekStartDate, 'yyyy');
const yearAtEnd = format(weekEndDate, 'yyyy');
//Ansichtwechsel
const handleViewChange = (newView: 'month' | 'week' | 'day' | 'agenda') => {
onView(newView);
@ -134,7 +118,7 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
}
//Datum im DatePicker aktualisieren
setSelectedDate(newDate);
onNavigate('SET_DATE', newDate);
onNavigate('DATE', newDate);
};
//Pfeiltaste nach Hinten
@ -160,7 +144,7 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
}
//Datum im DatePicker aktualisieren
setSelectedDate(newDate);
onNavigate('SET_DATE', newDate);
onNavigate('DATE', newDate);
};
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
@ -174,14 +158,14 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
setSelectedWeek(newWeek);
setSelectedYear(newYear);
const newDate = getDateOfISOWeek(newWeek, newYear);
onNavigate('SET_DATE', newDate);
onNavigate('DATE', newDate);
} else if (view === 'day') {
onNavigate('SET_DATE', date);
onNavigate('DATE', date);
} else if (view === 'month') {
const newDate = new Date(date.getFullYear(), date.getMonth(), 1);
onNavigate('SET_DATE', newDate);
onNavigate('DATE', newDate);
} else if (view === 'agenda') {
onNavigate('SET_DATE', date);
onNavigate('DATE', date);
}
}
};

View file

@ -3,10 +3,10 @@
import React, { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/labeled-input';
import { Button } from '@/components/custom-ui/button';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button';
import useZodForm from '@/lib/hooks/useZodForm';
import { loginSchema, registerSchema } from '@/lib/validation/user';
import { loginSchema, registerSchema } from '@/lib/auth/validation';
import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register';

View file

@ -4,7 +4,7 @@ import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/custom-ui/button';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,

View file

@ -0,0 +1,12 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';
const queryClient = new QueryClient();
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View file

@ -546,7 +546,8 @@ button.rbc-input::-moz-focus-inner {
padding-right: 15px;
text-transform: lowercase;
}
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td {
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td,
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td.rbc-agenda-time-cell {
border-left: 1px solid #ddd;
}
.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td {
@ -767,7 +768,6 @@ button.rbc-input::-moz-focus-inner {
-ms-flex: 1;
flex: 1;
width: 100%;
border: 1px solid #ddd;
min-height: 0;
}
.rbc-time-view .rbc-time-gutter {
@ -870,10 +870,18 @@ button.rbc-input::-moz-focus-inner {
-ms-flex-align: start;
align-items: flex-start;
width: 100%;
border-top: 2px solid #717171; /*#ddd*/
overflow-y: auto;
position: relative;
}
.rbc-time-header-content {
border-bottom: 2px solid #717171; /*#ddd*/
}
.rbc-time-column :last-child {
border-bottom: 0;
}
.rbc-time-content > .rbc-time-gutter {
-webkit-box-flex: 0;
-ms-flex: none;

38
src/lib/apiHelpers.ts Normal file
View file

@ -0,0 +1,38 @@
import { NextAuthRequest } from 'next-auth';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import { NextResponse } from 'next/server';
extendZodWithOpenApi(zod);
export function userAuthenticated(req: NextAuthRequest) {
if (!req.auth || !req.auth.user || !req.auth.user.id)
return {
continue: false,
response: { success: false, message: 'Not authenticated' },
metadata: { status: 401 },
} as const;
return { continue: true, user: req.auth.user } as const;
}
export function returnZodTypeCheckedResponse<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends zod.ZodType<any, any, any>,
>(
expectedType: Schema,
response: zod.input<Schema>,
metadata?: { status: number },
): NextResponse {
const result = expectedType.safeParse(response);
if (!result.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid response format',
errors: result.error.issues,
},
{ status: 500 },
);
}
return NextResponse.json(result.data, { status: metadata?.status || 200 });
}

View file

@ -1,7 +1,7 @@
'use server';
import { z } from 'zod';
import { loginSchema } from '@/lib/validation/user';
import { z } from 'zod/v4';
import { loginSchema } from './validation';
import { signIn } from '@/auth';
export async function loginAction(data: z.infer<typeof loginSchema>) {

View file

@ -1,46 +1,24 @@
'use server';
import type { z } from 'zod';
import type { z } from 'zod/v4';
import bcrypt from 'bcryptjs';
import { registerSchema } from '@/lib/validation/user';
import { registerServerSchema } from './validation';
import { prisma } from '@/prisma';
export async function registerAction(data: z.infer<typeof registerSchema>) {
export async function registerAction(
data: z.infer<typeof registerServerSchema>,
) {
try {
const result = await registerSchema.safeParseAsync(data);
const result = await registerServerSchema.safeParseAsync(data);
if (!result.success) {
return {
error: result.error.errors[0].message,
error: result.error.issues[0].message,
};
}
const { email, password, firstName, lastName, username } = result.data;
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (user) {
return {
error: 'User already exist with this email',
};
}
const existingUsername = await prisma.user.findUnique({
where: {
name: username,
},
});
if (existingUsername) {
return {
error: 'Username already exists',
};
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.$transaction(async (tx) => {

View file

@ -0,0 +1,53 @@
import zod from 'zod/v4';
import {
emailSchema,
firstNameSchema,
lastNameSchema,
newUserEmailServerSchema,
newUserNameServerSchema,
passwordSchema,
userNameSchema,
} from '@/app/api/user/validation';
// ----------------------------------------
//
// Login Validation
//
// ----------------------------------------
export const loginSchema = zod.object({
email: emailSchema.or(userNameSchema),
password: zod.string().min(1, 'Password is required'),
});
// ----------------------------------------
//
// Register Validation
//
// ----------------------------------------
export const registerServerSchema = zod
.object({
firstName: firstNameSchema,
lastName: lastNameSchema,
email: newUserEmailServerSchema,
password: passwordSchema,
confirmPassword: passwordSchema,
username: newUserNameServerSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export const registerSchema = zod
.object({
firstName: firstNameSchema,
lastName: lastNameSchema,
email: emailSchema,
password: passwordSchema,
confirmPassword: passwordSchema,
username: userNameSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});

View file

@ -0,0 +1,60 @@
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
export const invalidRequestDataResponse = {
400: {
description: 'Invalid request data',
content: {
'application/json': {
schema: ZodErrorResponseSchema,
},
},
},
};
export const notAuthenticatedResponse = {
401: {
description: 'Not authenticated',
content: {
'application/json': {
schema: ErrorResponseSchema,
example: {
success: false,
message: 'Not authenticated',
},
},
},
},
};
export const userNotFoundResponse = {
404: {
description: 'User not found',
content: {
'application/json': {
schema: ErrorResponseSchema,
example: {
success: false,
message: 'User not found',
},
},
},
},
};
export const serverReturnedDataValidationErrorResponse = {
500: {
description: 'Server returned data validation error',
content: {
'application/json': {
schema: ZodErrorResponseSchema,
example: {
success: false,
message: 'Server returned data validation error',
},
},
},
},
};

View file

@ -1,13 +1,14 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { z } from 'zod/v4';
export default function useZodForm<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends z.ZodType<any, any, any>,
Values extends z.infer<Schema>,
>(schema: Schema, defaultValues?: Values) {
return useForm<Values>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
resolver: zodResolver(schema),
defaultValues,
});

36
src/lib/swagger.ts Normal file
View file

@ -0,0 +1,36 @@
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from '@asteasolutions/zod-to-openapi';
export const registry = new OpenAPIRegistry();
export const getApiDocs = async () => {
const swaggerFiles = require.context('../app', true, /swagger\.ts$/);
swaggerFiles
.keys()
.sort((a, b) => b.length - a.length)
.forEach((file) => {
console.log(`Registering Swagger file: ${file}`);
swaggerFiles(file).default?.(registry);
});
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@/app/api/validation');
try {
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',
},
});
return spec;
} catch (error) {
console.error('Error generating API docs:', error);
throw new Error('Failed to generate API documentation');
}
};

View file

@ -1,67 +0,0 @@
import zod from 'zod';
export const loginSchema = zod.object({
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required')
.or(
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',
),
),
password: zod.string().min(1, 'Password is required'),
});
export const registerSchema = zod
.object({
firstName: zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long'),
lastName: zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long'),
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required'),
password: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
confirmPassword: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
username: 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',
),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
.refine(
(data) =>
!data.password.includes(data.firstName) &&
!data.password.includes(data.lastName) &&
!data.password.includes(data.email) &&
!data.password.includes(data.username),
{
message:
'Password cannot contain your first name, last name, email, or username',
path: ['password'],
},
);

View file

@ -2,6 +2,6 @@ export { auth as middleware } from '@/auth';
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)',
'/((?!api|_next/static|api-doc|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|apple-touch-icon.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)',
],
};

View file

@ -22,6 +22,11 @@
"@/*": ["./src/*"]
}
},
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

4686
yarn.lock

File diff suppressed because it is too large Load diff