diff --git a/next-swagger-doc.json b/next-swagger-doc.json index 7af9ffe..eec01cb 100644 --- a/next-swagger-doc.json +++ b/next-swagger-doc.json @@ -21,6 +21,29 @@ } } }, + "ZodErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": false + }, + "message": { + "type": "string", + "description": "Error message" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "message": { "type": "string" } + } + } + } + } + }, "User": { "type": "object", "properties": { @@ -30,7 +53,9 @@ "last_name": { "type": "string" }, "email": { "type": "string", "format": "email" }, "image": { "type": "string", "format": "uri" }, - "timezone": { "type": "string", "description": "User timezone" } + "timezone": { "type": "string", "description": "User timezone" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } } }, "PublicUser": { @@ -76,9 +101,17 @@ "participants": { "type": "array", "items": { - "$ref": "#/components/schemas/Participant" + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/SimpleUser" + }, + "status": { "type": "string" } + } } - } + }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } } } } diff --git a/src/app/api/event/[eventID]/participant/[user]/route.ts b/src/app/api/event/[eventID]/participant/[user]/route.ts index 2426c49..ea1e1b1 100644 --- a/src/app/api/event/[eventID]/participant/[user]/route.ts +++ b/src/app/api/event/[eventID]/participant/[user]/route.ts @@ -1,6 +1,11 @@ import { prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; + +const patchParticipantSchema = z.object({ + status: z.enum(['ACCEPTED', 'DECLINED', 'TENTATIVE', 'PENDING']), +}); /** * @swagger @@ -122,8 +127,14 @@ export const GET = auth(async (req, { params }) => { user_id: user, }, }, - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, }, }); @@ -136,13 +147,7 @@ export const GET = auth(async (req, { params }) => { return NextResponse.json({ success: true, - participant: { - user: { - id: participant.user.id, - name: participant.user.name, - }, - status: participant.status, - }, + participant, }); }); @@ -324,6 +329,15 @@ export const DELETE = auth(async (req, { params }) => { * type: boolean * participant: * $ref: '#/components/schemas/Participant' + * 400: + * description: Bad request due to invalid input data. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * message: 'Invalid input data' * 401: * description: Not authenticated. * content: @@ -394,6 +408,15 @@ export const PATCH = auth(async (req, { params }) => { user_id: dbUser.id, }, }, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, + }, }); if (!participant) { @@ -404,21 +427,18 @@ export const PATCH = auth(async (req, { params }) => { } const body = await req.json(); - const { status } = body; - - if (!status) { + const parsedBody = patchParticipantSchema.safeParse(body); + if (!parsedBody.success) { return NextResponse.json( - { success: false, message: 'Status is required' }, - { status: 400 }, - ); - } - - if (!['accepted', 'declined', 'tentative'].includes(status)) { - return NextResponse.json( - { success: false, message: 'Invalid status' }, + { + success: false, + message: 'Invalid request body', + errors: parsedBody.error.issues, + }, { status: 400 }, ); } + const { status } = parsedBody.data; await prisma.meetingParticipant.update({ where: { @@ -428,18 +448,12 @@ export const PATCH = auth(async (req, { params }) => { }, }, data: { - status: status.toUpperCase(), + status, }, }); return NextResponse.json({ success: true, - participant: { - user: { - id: dbUser.id, - name: dbUser.name, - }, - status, - }, + participant, }); }); diff --git a/src/app/api/event/[eventID]/participant/route.ts b/src/app/api/event/[eventID]/participant/route.ts index bee8340..1b1b2d4 100644 --- a/src/app/api/event/[eventID]/participant/route.ts +++ b/src/app/api/event/[eventID]/participant/route.ts @@ -1,6 +1,12 @@ import { prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { userIdSchema } from '@/lib/validation/user'; + +const postParticipantSchema = z.object({ + userId: userIdSchema, +}); /** * @swagger @@ -114,20 +120,20 @@ export const GET = auth(async (req, { params }) => { where: { meeting_id: eventID, }, - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, }, }); return NextResponse.json({ success: true, - participants: participants.map((participant) => ({ - user: { - id: participant.user.id, - name: participant.user.name, - }, - status: participant.status, - })), + participants, }); }); @@ -169,14 +175,14 @@ export const GET = auth(async (req, { params }) => { * participant: * $ref: '#/components/schemas/Participant' * 400: - * description: Bad request, user ID is required. + * description: Bad request due to invalid input data. * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * example: * success: false - * message: User ID is required + * message: 'Invalid input data' * 401: * description: Not authenticated. * content: @@ -255,15 +261,19 @@ export const POST = auth(async (req, { params }) => { ); } - const body = await req.json(); - const { userId } = body; - - if (!userId) { + const dataRaw = await req.json(); + const data = await postParticipantSchema.safeParseAsync(dataRaw); + if (!data.success) { return NextResponse.json( - { success: false, message: 'User ID is required' }, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, { status: 400 }, ); } + const { userId } = data.data; const participantExists = await prisma.meetingParticipant.findFirst({ where: { diff --git a/src/app/api/event/[eventID]/route.ts b/src/app/api/event/[eventID]/route.ts index 6798192..5361765 100644 --- a/src/app/api/event/[eventID]/route.ts +++ b/src/app/api/event/[eventID]/route.ts @@ -1,6 +1,16 @@ import { prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; + +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 @@ -76,11 +86,32 @@ export const GET = auth(async (req, { params }) => { where: { id: eventID, }, - include: { - organizer: true, + 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, + }, + }, participants: { - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, }, }, }, @@ -96,26 +127,7 @@ export const GET = auth(async (req, { params }) => { return NextResponse.json( { success: true, - event: { - id: event.id, - title: event.title, - description: event.description, - start_time: event.start_time, - end_time: event.end_time, - status: event.status, - location: event.location, - organizer: { - id: event.organizer.id, - name: event.organizer.name, - }, - participants: event.participants.map((participant) => ({ - user: { - id: participant.user.id, - name: participant.user.name, - }, - status: participant.status, - })), - }, + event: event, }, { status: 200 }, ); @@ -282,11 +294,14 @@ export const DELETE = auth(async (req, { params }) => { * event: * $ref: '#/components/schemas/Event' * 400: - * description: Invalid input data. + * description: Bad request due to invalid input data. * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * message: 'Invalid input data' * 401: * description: Not authenticated. * content: @@ -362,75 +377,59 @@ export const PATCH = auth(async (req, { params }) => { ); } - const body = await req.json(); - - const { title, description, start_time, end_time, location, status } = body; - - if ( - !title && - !description && - !start_time && - !end_time && - !location && - !status - ) { + const dataRaw = await req.json(); + const data = await patchEventSchema.safeParseAsync(dataRaw); + if (!data.success) { return NextResponse.json( - { success: false, message: 'No fields to update' }, + { + success: false, + message: 'Invalid input data', + errors: data.error.issues, + }, { status: 400 }, ); } - - const updateData: Record = {}; - if (title) updateData.title = title; - if (description) updateData.description = description; - if (start_time) { - const startTimeValidation = new Date(start_time); - if (isNaN(startTimeValidation.getTime())) { - return NextResponse.json( - { success: false, message: 'Invalid start time' }, - { status: 400 }, - ); - } - updateData.start_time = startTimeValidation.getTime().toString(); - } - if (end_time) { - const endTimeValidation = new Date(end_time); - if (isNaN(endTimeValidation.getTime())) { - return NextResponse.json( - { success: false, message: 'Invalid end time' }, - { status: 400 }, - ); - } - updateData.end_time = endTimeValidation.getTime().toString(); - } - if (new Date(start_time) >= new Date(end_time)) { - return NextResponse.json( - { success: false, message: 'start_time must be before end_time' }, - { status: 400 }, - ); - } - if (location) updateData.location = location; - if (status) { - const validStatuses = ['TENTATIVE', 'CONFIRMED', 'CANCELLED']; - if (!validStatuses.includes(status.toUpperCase())) { - return NextResponse.json( - { success: false, message: 'Invalid status' }, - { status: 400 }, - ); - } - updateData.status = status.toUpperCase(); - } + const { title, description, start_time, end_time, location, status } = + data.data; const updatedEvent = await prisma.meeting.update({ where: { id: eventID, }, - data: updateData, - include: { - organizer: true, + data: { + title, + description, + start_time, + end_time, + location, + status, + }, + select: { + id: true, + title: true, + description: true, + start_time: true, + end_time: true, + status: true, + location: true, + organizer_id: true, + created_at: true, + updated_at: true, + organizer: { + select: { + id: true, + name: true, + }, + }, participants: { - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, }, }, }, @@ -439,26 +438,7 @@ export const PATCH = auth(async (req, { params }) => { return NextResponse.json( { success: true, - event: { - id: updatedEvent.id, - title: updatedEvent.title, - description: updatedEvent.description, - start_time: updatedEvent.start_time, - end_time: updatedEvent.end_time, - status: updatedEvent.status, - location: updatedEvent.location, - organizer: { - id: updatedEvent.organizer_id, - name: dbUser.name, - }, - participants: updatedEvent.participants.map((participant) => ({ - user: { - id: participant.user.id, - name: participant.user.name, - }, - status: participant.status, - })), - }, + event: updatedEvent, }, { status: 200 }, ); diff --git a/src/app/api/event/route.ts b/src/app/api/event/route.ts index e825c47..bebf955 100644 --- a/src/app/api/event/route.ts +++ b/src/app/api/event/route.ts @@ -1,6 +1,19 @@ import { prisma } from '@/prisma'; import { auth } from '@/auth'; import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; + +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(''), + }) + .refine((data) => new Date(data.start_time) < new Date(data.end_time), { + error: 'Start time must be before end time', + }); /** * @swagger @@ -74,11 +87,31 @@ export const GET = auth(async (req) => { { participants: { some: { user_id: dbUser.id } } }, ], }, - include: { - organizer: true, + 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, + }, + }, participants: { - include: { - user: true, + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, }, }, }, @@ -86,23 +119,7 @@ export const GET = auth(async (req) => { return NextResponse.json({ success: true, - events: userEvents.map((event) => ({ - id: event.id, - title: event.title, - description: event.description, - start_time: event.start_time, - end_time: event.end_time, - status: event.status, - location: event.location, - organizer: { - id: event.organizer.id, - name: event.organizer.name, - }, - participants: event.participants.map((participant) => ({ - id: participant.user.id, - name: participant.user.name, - })), - })), + events: userEvents, }); }); @@ -150,14 +167,14 @@ export const GET = auth(async (req) => { * event: * $ref: '#/components/schemas/Event' * 400: - * description: Missing required fields. + * description: Bad request due to invalid input data. * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * example: * success: false - * message: Missing required fields + * message: 'Invalid input data' * 401: * description: Not authenticated. * content: @@ -189,36 +206,19 @@ export const POST = auth(async (req) => { { status: 404 }, ); - const body = await req.json(); - const { title, description, start_time, end_time, location } = body; - - if (!title || !start_time || !end_time) { + const dataRaw = await req.json(); + const data = await postEventSchema.safeParseAsync(dataRaw); + if (!data.success) { return NextResponse.json( - { success: false, message: 'Missing required fields' }, - { status: 400 }, - ); - } - - if (isNaN(new Date(start_time).getTime())) { - return NextResponse.json( - { success: false, message: 'Invalid start_time' }, - { status: 400 }, - ); - } - - if (isNaN(new Date(end_time).getTime())) { - return NextResponse.json( - { success: false, message: 'Invalid end_time' }, - { status: 400 }, - ); - } - - if (new Date(start_time) >= new Date(end_time)) { - return NextResponse.json( - { success: false, message: 'start_time must be before end_time' }, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, { status: 400 }, ); } + const { title, description, start_time, end_time, location } = data.data; const newEvent = await prisma.meeting.create({ data: { @@ -226,26 +226,41 @@ export const POST = auth(async (req) => { description, start_time, end_time, - location: location || '', + location, organizer_id: req.auth.user.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, + }, + }, + participants: { + select: { + user: { + select: { + id: true, + name: true, + }, + }, + status: true, + }, + }, + }, }); return NextResponse.json({ success: true, - event: { - id: newEvent.id, - title: newEvent.title, - description: newEvent.description, - start_time: newEvent.start_time, - end_time: newEvent.end_time, - status: newEvent.status, - location: newEvent.location, - organizer: { - id: req.auth.user.id, - name: req.auth.user.name, - }, - participants: [], - }, + event: newEvent, }); }); diff --git a/src/app/api/search/user/route.ts b/src/app/api/search/user/route.ts new file mode 100644 index 0000000..d4a941e --- /dev/null +++ b/src/app/api/search/user/route.ts @@ -0,0 +1,154 @@ +import { auth } from '@/auth'; +import { NextResponse } from 'next/server'; +import { prisma } from '@/prisma'; +import { z } from 'zod/v4'; + +const getSearchUserSchema = z.object({ + query: z.string().optional().default(''), + count: z.int().min(1).max(100).default(10), + page: z.int().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 dataRaw = new URL(req.url).searchParams; + const data = await getSearchUserSchema.safeParseAsync(dataRaw); + if (!data.success) { + return NextResponse.json( + { + 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, + }, + }); + + return NextResponse.json({ + success: true, + users: dbUsers, + count: dbUsers.length, + }); +}); diff --git a/src/app/api/user/[user]/route.ts b/src/app/api/user/[user]/route.ts index 4b515cc..b79d204 100644 --- a/src/app/api/user/[user]/route.ts +++ b/src/app/api/user/[user]/route.ts @@ -66,6 +66,16 @@ export const GET = auth(async function GET(req, { params }) { where: { OR: [{ id: requestedUser }, { name: requestedUser }], }, + select: { + id: true, + name: true, + first_name: true, + last_name: true, + email: true, + created_at: true, + updated_at: true, + image: true, + }, }); if (!dbUser) @@ -76,13 +86,6 @@ export const GET = auth(async function GET(req, { params }) { return NextResponse.json({ success: true, - user: { - id: dbUser.id, - name: dbUser.name, - first_name: dbUser.first_name, - last_name: dbUser.last_name, - timezone: dbUser.timezone, - image: dbUser.image, - }, + user: dbUser, }); }); diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index 12ceddf..96ff327 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -6,8 +6,17 @@ import { userFirstNameSchema, userNameSchema, userLastNameSchema, - disallowedUsernames, } from '@/lib/validation/user'; +import { z } from 'zod/v4'; + +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 @@ -65,6 +74,17 @@ export const GET = auth(async function GET(req) { where: { id: req.auth.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 NextResponse.json( @@ -77,8 +97,6 @@ export const GET = auth(async function GET(req) { success: true, user: { ...dbUser, - password_hash: undefined, // Exclude sensitive information - email_verified: undefined, // Exclude sensitive information }, }, { status: 200 }, @@ -171,91 +189,42 @@ export const PATCH = auth(async function PATCH(req) { { status: 404 }, ); - const body = await req.json(); - const { name, first_name, last_name, email, image, timezone } = body; - if (!name && !first_name && !last_name && !email && !image && !timezone) + const dataRaw = await req.json(); + const data = await patchUserMeSchema.safeParseAsync(dataRaw); + if (!data.success) { return NextResponse.json( - { success: false, message: 'No fields to update' }, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, { status: 400 }, ); - const updateData: Record = {}; - if (name) { - const nameValidation = userNameSchema.safeParse(name); - if (!nameValidation.success) { - return NextResponse.json( - { success: false, message: nameValidation.error.errors[0].message }, - { status: 400 }, - ); - } - // Check if the name already exists for another user - const existingUser = await prisma.user.findUnique({ - where: { name }, - }); - if ( - (existingUser && existingUser.id !== req.auth.user.id) || - disallowedUsernames.includes(name.toLowerCase()) - ) { - return NextResponse.json( - { success: false, message: 'Username in use by another account' }, - { status: 400 }, - ); - } - updateData.name = name; - } - if (first_name) { - const firstNameValidation = userFirstNameSchema.safeParse(first_name); - if (!firstNameValidation.success) { - return NextResponse.json( - { - success: false, - message: firstNameValidation.error.errors[0].message, - }, - { status: 400 }, - ); - } - updateData.first_name = first_name; - } - if (last_name) { - const lastNameValidation = userLastNameSchema.safeParse(last_name); - if (!lastNameValidation.success) { - return NextResponse.json( - { success: false, message: lastNameValidation.error.errors[0].message }, - { status: 400 }, - ); - } - updateData.last_name = last_name; - } - if (email) { - const emailValidation = userEmailSchema.safeParse(email); - if (!emailValidation.success) { - return NextResponse.json( - { success: false, message: emailValidation.error.errors[0].message }, - { status: 400 }, - ); - } - // Check if the email already exists for another user - const existingUser = await prisma.user.findUnique({ - where: { email }, - }); - if (existingUser && existingUser.id !== req.auth.user.id) { - return NextResponse.json( - { success: false, message: 'Email in use by another account' }, - { status: 400 }, - ); - } - updateData.email = email; - } - if (image) { - updateData.image = image; - } - if (timezone) { - updateData.timezone = timezone; } + const { name, first_name, last_name, email, image, timezone } = data.data; const updatedUser = await prisma.user.update({ where: { id: req.auth.user.id, }, - data: updateData, + data: { + name, + first_name, + last_name, + email, + image, + timezone, + }, + 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 NextResponse.json( @@ -265,11 +234,7 @@ export const PATCH = auth(async function PATCH(req) { return NextResponse.json( { success: true, - user: { - ...updatedUser, - password_hash: undefined, // Exclude sensitive information - email_verified: undefined, // Exclude sensitive information - }, + user: updatedUser, }, { status: 200 }, ); diff --git a/src/auth.ts b/src/auth.ts index 20b7bb6..1f5fee8 100644 --- a/src/auth.ts +++ b/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 { loginClientSchema } from './lib/validation/user'; +import { ZodError } from 'zod/v4'; class InvalidLoginError extends CredentialsSignin { constructor(code: string) { @@ -38,7 +37,7 @@ const providers: Provider[] = [ if (process.env.DISABLE_PASSWORD_LOGIN) return null; try { - const { email, password } = await loginSchema.parseAsync(c); + const { email, password } = await loginClientSchema.parseAsync(c); const user = await prisma.user.findFirst({ where: { OR: [{ email }, { name: email }] }, diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index a0167e2..39d42d8 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'; import LabeledInput from '@/components/labeled-input'; import { Button } from '@/components/custom-ui/button'; import useZodForm from '@/lib/hooks/useZodForm'; -import { loginSchema, registerSchema } from '@/lib/validation/user'; +import { loginClientSchema, registerClientSchema } from '@/lib/validation/user'; import { loginAction } from '@/lib/auth/login'; import { registerAction } from '@/lib/auth/register'; @@ -18,7 +18,7 @@ function LoginFormElement({ formRef?: React.RefObject; }) { const { handleSubmit, formState, register, setError } = - useZodForm(loginSchema); + useZodForm(loginClientSchema); const router = useRouter(); const onSubmit = handleSubmit(async (data) => { @@ -95,7 +95,7 @@ function RegisterFormElement({ formRef?: React.RefObject; }) { const { handleSubmit, formState, register, setError } = - useZodForm(registerSchema); + useZodForm(registerClientSchema); const onSubmit = handleSubmit(async (data) => { try { diff --git a/src/lib/auth/login.ts b/src/lib/auth/login.ts index 0019ae0..41df5b9 100644 --- a/src/lib/auth/login.ts +++ b/src/lib/auth/login.ts @@ -1,10 +1,10 @@ 'use server'; -import { z } from 'zod'; -import { loginSchema } from '@/lib/validation/user'; +import { z } from 'zod/v4'; +import { loginClientSchema } from '@/lib/validation/user'; import { signIn } from '@/auth'; -export async function loginAction(data: z.infer) { +export async function loginAction(data: z.infer) { try { await signIn('credentials', { ...data, diff --git a/src/lib/auth/register.ts b/src/lib/auth/register.ts index 2e51e18..26cdea4 100644 --- a/src/lib/auth/register.ts +++ b/src/lib/auth/register.ts @@ -1,8 +1,8 @@ 'use server'; -import type { z } from 'zod'; +import type { z } from 'zod/v4'; import bcrypt from 'bcryptjs'; -import { disallowedUsernames, registerSchema } from '@/lib/validation/user'; +import { registerSchema } from '@/lib/validation/user'; import { prisma } from '@/prisma'; export async function registerAction(data: z.infer) { @@ -11,39 +11,12 @@ export async function registerAction(data: z.infer) { 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 || - disallowedUsernames.includes(username.toLowerCase()) - ) { - return { - error: 'Username already exists', - }; - } - const passwordHash = await bcrypt.hash(password, 10); await prisma.$transaction(async (tx) => { diff --git a/src/lib/hooks/useZodForm.tsx b/src/lib/hooks/useZodForm.tsx index 8b8eb62..13dbf1d 100644 --- a/src/lib/hooks/useZodForm.tsx +++ b/src/lib/hooks/useZodForm.tsx @@ -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, Values extends z.infer, >(schema: Schema, defaultValues?: Values) { - return useForm({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return useForm, any, z.output>({ resolver: zodResolver(schema), defaultValues, }); diff --git a/src/lib/validation/user.ts b/src/lib/validation/user.ts index 755f395..f95da45 100644 --- a/src/lib/validation/user.ts +++ b/src/lib/validation/user.ts @@ -1,10 +1,17 @@ -import zod from 'zod'; +import { prisma } from '@/prisma'; +import zod from 'zod/v4'; -export const userEmailSchema = zod - .string() +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') @@ -15,20 +22,40 @@ export const userLastNameSchema = zod .min(1, 'Last name is required') .max(32, 'Last name must be at most 32 characters long'); -export const userNameSchema = zod +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 loginSchema = zod.object({ - email: userEmailSchema.or(userNameSchema), +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, @@ -45,7 +72,7 @@ export const registerSchema = zod username: userNameSchema, }) .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', + error: 'Passwords do not match', path: ['confirmPassword'], }) .refine( @@ -55,7 +82,39 @@ export const registerSchema = zod !data.password.includes(data.email) && !data.password.includes(data.username), { - message: + 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'], },