feat(api): add user search endpoint and normalize response data and validation

This commit is contained in:
Dominik 2025-06-17 21:46:38 +02:00
parent 91f4c524b9
commit c7b7d61cec
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": { "User": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -30,7 +53,9 @@
"last_name": { "type": "string" }, "last_name": { "type": "string" },
"email": { "type": "string", "format": "email" }, "email": { "type": "string", "format": "email" },
"image": { "type": "string", "format": "uri" }, "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": { "PublicUser": {
@ -76,9 +101,17 @@
"participants": { "participants": {
"type": "array", "type": "array",
"items": { "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 { prisma } from '@/prisma';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
const patchParticipantSchema = z.object({
status: z.enum(['ACCEPTED', 'DECLINED', 'TENTATIVE', 'PENDING']),
});
/** /**
* @swagger * @swagger
@ -122,8 +127,14 @@ export const GET = auth(async (req, { params }) => {
user_id: user, user_id: user,
}, },
}, },
include: { select: {
user: true, user: {
select: {
id: true,
name: true,
},
},
status: true,
}, },
}); });
@ -136,13 +147,7 @@ export const GET = auth(async (req, { params }) => {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
participant: { participant,
user: {
id: participant.user.id,
name: participant.user.name,
},
status: participant.status,
},
}); });
}); });
@ -324,6 +329,15 @@ export const DELETE = auth(async (req, { params }) => {
* type: boolean * type: boolean
* participant: * participant:
* $ref: '#/components/schemas/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: * 401:
* description: Not authenticated. * description: Not authenticated.
* content: * content:
@ -394,6 +408,15 @@ export const PATCH = auth(async (req, { params }) => {
user_id: dbUser.id, user_id: dbUser.id,
}, },
}, },
select: {
user: {
select: {
id: true,
name: true,
},
},
status: true,
},
}); });
if (!participant) { if (!participant) {
@ -404,21 +427,18 @@ export const PATCH = auth(async (req, { params }) => {
} }
const body = await req.json(); const body = await req.json();
const { status } = body; const parsedBody = patchParticipantSchema.safeParse(body);
if (!parsedBody.success) {
if (!status) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'Status is required' }, {
{ status: 400 }, success: false,
); message: 'Invalid request body',
} errors: parsedBody.error.issues,
},
if (!['accepted', 'declined', 'tentative'].includes(status)) {
return NextResponse.json(
{ success: false, message: 'Invalid status' },
{ status: 400 }, { status: 400 },
); );
} }
const { status } = parsedBody.data;
await prisma.meetingParticipant.update({ await prisma.meetingParticipant.update({
where: { where: {
@ -428,18 +448,12 @@ export const PATCH = auth(async (req, { params }) => {
}, },
}, },
data: { data: {
status: status.toUpperCase(), status,
}, },
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
participant: { participant,
user: {
id: dbUser.id,
name: dbUser.name,
},
status,
},
}); });
}); });

View file

@ -1,6 +1,12 @@
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { userIdSchema } from '@/lib/validation/user';
const postParticipantSchema = z.object({
userId: userIdSchema,
});
/** /**
* @swagger * @swagger
@ -114,20 +120,20 @@ export const GET = auth(async (req, { params }) => {
where: { where: {
meeting_id: eventID, meeting_id: eventID,
}, },
include: { select: {
user: true, user: {
select: {
id: true,
name: true,
},
},
status: true,
}, },
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
participants: participants.map((participant) => ({ participants,
user: {
id: participant.user.id,
name: participant.user.name,
},
status: participant.status,
})),
}); });
}); });
@ -169,14 +175,14 @@ export const GET = auth(async (req, { params }) => {
* participant: * participant:
* $ref: '#/components/schemas/Participant' * $ref: '#/components/schemas/Participant'
* 400: * 400:
* description: Bad request, user ID is required. * description: Bad request due to invalid input data.
* content: * content:
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/ErrorResponse' * $ref: '#/components/schemas/ErrorResponse'
* example: * example:
* success: false * success: false
* message: User ID is required * message: 'Invalid input data'
* 401: * 401:
* description: Not authenticated. * description: Not authenticated.
* content: * content:
@ -255,15 +261,19 @@ export const POST = auth(async (req, { params }) => {
); );
} }
const body = await req.json(); const dataRaw = await req.json();
const { userId } = body; const data = await postParticipantSchema.safeParseAsync(dataRaw);
if (!data.success) {
if (!userId) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'User ID is required' }, {
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 }, { status: 400 },
); );
} }
const { userId } = data.data;
const participantExists = await prisma.meetingParticipant.findFirst({ const participantExists = await prisma.meetingParticipant.findFirst({
where: { where: {

View file

@ -1,6 +1,16 @@
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { NextResponse } from 'next/server'; 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 * @swagger
@ -76,11 +86,32 @@ export const GET = auth(async (req, { params }) => {
where: { where: {
id: eventID, id: eventID,
}, },
include: { select: {
organizer: true, 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: { participants: {
include: { select: {
user: true, user: {
select: {
id: true,
name: true,
},
},
status: true,
}, },
}, },
}, },
@ -96,26 +127,7 @@ export const GET = auth(async (req, { params }) => {
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
event: { event: 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,
})),
},
}, },
{ status: 200 }, { status: 200 },
); );
@ -282,11 +294,14 @@ export const DELETE = auth(async (req, { params }) => {
* event: * event:
* $ref: '#/components/schemas/Event' * $ref: '#/components/schemas/Event'
* 400: * 400:
* description: Invalid input data. * description: Bad request due to invalid input data.
* content: * content:
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/ErrorResponse' * $ref: '#/components/schemas/ErrorResponse'
* example:
* success: false
* message: 'Invalid input data'
* 401: * 401:
* description: Not authenticated. * description: Not authenticated.
* content: * content:
@ -362,75 +377,59 @@ export const PATCH = auth(async (req, { params }) => {
); );
} }
const body = await req.json(); const dataRaw = await req.json();
const data = await patchEventSchema.safeParseAsync(dataRaw);
const { title, description, start_time, end_time, location, status } = body; if (!data.success) {
if (
!title &&
!description &&
!start_time &&
!end_time &&
!location &&
!status
) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'No fields to update' }, {
success: false,
message: 'Invalid input data',
errors: data.error.issues,
},
{ status: 400 }, { status: 400 },
); );
} }
const { title, description, start_time, end_time, location, status } =
const updateData: Record<string, string> = {}; data.data;
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 updatedEvent = await prisma.meeting.update({ const updatedEvent = await prisma.meeting.update({
where: { where: {
id: eventID, id: eventID,
}, },
data: updateData, data: {
include: { title,
organizer: true, 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: { participants: {
include: { select: {
user: true, user: {
select: {
id: true,
name: true,
},
},
status: true,
}, },
}, },
}, },
@ -439,26 +438,7 @@ export const PATCH = auth(async (req, { params }) => {
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
event: { event: updatedEvent,
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,
})),
},
}, },
{ status: 200 }, { status: 200 },
); );

View file

@ -1,6 +1,19 @@
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { NextResponse } from 'next/server'; 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 * @swagger
@ -74,11 +87,31 @@ export const GET = auth(async (req) => {
{ participants: { some: { user_id: dbUser.id } } }, { participants: { some: { user_id: dbUser.id } } },
], ],
}, },
include: { select: {
organizer: true, 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: { participants: {
include: { select: {
user: true, user: {
select: {
id: true,
name: true,
},
},
status: true,
}, },
}, },
}, },
@ -86,23 +119,7 @@ export const GET = auth(async (req) => {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
events: userEvents.map((event) => ({ events: userEvents,
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,
})),
})),
}); });
}); });
@ -150,14 +167,14 @@ export const GET = auth(async (req) => {
* event: * event:
* $ref: '#/components/schemas/Event' * $ref: '#/components/schemas/Event'
* 400: * 400:
* description: Missing required fields. * description: Bad request due to invalid input data.
* content: * content:
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/ErrorResponse' * $ref: '#/components/schemas/ErrorResponse'
* example: * example:
* success: false * success: false
* message: Missing required fields * message: 'Invalid input data'
* 401: * 401:
* description: Not authenticated. * description: Not authenticated.
* content: * content:
@ -189,36 +206,19 @@ export const POST = auth(async (req) => {
{ status: 404 }, { status: 404 },
); );
const body = await req.json(); const dataRaw = await req.json();
const { title, description, start_time, end_time, location } = body; const data = await postEventSchema.safeParseAsync(dataRaw);
if (!data.success) {
if (!title || !start_time || !end_time) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'Missing required fields' }, {
{ status: 400 }, success: false,
); message: 'Invalid request data',
} errors: data.error.issues,
},
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' },
{ status: 400 }, { status: 400 },
); );
} }
const { title, description, start_time, end_time, location } = data.data;
const newEvent = await prisma.meeting.create({ const newEvent = await prisma.meeting.create({
data: { data: {
@ -226,26 +226,41 @@ export const POST = auth(async (req) => {
description, description,
start_time, start_time,
end_time, end_time,
location: location || '', location,
organizer_id: req.auth.user.id, 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({ return NextResponse.json({
success: true, success: true,
event: { event: newEvent,
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: [],
},
}); });
}); });

View file

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

View file

@ -66,6 +66,16 @@ export const GET = auth(async function GET(req, { params }) {
where: { where: {
OR: [{ id: requestedUser }, { name: requestedUser }], 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) if (!dbUser)
@ -76,13 +86,6 @@ export const GET = auth(async function GET(req, { params }) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
user: { user: dbUser,
id: dbUser.id,
name: dbUser.name,
first_name: dbUser.first_name,
last_name: dbUser.last_name,
timezone: dbUser.timezone,
image: dbUser.image,
},
}); });
}); });

View file

@ -6,8 +6,17 @@ import {
userFirstNameSchema, userFirstNameSchema,
userNameSchema, userNameSchema,
userLastNameSchema, userLastNameSchema,
disallowedUsernames,
} from '@/lib/validation/user'; } 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 * @swagger
@ -65,6 +74,17 @@ export const GET = auth(async function GET(req) {
where: { where: {
id: req.auth.user.id, 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) if (!dbUser)
return NextResponse.json( return NextResponse.json(
@ -77,8 +97,6 @@ export const GET = auth(async function GET(req) {
success: true, success: true,
user: { user: {
...dbUser, ...dbUser,
password_hash: undefined, // Exclude sensitive information
email_verified: undefined, // Exclude sensitive information
}, },
}, },
{ status: 200 }, { status: 200 },
@ -171,91 +189,42 @@ export const PATCH = auth(async function PATCH(req) {
{ status: 404 }, { status: 404 },
); );
const body = await req.json(); const dataRaw = await req.json();
const { name, first_name, last_name, email, image, timezone } = body; const data = await patchUserMeSchema.safeParseAsync(dataRaw);
if (!name && !first_name && !last_name && !email && !image && !timezone) if (!data.success) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'No fields to update' }, {
success: false,
message: 'Invalid request data',
errors: data.error.issues,
},
{ status: 400 }, { 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({ const updatedUser = await prisma.user.update({
where: { where: {
id: req.auth.user.id, 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) if (!updatedUser)
return NextResponse.json( return NextResponse.json(
@ -265,11 +234,7 @@ export const PATCH = auth(async function PATCH(req) {
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
user: { user: updatedUser,
...updatedUser,
password_hash: undefined, // Exclude sensitive information
email_verified: undefined, // Exclude sensitive information
},
}, },
{ status: 200 }, { status: 200 },
); );

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
'use server'; 'use server';
import type { z } from 'zod'; import type { z } from 'zod/v4';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { disallowedUsernames, registerSchema } from '@/lib/validation/user'; import { registerSchema } from '@/lib/validation/user';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
export async function registerAction(data: z.infer<typeof registerSchema>) { 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) { if (!result.success) {
return { return {
error: result.error.errors[0].message, error: result.error.issues[0].message,
}; };
} }
const { email, password, firstName, lastName, username } = result.data; 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); const passwordHash = await bcrypt.hash(password, 10);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {

View file

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

View file

@ -1,10 +1,17 @@
import zod from 'zod'; import { prisma } from '@/prisma';
import zod from 'zod/v4';
export const userEmailSchema = zod export const userEmailClientSchema = zod
.string()
.email('Invalid email address') .email('Invalid email address')
.min(3, 'Email is required'); .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 export const userFirstNameSchema = zod
.string() .string()
.min(1, 'First name is required') .min(1, 'First name is required')
@ -15,20 +22,40 @@ export const userLastNameSchema = zod
.min(1, 'Last name is required') .min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long'); .max(32, 'Last name must be at most 32 characters long');
export const userNameSchema = zod export const userNameClientSchema = zod
.string() .string()
.min(3, 'Username is required') .min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long') .max(32, 'Username must be at most 32 characters long')
.regex( .regex(
/^[a-zA-Z0-9_]+$/, /^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores', 'Username can only contain letters, numbers, and underscores',
); )
.refine((val) => !disallowedUsernames.includes(val?.toLowerCase() || ''), {
error: 'Username is not allowed',
});
export const loginSchema = zod.object({ export const userNameSchema = userNameClientSchema.refine(async (val) => {
email: userEmailSchema.or(userNameSchema), 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'), 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 export const registerSchema = zod
.object({ .object({
firstName: userFirstNameSchema, firstName: userFirstNameSchema,
@ -45,7 +72,7 @@ export const registerSchema = zod
username: userNameSchema, username: userNameSchema,
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match', error: 'Passwords do not match',
path: ['confirmPassword'], path: ['confirmPassword'],
}) })
.refine( .refine(
@ -55,7 +82,39 @@ export const registerSchema = zod
!data.password.includes(data.email) && !data.password.includes(data.email) &&
!data.password.includes(data.username), !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', 'Password cannot contain your first name, last name, email, or username',
path: ['password'], path: ['password'],
}, },