Compare commits
36 commits
f954d0dbc0
...
7404f16557
Author | SHA1 | Date | |
---|---|---|---|
7404f16557 | |||
d6c50c2c58 | |||
3ca646e9ac | |||
4e65d0bfd9 | |||
f3eadcb373 | |||
7ba51a2af0 | |||
a3bfeb4e82 | |||
f240bf525d | |||
525b8597f2 | |||
0da8e35b9b | |||
cd1ad5dbc4 | |||
5fbd7ac091 | |||
5e6feb39eb | |||
b652499788 | |||
96ff00f120 | |||
58cf178968 | |||
360f0788dd | |||
445a15ccc7 | |||
40d13101a3 | |||
68cafccec7 | |||
f5a5704be3 | |||
50d915854f | |||
b10b374b84 | |||
c71de4a14c | |||
eb04c276ab | |||
3e890d4363 | |||
87dc6162f4 | |||
98776aacb2 | |||
a412d0710b | |||
050a1d2bf5 | |||
138970f4c3 | |||
77653bcc69 | |||
c49c654f9f | |||
34a2956399 | |||
d054fe1079 | |||
8d3aa9ec85 |
67 changed files with 7515 additions and 527 deletions
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
|
@ -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
2
.gitignore
vendored
|
@ -43,5 +43,5 @@ next-env.d.ts
|
|||
|
||||
# database
|
||||
/prisma/*.db*
|
||||
src/generated/prisma
|
||||
src/generated/*
|
||||
data
|
||||
|
|
|
@ -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 -----
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
62
exportSwagger.ts
Normal 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
10
orval.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
18
package.json
18
package.json
|
@ -13,9 +13,12 @@
|
|||
"prisma:studio": "dotenv -e .env.local -- prisma studio",
|
||||
"prisma:db:push": "dotenv -e .env.local -- prisma db push",
|
||||
"prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset",
|
||||
"dev_container": "docker compose -f docker-compose.dev.yml up --watch --build"
|
||||
"dev_container": "docker compose -f docker-compose.dev.yml up --watch --build",
|
||||
"swagger:generate": "ts-node -r tsconfig-paths/register exportSwagger.ts",
|
||||
"orval:generate": "orval"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^8.0.0-beta.4",
|
||||
"@auth/prisma-adapter": "^2.9.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
|
@ -33,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"
|
||||
},
|
||||
|
|
|
@ -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
11
src/app/api-doc/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
src/app/api-doc/react-swagger.tsx
Normal file
14
src/app/api-doc/react-swagger.tsx
Normal 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;
|
276
src/app/api/event/[eventID]/participant/[user]/route.ts
Normal file
276
src/app/api/event/[eventID]/participant/[user]/route.ts
Normal 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,
|
||||
});
|
||||
});
|
102
src/app/api/event/[eventID]/participant/[user]/swagger.ts
Normal file
102
src/app/api/event/[eventID]/participant/[user]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
200
src/app/api/event/[eventID]/participant/route.ts
Normal file
200
src/app/api/event/[eventID]/participant/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
72
src/app/api/event/[eventID]/participant/swagger.ts
Normal file
72
src/app/api/event/[eventID]/participant/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
50
src/app/api/event/[eventID]/participant/validation.ts
Normal file
50
src/app/api/event/[eventID]/participant/validation.ts
Normal 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),
|
||||
});
|
318
src/app/api/event/[eventID]/route.ts
Normal file
318
src/app/api/event/[eventID]/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
94
src/app/api/event/[eventID]/swagger.ts
Normal file
94
src/app/api/event/[eventID]/swagger.ts
Normal 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
178
src/app/api/event/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
62
src/app/api/event/swagger.ts
Normal file
62
src/app/api/event/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
167
src/app/api/event/validation.ts
Normal file
167
src/app/api/event/validation.ts
Normal 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),
|
||||
});
|
79
src/app/api/search/user/route.ts
Normal file
79
src/app/api/search/user/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
33
src/app/api/search/user/swagger.ts
Normal file
33
src/app/api/search/user/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
20
src/app/api/search/user/validation.ts
Normal file
20
src/app/api/search/user/validation.ts
Normal 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(),
|
||||
});
|
222
src/app/api/user/[user]/calendar/route.ts
Normal file
222
src/app/api/user/[user]/calendar/route.ts
Normal 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,
|
||||
});
|
||||
});
|
37
src/app/api/user/[user]/calendar/swagger.ts
Normal file
37
src/app/api/user/[user]/calendar/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
100
src/app/api/user/[user]/calendar/validation.ts
Normal file
100
src/app/api/user/[user]/calendar/validation.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
55
src/app/api/user/[user]/route.ts
Normal file
55
src/app/api/user/[user]/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
33
src/app/api/user/[user]/swagger.ts
Normal file
33
src/app/api/user/[user]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
119
src/app/api/user/me/route.ts
Normal file
119
src/app/api/user/me/route.ts
Normal 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 },
|
||||
);
|
||||
});
|
63
src/app/api/user/me/swagger.ts
Normal file
63
src/app/api/user/me/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
21
src/app/api/user/me/validation.ts
Normal file
21
src/app/api/user/me/validation.ts
Normal 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(),
|
||||
});
|
149
src/app/api/user/validation.ts
Normal file
149
src/app/api/user/validation.ts
Normal 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
87
src/app/api/validation.ts
Normal 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',
|
||||
}),
|
||||
);
|
|
@ -364,3 +364,8 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for swagger ui readability */
|
||||
body:has(.swagger-ui) {
|
||||
@apply bg-white text-black;
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,7 +51,6 @@ export default async function LoginPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Button variant='link'>made with love</Button>
|
||||
|
@ -66,5 +65,6 @@ export default async function LoginPage() {
|
|||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { signOut } from '@/auth';
|
||||
import { Button } from '@/components/custom-ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Button } from '@/components/custom-ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
25
src/auth.ts
25
src/auth.ts
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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';
|
|
@ -1,4 +1,4 @@
|
|||
import { Button } from '../custom-ui/button';
|
||||
import { Button } from '../ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function RedirectButton({
|
|
@ -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({
|
|
@ -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;
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
@ -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,
|
12
src/components/query-provider.tsx
Normal file
12
src/components/query-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
38
src/lib/apiHelpers.ts
Normal 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 });
|
||||
}
|
|
@ -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>) {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
53
src/lib/auth/validation.ts
Normal file
53
src/lib/auth/validation.ts
Normal 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'],
|
||||
});
|
60
src/lib/defaultApiResponses.ts
Normal file
60
src/lib/defaultApiResponses.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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
36
src/lib/swagger.ts
Normal 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');
|
||||
}
|
||||
};
|
|
@ -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'],
|
||||
},
|
||||
);
|
|
@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
|
|
@ -22,6 +22,11 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue