refactor(validation): restucture api input and output validation
All checks were successful
container-scan / Container Scan (pull_request) Successful in 5m17s
docker-build / docker (pull_request) Successful in 6m1s

This commit is contained in:
Dominik 2025-06-18 21:36:30 +02:00
parent 485a95f99a
commit eef17c5360
Signed by: dominik
GPG key ID: 06A4003FC5049644
34 changed files with 1891 additions and 1802 deletions

View file

@ -1,97 +1,40 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import {
ParticipantResponseSchema,
updateParticipantSchema,
} from '../validation';
const patchParticipantSchema = z.object({
status: z.enum(['ACCEPTED', 'DECLINED', 'TENTATIVE', 'PENDING']),
});
/**
* @swagger
* /api/event/{eventID}/participant/{user}:
* get:
* summary: Get a specific participant's details in an event
* description: Returns the details of a specific participant in an event.
* tags:
* - Event_Participant
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event.
* - in: path
* name: user
* required: true
* schema:
* type: string
* description: The ID or name of the user.
* responses:
* 200:
* description: Details of the participant.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* participant:
* $ref: "#/components/schemas/Participant"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
* 403:
* description: User is not a participant or organizer of this event.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User is not a participant or organizer of this event
*/
export const GET = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
}
const eventID = (await params).eventID;
const user = (await params).user;
@ -110,15 +53,15 @@ export const GET = auth(async (req, { params }) => {
},
});
if (!isParticipant && !isOrganizer) {
return NextResponse.json(
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: {
@ -132,110 +75,50 @@ export const GET = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
if (!participant) {
return NextResponse.json(
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
}
return NextResponse.json({
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant,
});
});
/**
* @swagger
* /api/event/{eventID}/participant/{user}:
* delete:
* summary: Remove a participant from an event
* description: Removes a participant from an event. Only the organizer can remove participants.
* tags:
* - Event_Participant
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event.
* - in: path
* name: user
* required: true
* schema:
* type: string
* description: The ID or name of the user to be removed.
* responses:
* 200:
* description: Participant removed successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
* 403:
* description: Only organizer can remove participants.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Only organizer can remove participants
*/
export const DELETE = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
}
const eventID = (await params).eventID;
const user = (await params).user;
@ -247,12 +130,12 @@ export const DELETE = auth(async (req, { params }) => {
},
});
if (!isOrganizer) {
return NextResponse.json(
if (!isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Only organizer can remove participants' },
{ status: 403 },
);
}
const participant = await prisma.meetingParticipant.findUnique({
where: {
@ -263,12 +146,12 @@ export const DELETE = auth(async (req, { params }) => {
},
});
if (!participant) {
return NextResponse.json(
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
}
await prisma.meetingParticipant.delete({
where: {
@ -279,124 +162,44 @@ export const DELETE = auth(async (req, { params }) => {
},
});
return NextResponse.json({
success: true,
message: 'Participant removed successfully',
});
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{ success: true, message: 'Participant removed successfully' },
{ status: 200 },
);
});
/**
* @swagger
* /api/event/{eventID}/participant/{user}:
* patch:
* summary: Update a participant's status in an event
* description: Updates the status of a participant in an event. Only the participant can update their own status.
* tags:
* - Event_Participant
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event.
* - in: path
* name: user
* required: true
* schema:
* type: string
* description: The ID or name of the user whose status is being updated.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* enum: [accepted, declined, tentative]
* description: The new status of the participant.
* responses:
* 200:
* description: Participant status updated successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* participant:
* $ref: "#/components/schemas/Participant"
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
* 403:
* description: Only participant can update their status.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Only participant can update their status
*/
export const PATCH = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
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 NextResponse.json(
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: {
@ -410,23 +213,28 @@ export const PATCH = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
if (!participant) {
return NextResponse.json(
if (!participant)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Participant not found' },
{ status: 404 },
);
}
const body = await req.json();
const parsedBody = patchParticipantSchema.safeParse(body);
if (!parsedBody.success) {
return NextResponse.json(
const parsedBody = await updateParticipantSchema.safeParseAsync(body);
if (!parsedBody.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request body',
@ -434,10 +242,9 @@ export const PATCH = auth(async (req, { params }) => {
},
{ status: 400 },
);
}
const { status } = parsedBody.data;
await prisma.meetingParticipant.update({
const updatedParticipant = await prisma.meetingParticipant.update({
where: {
meeting_id_user_id: {
meeting_id: eventID,
@ -447,10 +254,23 @@ export const PATCH = auth(async (req, { params }) => {
data: {
status,
},
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return NextResponse.json({
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant,
participant: updatedParticipant,
});
});

View file

@ -0,0 +1,102 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
ParticipantResponseSchema,
updateParticipantSchema,
} from '../validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import {
EventIdParamSchema,
UserIdParamSchema,
SuccessResponseSchema,
} from '@/app/api/validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'Get a participant for the event',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'delete',
path: '/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'Participant removed successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'patch',
path: '/event/{eventID}/participant/{user}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
user: UserIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: updateParticipantSchema,
},
},
},
},
responses: {
200: {
description: 'Participant updated successfully',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
...invalidRequestDataResponse,
},
tags: ['Event Participant'],
});
}

View file

@ -1,94 +1,40 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { userIdSchema } from '@/lib/validation/user';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import {
inviteParticipantSchema,
ParticipantResponseSchema,
ParticipantsResponseSchema,
} from './validation';
const postParticipantSchema = z.object({
userId: userIdSchema,
});
/**
* @swagger
* /api/event/{eventID}/participant:
* get:
* summary: Get participants of an event
* description: Returns all participants of a specific event.
* tags:
* - Event_Participant
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event.
* responses:
* 200:
* description: List of participants for the event.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* participants:
* type: array
* items:
* $ref: "#/components/schemas/Participant"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
* 403:
* description: User is not a participant or organizer of this event.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User is not a participant or organizer of this event
*/
export const GET = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
}
const eventID = (await params).eventID;
@ -97,6 +43,9 @@ export const GET = auth(async (req, { params }) => {
meeting_id: eventID,
user_id: dbUser.id,
},
select: {
status: true,
},
});
const isOrganizer = await prisma.meeting.findFirst({
@ -104,17 +53,20 @@ export const GET = auth(async (req, { params }) => {
id: eventID,
organizer_id: dbUser.id,
},
select: {
id: true,
},
});
if (!isParticipant && !isOrganizer) {
return NextResponse.json(
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: {
@ -125,122 +77,43 @@ export const GET = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return NextResponse.json({
return returnZodTypeCheckedResponse(ParticipantsResponseSchema, {
success: true,
participants,
});
});
/**
* @swagger
* /api/event/{eventID}/participant:
* post:
* summary: Add a participant to an event
* description: Adds a user as a participant to a specific event.
* tags:
* - Event_Participant
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* description: The ID of the user to add as a participant.
* responses:
* 200:
* description: Participant added successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* participant:
* $ref: "#/components/schemas/Participant"
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
* 403:
* description: Only organizers can add participants.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Only organizers can add participants
* 409:
* description: User is already a participant of this event.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User is already a participant of this event
*/
export const POST = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
}
const eventID = (await params).eventID;
@ -251,17 +124,18 @@ export const POST = auth(async (req, { params }) => {
},
});
if (!isOrganizer) {
return NextResponse.json(
if (!isOrganizer)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Only organizers can add participants' },
{ status: 403 },
);
}
const dataRaw = await req.json();
const data = await postParticipantSchema.safeParseAsync(dataRaw);
const data = await inviteParticipantSchema.safeParseAsync(dataRaw);
if (!data.success) {
return NextResponse.json(
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
@ -270,41 +144,55 @@ export const POST = auth(async (req, { params }) => {
{ status: 400 },
);
}
const { userId } = data.data;
const { user_id } = data.data;
const participantExists = await prisma.meetingParticipant.findFirst({
where: {
meeting_id: eventID,
user_id: userId,
user_id,
},
});
if (participantExists) {
return NextResponse.json(
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: userId,
user_id,
},
include: {
user: true,
select: {
user: {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
},
});
return NextResponse.json({
return returnZodTypeCheckedResponse(ParticipantResponseSchema, {
success: true,
participant: {
user: {
id: newParticipant.user.id,
name: newParticipant.user.name,
first_name: newParticipant.user.first_name,
last_name: newParticipant.user.last_name,
image: newParticipant.user.image,
timezone: newParticipant.user.timezone,
},
status: newParticipant.status,
},

View file

@ -0,0 +1,72 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
ParticipantsResponseSchema,
ParticipantResponseSchema,
inviteParticipantSchema,
} from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { EventIdParamSchema } from '@/app/api/validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/event/{eventID}/participant',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'List participants for the event',
content: {
'application/json': {
schema: ParticipantsResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
registry.registerPath({
method: 'post',
path: '/event/{eventID}/participant',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: inviteParticipantSchema,
},
},
},
},
responses: {
200: {
description: 'Participant invited successfully',
content: {
'application/json': {
schema: ParticipantResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event Participant'],
});
}

View file

@ -0,0 +1,50 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
existingUserIdServerSchema,
PublicUserSchema,
} from '@/app/api/user/validation';
extendZodWithOpenApi(zod);
export const participantStatusSchema = zod.enum([
'ACCEPTED',
'DECLINED',
'TENTATIVE',
'PENDING',
]);
export const inviteParticipantSchema = zod
.object({
user_id: existingUserIdServerSchema,
})
.openapi('InviteParticipant', {
description: 'Schema for inviting a participant to an event',
});
export const updateParticipantSchema = zod
.object({
status: participantStatusSchema,
})
.openapi('UpdateParticipant', {
description: 'Schema for updating participant status in an event',
});
export const ParticipantSchema = zod
.object({
user: PublicUserSchema,
status: participantStatusSchema,
})
.openapi('Participant', {
description: 'Participant information including user and status',
});
export const ParticipantResponseSchema = zod.object({
success: zod.boolean(),
participant: ParticipantSchema,
});
export const ParticipantsResponseSchema = zod.object({
success: zod.boolean(),
participants: zod.array(ParticipantSchema),
});

View file

@ -1,90 +1,48 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema,
} from '../../validation';
import { EventResponseSchema } from '../validation';
import { updateEventSchema } from '../validation';
const patchEventSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
start_time: z.iso.datetime().optional(),
end_time: z.iso.datetime().optional(),
location: z.string().optional(),
status: z.enum(['TENTATIVE', 'CONFIRMED', 'CANCELLED']).optional(),
});
/**
* @swagger
* /api/event/{eventID}:
* get:
* summary: Get details of a specific event
* description: Returns the details of an event by its ID.
* tags:
* - Event
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event to retrieve.
* responses:
* 200:
* description: Event details retrieved successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* event:
* $ref: "#/components/schemas/Event"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User or event not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
*/
export const GET = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
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,
@ -101,6 +59,10 @@ export const GET = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
@ -109,6 +71,10 @@ export const GET = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
@ -117,120 +83,67 @@ export const GET = auth(async (req, { params }) => {
},
});
if (!event) {
return NextResponse.json(
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
}
return NextResponse.json(
{
success: true,
event: event,
},
return returnZodTypeCheckedResponse(
EventResponseSchema,
{ success: true, event },
{ status: 200 },
);
});
/**
* @swagger
* /api/event/{eventID}:
* delete:
* summary: Delete a specific event
* description: Deletes an event by its ID if the user is the organizer.
* tags:
* - Event
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event to delete.
* responses:
* 200:
* description: Event deleted successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 403:
* description: User is not the organizer.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: You are not the organizer of this event
* 404:
* description: User or event not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
*/
export const DELETE = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
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 NextResponse.json(
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
}
if (event.organizer_id !== dbUser.id) {
return NextResponse.json(
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: {
@ -238,146 +151,66 @@ export const DELETE = auth(async (req, { params }) => {
},
});
return NextResponse.json(
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{ success: true, message: 'Event deleted successfully' },
{ status: 200 },
);
});
/**
* @swagger
* /api/event/{eventID}:
* patch:
* summary: Update a specific event
* description: Updates an event by its ID if the user is the organizer.
* tags:
* - Event
* parameters:
* - in: path
* name: eventID
* required: true
* schema:
* type: string
* description: The ID of the event to update.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* description:
* type: string
* start_time:
* type: string
* format: date-time
* end_time:
* type: string
* format: date-time
* location:
* type: string
* status:
* type: string
* enum: [TENTATIVE, CONFIRMED, CANCELLED]
* responses:
* 200:
* description: Event updated successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* event:
* $ref: "#/components/schemas/Event"
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 403:
* description: User is not the organizer.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: You are not the organizer of this event
* 404:
* description: User or event not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User or event not found
*/
export const PATCH = auth(async (req, { params }) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser) {
return NextResponse.json(
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 NextResponse.json(
if (!event)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'Event not found' },
{ status: 404 },
);
}
if (event.organizer_id !== dbUser.id) {
return NextResponse.json(
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 patchEventSchema.safeParseAsync(dataRaw);
const data = await updateEventSchema.safeParseAsync(dataRaw);
if (!data.success) {
return NextResponse.json(
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid input data',
@ -416,6 +249,10 @@ export const PATCH = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
@ -424,6 +261,10 @@ export const PATCH = auth(async (req, { params }) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
@ -432,7 +273,8 @@ export const PATCH = auth(async (req, { params }) => {
},
});
return NextResponse.json(
return returnZodTypeCheckedResponse(
EventResponseSchema,
{
success: true,
event: updatedEvent,

View file

@ -0,0 +1,94 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { EventResponseSchema, updateEventSchema } from '../validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import {
EventIdParamSchema,
SuccessResponseSchema,
} from '@/app/api/validation';
import zod from 'zod/v4';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'Event retrieved successfully',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'delete',
path: '/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
},
responses: {
200: {
description: 'Event deleted successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'patch',
path: '/event/{eventID}',
request: {
params: zod.object({
eventID: EventIdParamSchema,
}),
body: {
content: {
'application/json': {
schema: updateEventSchema,
},
},
},
},
responses: {
200: {
description: 'Event updated successfully',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
}

View file

@ -1,95 +1,34 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { ErrorResponseSchema, ZodErrorResponseSchema } from '../validation';
import {
createEventSchema,
EventResponseSchema,
EventsResponseSchema,
} from './validation';
const postEventSchema = z
.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
start_time: z.iso.datetime(),
end_time: z.iso.datetime(),
location: z.string().optional().default(''),
participants: z.array(z.string()).optional(),
})
.refine((data) => new Date(data.start_time) < new Date(data.end_time), {
error: 'Start time must be before end time',
})
.refine(
async (data) =>
!data.participants ||
(await Promise.all(
data.participants.map(async (userId) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
return !!user;
}),
).then((results) => results.every(Boolean))),
{
error: 'One or more participant user IDs are invalid',
},
);
/**
* @swagger
* /api/event:
* get:
* summary: Get all events for the authenticated user
* description: Returns all events where the user is an organizer or a participant.
* tags:
* - Event
* responses:
* 200:
* description: List of events for the user.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* events:
* type: array
* items:
* $ref: "#/components/schemas/Event"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
*/
export const GET = auth(async (req) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
});
if (!dbUser)
return NextResponse.json(
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{ success: false, message: 'User not found' },
{ status: 404 },
);
@ -115,6 +54,10 @@ export const GET = auth(async (req) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
@ -123,6 +66,10 @@ export const GET = auth(async (req) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
@ -131,101 +78,30 @@ export const GET = auth(async (req) => {
},
});
return NextResponse.json({
success: true,
events: userEvents,
});
return returnZodTypeCheckedResponse(
EventsResponseSchema,
{
success: true,
events: userEvents,
},
{ status: 200 },
);
});
/**
* @swagger
* /api/event:
* post:
* summary: Create a new event
* description: Creates a new event as the authenticated user (organizer).
* tags:
* - Event
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - title
* - start_time
* - end_time
* properties:
* title:
* type: string
* description:
* type: string
* start_time:
* type: string
* format: date-time
* end_time:
* type: string
* format: date-time
* location:
* type: string
* participants:
* type: array
* items:
* type: string
* description: User ID of a participant
* responses:
* 200:
* description: Event created successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* event:
* $ref: "#/components/schemas/Event"
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
* 401:
* description: Not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: Not authenticated
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: User not found
*/
export const POST = auth(async (req) => {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = await req.json();
const data = await postEventSchema.safeParseAsync(dataRaw);
const data = await createEventSchema.safeParseAsync(dataRaw);
if (!data.success) {
return NextResponse.json(
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
@ -244,7 +120,7 @@ export const POST = auth(async (req) => {
start_time,
end_time,
location,
organizer_id: req.auth.user.id,
organizer_id: authCheck.user.id!,
participants: participants
? {
create: participants.map((userId) => ({
@ -267,6 +143,10 @@ export const POST = auth(async (req) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
participants: {
@ -275,6 +155,10 @@ export const POST = auth(async (req) => {
select: {
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
},
},
status: true,
@ -283,8 +167,12 @@ export const POST = auth(async (req) => {
},
});
return NextResponse.json({
success: true,
event: newEvent,
});
return returnZodTypeCheckedResponse(
EventResponseSchema,
{
success: true,
event: newEvent,
},
{ status: 201 },
);
});

View file

@ -0,0 +1,62 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import {
EventResponseSchema,
EventsResponseSchema,
createEventSchema,
} from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/event',
responses: {
200: {
description: 'List of events for the authenticated user',
content: {
'application/json': {
schema: EventsResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
registry.registerPath({
method: 'post',
path: '/api/event',
request: {
body: {
content: {
'application/json': {
schema: createEventSchema,
},
},
},
},
responses: {
201: {
description: 'Event created successfully.',
content: {
'application/json': {
schema: EventResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Event'],
});
}

View file

@ -0,0 +1,168 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import {
existingUserIdServerSchema,
PublicUserSchema,
} from '../user/validation';
import { ParticipantSchema } from './[eventID]/participant/validation';
extendZodWithOpenApi(zod);
// ----------------------------------------
//
// Event ID Validation
//
// ----------------------------------------
export const eventIdSchema = zod.string().min(1, 'Event ID is required');
// ----------------------------------------
//
// Event Title Validation
//
// ----------------------------------------
export const eventTitleSchema = zod
.string()
.min(1, 'Title is required')
.max(100, 'Title must be at most 100 characters long');
// ----------------------------------------
//
// Event Description Validation
//
// ----------------------------------------
export const eventDescriptionSchema = zod
.string()
.max(500, 'Description must be at most 500 characters long');
// ----------------------------------------
//
// Event start time Validation
//
// ----------------------------------------
export const eventStartTimeSchema = zod.iso
.datetime()
.or(zod.date().transform((date) => date.toISOString()))
.refine((date) => !isNaN(new Date(date).getTime()), {
message: 'Invalid start time',
});
// ----------------------------------------
//
// Event end time Validation
//
// ----------------------------------------
export const eventEndTimeSchema = zod.iso.datetime().or(
zod
.date()
.transform((date) => date.toISOString())
.refine((date) => !isNaN(new Date(date).getTime()), {
message: 'Invalid end time',
}),
);
// ----------------------------------------
//
// Event Location Validation
//
// ----------------------------------------
export const eventLocationSchema = zod
.string()
.max(200, 'Location must be at most 200 characters long');
// ----------------------------------------
//
// Event Participants Validation
//
// ----------------------------------------
export const eventParticipantsSchema = zod.array(existingUserIdServerSchema);
// ----------------------------------------
//
// Event Status Validation
//
// ----------------------------------------
export const eventStatusSchema = zod.enum([
'TENTATIVE',
'CONFIRMED',
'CANCELLED',
]);
// ----------------------------------------
//
// Create Event Schema
//
// ----------------------------------------
export const createEventSchema = zod
.object({
title: eventTitleSchema,
description: eventDescriptionSchema.optional(),
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
location: eventLocationSchema.optional().default(''),
participants: eventParticipantsSchema.optional(),
status: eventStatusSchema.optional().default('TENTATIVE'),
})
.refine((data) => new Date(data.start_time) < new Date(data.end_time), {
message: 'Start time must be before end time',
});
// ----------------------------------------
//
// Update Event Schema
//
// ----------------------------------------
export const updateEventSchema = zod
.object({
id: eventIdSchema,
title: eventTitleSchema.optional(),
description: eventDescriptionSchema.optional(),
start_time: eventStartTimeSchema.optional(),
end_time: eventEndTimeSchema.optional(),
location: eventLocationSchema.optional().default(''),
participants: eventParticipantsSchema.optional(),
status: eventStatusSchema.optional(),
})
.refine(
(data) => {
if (data.start_time && data.end_time) {
return new Date(data.start_time) < new Date(data.end_time);
}
return true;
},
{
message: 'Start time must be before end time',
},
);
// ----------------------------------------
//
// Event Schema Validation (for API responses)
//
// ----------------------------------------
export const EventSchema = zod
.object({
id: eventIdSchema,
title: eventTitleSchema,
description: eventDescriptionSchema.nullish(),
start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema,
location: eventLocationSchema.nullish(),
status: eventStatusSchema,
created_at: zod.date(),
updated_at: zod.date(),
organizer: PublicUserSchema,
participants: zod.array(ParticipantSchema).nullish(),
})
.openapi('Event', {
description: 'Event information including all fields',
});
export const EventResponseSchema = zod.object({
success: zod.boolean(),
event: EventSchema,
});
export const EventsResponseSchema = zod.object({
success: zod.boolean(),
events: zod.array(EventSchema),
});

View file

@ -1,118 +1,29 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/prisma';
import { z } from 'zod/v4';
import { searchUserSchema, searchUserResponseSchema } from './validation';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
const getSearchUserSchema = z.object({
query: z.string().optional().default(''),
count: z.coerce.number().min(1).max(100).default(10),
page: z.coerce.number().min(1).default(1),
sort_by: z
.enum(['created_at', 'name', 'first_name', 'last_name', 'id'])
.optional()
.default('created_at'),
sort_order: z.enum(['asc', 'desc']).optional().default('desc'),
});
/**
* @swagger
* /api/search/user:
* get:
* summary: Search for users
* description: Search for users by name, first name, or last name with pagination and sorting.
* tags:
* - Search
* parameters:
* - in: query
* name: query
* required: false
* schema:
* type: string
* description: The search query to filter users by name, first name, or last name.
* - in: query
* name: count
* required: false
* schema:
* type: integer
* description: The number of users to return per page (default is 10).
* - in: query
* name: page
* required: false
* schema:
* type: integer
* description: The page number to return (default is 1).
* - in: query
* name: sort_by
* required: false
* schema:
* type: string
* enum: ["created_at", "name", "first_name", "last_name", "id"]
* description: The field to sort by (default is 'created_at').
* - in: query
* name: sort_order
* required: false
* schema:
* type: string
* enum: ["asc", "desc"]
* description: The order to sort by, either 'asc' or 'desc' (default is 'desc').
* responses:
* 200:
* description: Users found successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* users:
* type: array
* items:
* $ref: "#/components/schemas/PublicUser"
* count:
* type: integer
* description: The number of users returned.
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "Not authenticated"
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "User not found"
*/
export const GET = auth(async function GET(req) {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
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 getSearchUserSchema.safeParseAsync(dataRaw);
if (!data.success) {
return NextResponse.json(
const data = await searchUserSchema.safeParseAsync(dataRaw);
if (!data.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
@ -120,7 +31,6 @@ export const GET = auth(async function GET(req) {
},
{ status: 400 },
);
}
const { query, count, page, sort_by, sort_order } = data.data;
const dbUsers = await prisma.user.findMany({
@ -156,9 +66,14 @@ export const GET = auth(async function GET(req) {
},
});
return NextResponse.json({
success: true,
users: dbUsers,
count: userCount,
});
return returnZodTypeCheckedResponse(
searchUserResponseSchema,
{
success: true,
users: dbUsers,
total_count: userCount,
total_pages: Math.ceil(userCount / count),
},
{ status: 200 },
);
});

View file

@ -0,0 +1,33 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { searchUserResponseSchema, searchUserSchema } from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/search/user',
request: {
query: searchUserSchema,
},
responses: {
200: {
description: 'User search results',
content: {
'application/json': {
schema: searchUserResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['Search'],
});
}

View file

@ -0,0 +1,20 @@
import zod from 'zod/v4';
import { PublicUserSchema } from '../../user/validation';
export const searchUserSchema = zod.object({
query: zod.string().optional().default(''),
count: zod.coerce.number().min(1).max(100).default(10),
page: zod.coerce.number().min(1).default(1),
sort_by: zod
.enum(['created_at', 'name', 'first_name', 'last_name', 'id'])
.optional()
.default('created_at'),
sort_order: zod.enum(['asc', 'desc']).optional().default('desc'),
});
export const searchUserResponseSchema = zod.object({
success: zod.boolean(),
users: zod.array(PublicUserSchema),
total_count: zod.number(),
total_pages: zod.number(),
});

View file

@ -1,64 +1,19 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/prisma';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { PublicUserResponseSchema } from '../validation';
import { ErrorResponseSchema } from '@/app/api/validation';
/**
* @swagger
* /api/user/{user}:
* get:
* summary: Get user information by ID or name
* description: Retrieve the information of a specific user by ID or name.
* tags:
* - User
* parameters:
* - in: path
* name: user
* required: true
* schema:
* type: string
* description: The ID or name of the user to retrieve.
* responses:
* 200:
* description: User information retrieved successfully.
* content:
* application/json:
* schema:
* type: "object"
* properties:
* success:
* type: "boolean"
* default: true
* user:
* $ref: "#/components/schemas/PublicUser"
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "Not authenticated"
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "User not found"
*/
export const GET = auth(async function GET(req, { params }) {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const requestedUser = (await params).user;
@ -75,17 +30,26 @@ export const GET = auth(async function GET(req, { params }) {
created_at: true,
updated_at: true,
image: true,
timezone: true,
},
});
if (!dbUser)
return NextResponse.json(
{ success: false, message: 'User not found' },
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return NextResponse.json({
success: true,
user: dbUser,
});
return returnZodTypeCheckedResponse(
PublicUserResponseSchema,
{
success: true,
user: dbUser,
},
{ status: 200 },
);
});

View file

@ -0,0 +1,33 @@
import { PublicUserResponseSchema } from '../validation';
import {
notAuthenticatedResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
import { UserIdParamSchema } from '../../validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/user/{user}',
request: {
params: zod.object({
user: UserIdParamSchema,
}),
},
responses: {
200: {
description: 'User information retrieved successfully.',
content: {
'application/json': {
schema: PublicUserResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
},
tags: ['User'],
});
}

View file

@ -1,78 +1,28 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/prisma';
import { updateUserServerSchema } from './validation';
import {
userEmailSchema,
userFirstNameSchema,
userNameSchema,
userLastNameSchema,
} from '@/lib/validation/user';
import { z } from 'zod/v4';
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { FullUserResponseSchema } from '../validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
const patchUserMeSchema = z.object({
name: userNameSchema.optional(),
first_name: userFirstNameSchema.optional(),
last_name: userLastNameSchema.optional(),
email: userEmailSchema.optional(),
image: z.string().optional(),
timezone: z.string().optional(),
});
/**
* @swagger
* /api/user/me:
* get:
* summary: Get the currently authenticated user's information
* description: Retrieve the information of the currently authenticated user.
* tags:
* - User
* responses:
* 200:
* description: User information retrieved successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* default: true
* user:
* $ref: "#/components/schemas/User"
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "Not authenticated"
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "User not found"
*/
export const GET = auth(async function GET(req) {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
id: authCheck.user.id,
},
select: {
id: true,
@ -87,109 +37,35 @@ export const GET = auth(async function GET(req) {
},
});
if (!dbUser)
return NextResponse.json(
{ success: false, message: 'User not found' },
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return NextResponse.json(
{
success: true,
user: {
...dbUser,
},
},
{ status: 200 },
);
return returnZodTypeCheckedResponse(FullUserResponseSchema, {
success: true,
user: dbUser,
});
});
/**
* @swagger
* /api/user/me:
* patch:
* summary: Update the currently authenticated user's information
* description: Update the information of the currently authenticated user.
* tags:
* - User
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description: Username of the user.
* first_name:
* type: string
* description: First name of the user.
* last_name:
* type: string
* description: Last name of the user.
* email:
* type: string
* description: Email address of the user.
* image:
* type: string
* description: URL of the user's profile image.
* timezone:
* type: string
* description: Timezone of the user.
* responses:
* 200:
* description: User information updated successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* default: true
* user:
* $ref: "#/components/schemas/User"
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "Not authenticated"
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ErrorResponse"
* example:
* success: false
* message: "User not found"
* 400:
* description: Invalid request data.
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ZodErrorResponse"
*/
export const PATCH = auth(async function PATCH(req) {
if (!req.auth)
return NextResponse.json(
{ success: false, message: 'Not authenticated' },
{ status: 401 },
);
if (!req.auth.user)
return NextResponse.json(
{ success: false, message: 'User not found' },
{ status: 404 },
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dataRaw = await req.json();
const data = await patchUserMeSchema.safeParseAsync(dataRaw);
const data = await updateUserServerSchema.safeParseAsync(dataRaw);
if (!data.success) {
return NextResponse.json(
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
@ -198,19 +74,19 @@ export const PATCH = auth(async function PATCH(req) {
{ status: 400 },
);
}
const { name, first_name, last_name, email, image, timezone } = data.data;
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: req.auth.user.id,
},
data: {
name,
first_name,
last_name,
email,
image,
timezone,
id: authCheck.user.id,
},
data: data.data,
select: {
id: true,
name: true,
@ -224,11 +100,16 @@ export const PATCH = auth(async function PATCH(req) {
},
});
if (!updatedUser)
return NextResponse.json(
{ success: false, message: 'User not found' },
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
return NextResponse.json(
return returnZodTypeCheckedResponse(
FullUserResponseSchema,
{
success: true,
user: updatedUser,

View file

@ -0,0 +1,63 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { FullUserResponseSchema } from '../validation';
import { updateUserServerSchema } from './validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'get',
path: '/api/user/me',
description: 'Get the currently authenticated user',
responses: {
200: {
description: 'User information retrieved successfully',
content: {
'application/json': {
schema: FullUserResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
registry.registerPath({
method: 'patch',
path: '/api/user/me',
description: 'Update the currently authenticated user',
request: {
body: {
description: 'User information to update',
required: true,
content: {
'application/json': {
schema: updateUserServerSchema,
},
},
},
},
responses: {
200: {
description: 'User information updated successfully',
content: {
'application/json': {
schema: FullUserResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
}

View file

@ -0,0 +1,21 @@
import zod from 'zod/v4';
import {
firstNameSchema,
lastNameSchema,
newUserEmailServerSchema,
newUserNameServerSchema,
} from '@/app/api/user/validation';
// ----------------------------------------
//
// Update User Validation
//
// ----------------------------------------
export const updateUserServerSchema = zod.object({
name: newUserNameServerSchema.optional(),
first_name: firstNameSchema.optional(),
last_name: lastNameSchema.optional(),
email: newUserEmailServerSchema.optional(),
image: zod.string().optional(),
timezone: zod.string().optional(),
});

View file

@ -0,0 +1,149 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { prisma } from '@/prisma';
import zod from 'zod/v4';
extendZodWithOpenApi(zod);
// ----------------------------------------
//
// Email Validation
//
// ----------------------------------------
export const emailSchema = zod
.email('Invalid email address')
.min(3, 'Email is required');
export const newUserEmailServerSchema = emailSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { email: val },
});
return !existingUser;
}, 'Email in use by another account');
export const existingUserEmailServerSchema = emailSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { email: val },
});
return !!existingUser;
}, 'Email not found');
// ----------------------------------------
//
// First Name Validation
//
// ----------------------------------------
export const firstNameSchema = zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long');
// ----------------------------------------
//
// Last Name Validation
//
// ----------------------------------------
export const lastNameSchema = zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long');
// ----------------------------------------
//
// Username Validation
//
// ----------------------------------------
export const userNameSchema = zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
);
export const newUserNameServerSchema = userNameSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { name: val },
});
return !existingUser;
}, 'Username in use by another account');
export const existingUserNameServerSchema = userNameSchema.refine(
async (val) => {
const existingUser = await prisma.user.findUnique({
where: { name: val },
});
return !!existingUser;
},
'Username not found',
);
// ----------------------------------------
//
// User ID Validation
//
// ----------------------------------------
export const existingUserIdServerSchema = zod
.string()
.min(1, 'User ID is required')
.refine(async (val) => {
const user = await prisma.user.findUnique({
where: { id: val },
});
return !!user;
}, 'User not found');
// ----------------------------------------
//
// Password Validation
//
// ----------------------------------------
export const passwordSchema = zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={}\[\]:;"'<>,.?\/\\-]).{8,}$/,
'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
);
// ----------------------------------------
//
// User Schema Validation (for API responses)
//
// ----------------------------------------
export const FullUserSchema = zod
.object({
id: zod.string(),
name: zod.string(),
first_name: zod.string().nullish(),
last_name: zod.string().nullish(),
email: zod.email(),
image: zod.string().nullish(),
timezone: zod.string(),
created_at: zod.date(),
updated_at: zod.date(),
})
.openapi('FullUser', {
description: 'Full user information including all fields',
});
export const PublicUserSchema = FullUserSchema.pick({
id: true,
name: true,
first_name: true,
last_name: true,
image: true,
timezone: true,
}).openapi('PublicUser', {
description: 'Public user information excluding sensitive data',
});
export const FullUserResponseSchema = zod.object({
success: zod.boolean(),
user: FullUserSchema,
});
export const PublicUserResponseSchema = zod.object({
success: zod.boolean(),
user: PublicUserSchema,
});

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

@ -0,0 +1,87 @@
import { registry } from '@/lib/swagger';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import zod from 'zod/v4';
extendZodWithOpenApi(zod);
export const ErrorResponseSchema = zod
.object({
success: zod.boolean(),
message: zod.string(),
})
.openapi('ErrorResponseSchema', {
description: 'Error response schema',
example: {
success: false,
message: 'An error occurred',
},
});
export const ZodErrorResponseSchema = ErrorResponseSchema.extend({
errors: zod.array(
zod.object({
expected: zod.string().optional(),
code: zod.string(),
path: zod.array(
zod
.string()
.or(zod.number())
.or(
zod.symbol().openapi({
type: 'string',
}),
),
),
message: zod.string(),
}),
),
}).openapi('ZodErrorResponseSchema', {
description: 'Zod error response schema',
example: {
success: false,
message: 'Invalid request data',
errors: [
{
expected: 'string',
code: 'invalid_type',
path: ['first_name'],
message: 'Invalid input: expected string, received number',
},
],
},
});
export const SuccessResponseSchema = zod
.object({
success: zod.boolean(),
message: zod.string().optional(),
})
.openapi('SuccessResponseSchema', {
description: 'Success response schema',
example: {
success: true,
message: 'Operation completed successfully',
},
});
export const UserIdParamSchema = registry.registerParameter(
'UserIdOrNameParam',
zod.string().openapi({
param: {
name: 'user',
in: 'path',
},
example: '12345',
}),
);
export const EventIdParamSchema = registry.registerParameter(
'EventIdParam',
zod.string().openapi({
param: {
name: 'eventID',
in: 'path',
},
example: '67890',
}),
);

View file

@ -8,7 +8,7 @@ import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma';
import { loginClientSchema } from './lib/validation/user';
import { loginSchema } from '@/lib/auth/validation';
import { ZodError } from 'zod/v4';
class InvalidLoginError extends CredentialsSignin {
@ -37,7 +37,7 @@ const providers: Provider[] = [
if (process.env.DISABLE_PASSWORD_LOGIN) return null;
try {
const { email, password } = await loginClientSchema.parseAsync(c);
const { email, password } = await loginSchema.parseAsync(c);
const user = await prisma.user.findFirst({
where: { OR: [{ email }, { name: email }] },

View file

@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button';
import useZodForm from '@/lib/hooks/useZodForm';
import { loginClientSchema, registerClientSchema } from '@/lib/validation/user';
import { loginSchema, registerSchema } from '@/lib/auth/validation';
import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register';
@ -18,7 +18,7 @@ function LoginFormElement({
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(loginClientSchema);
useZodForm(loginSchema);
const router = useRouter();
const onSubmit = handleSubmit(async (data) => {
@ -95,7 +95,7 @@ function RegisterFormElement({
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(registerClientSchema);
useZodForm(registerSchema);
const onSubmit = handleSubmit(async (data) => {
try {

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

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

View file

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

View file

@ -2,12 +2,14 @@
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 {

View file

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

View file

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

View file

@ -1,8 +1,36 @@
import { createSwaggerSpec } from 'next-swagger-doc';
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from '@asteasolutions/zod-to-openapi';
import swaggerConfig from '../../next-swagger-doc.json';
export const registry = new OpenAPIRegistry();
export const getApiDocs = async () => {
const spec = createSwaggerSpec(swaggerConfig);
return spec;
const swaggerFiles = require.context('../app', true, /swagger\.ts$/);
swaggerFiles
.keys()
.sort((a, b) => b.length - a.length)
.forEach((file) => {
console.log(`Registering Swagger file: ${file}`);
swaggerFiles(file).default?.(registry);
});
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@/app/api/validation');
try {
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'MeetUP',
description: 'API documentation for MeetUP application',
},
});
return spec;
} catch (error) {
console.error('Error generating API docs:', error);
throw new Error('Failed to generate API documentation');
}
};

View file

@ -1,123 +0,0 @@
import { prisma } from '@/prisma';
import zod from 'zod/v4';
export const userEmailClientSchema = zod
.email('Invalid email address')
.min(3, 'Email is required');
export const userEmailSchema = userEmailClientSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { email: val },
});
return !existingUser;
}, 'Email in use by another account');
export const userFirstNameSchema = zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long');
export const userLastNameSchema = zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long');
export const userNameClientSchema = 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((val) => !disallowedUsernames.includes(val?.toLowerCase() || ''), {
error: 'Username is not allowed',
});
export const userNameSchema = userNameClientSchema.refine(async (val) => {
const existingUser = await prisma.user.findUnique({
where: { name: val },
});
return !existingUser;
}, 'Username in use by another account');
export const loginClientSchema = zod.object({
email: userEmailClientSchema.or(userNameClientSchema),
password: zod.string().min(1, 'Password is required'),
});
export const userIdSchema = 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');
export const registerSchema = zod
.object({
firstName: userFirstNameSchema,
lastName: userLastNameSchema,
email: userEmailSchema,
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: userNameSchema,
})
.refine((data) => data.password === data.confirmPassword, {
error: '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),
{
error:
'Password cannot contain your first name, last name, email, or username',
path: ['password'],
},
);
export const registerClientSchema = zod
.object({
firstName: userFirstNameSchema,
lastName: userLastNameSchema,
email: userEmailClientSchema,
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: userNameClientSchema,
})
.refine((data) => data.password === data.confirmPassword, {
error: '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),
{
error:
'Password cannot contain your first name, last name, email, or username',
path: ['password'],
},
);
export const disallowedUsernames = ['me', 'admin', 'search'];