feat(api): add user search endpoint and normalize response data and validation
Some checks failed
container-scan / Container Scan (pull_request) Failing after 7m35s
docker-build / docker (pull_request) Failing after 11m56s

This commit is contained in:
Dominik 2025-06-17 21:46:38 +02:00
parent 787edf8e1f
commit 476114ca87
Signed by: dominik
GPG key ID: 06A4003FC5049644
14 changed files with 574 additions and 368 deletions

View file

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

View file

@ -1,6 +1,11 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
export 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,
});
});

View file

@ -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';
export 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: {

View file

@ -1,6 +1,16 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
export 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<string, string> = {};
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 },
);

View file

@ -1,6 +1,19 @@
import { prisma } from '@/prisma';
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
export 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,
});
});

View file

@ -0,0 +1,154 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/prisma';
import { z } from 'zod/v4';
export 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,
});
});

View file

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

View file

@ -6,8 +6,17 @@ import {
userFirstNameSchema,
userNameSchema,
userLastNameSchema,
disallowedUsernames,
} from '@/lib/validation/user';
import { z } from 'zod/v4';
export 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<string, string> = {};
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 },
);

View file

@ -8,9 +8,8 @@ import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma';
import { loginSchema } from './lib/validation/user';
import { ZodError } from 'zod';
import { 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 }] },

View file

@ -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<HTMLFormElement | null>;
}) {
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<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(registerSchema);
useZodForm(registerClientSchema);
const onSubmit = handleSubmit(async (data) => {
try {

View file

@ -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<typeof loginSchema>) {
export async function loginAction(data: z.infer<typeof loginClientSchema>) {
try {
await signIn('credentials', {
...data,

View file

@ -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<typeof registerSchema>) {
@ -11,39 +11,12 @@ export async function registerAction(data: z.infer<typeof registerSchema>) {
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) => {

View file

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

View file

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