diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts new file mode 100644 index 0000000..5ba9792 --- /dev/null +++ b/src/app/api/user/me/route.ts @@ -0,0 +1,119 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { updateUserServerSchema } from './validation'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { FullUserResponseSchema } from '../validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + select: { + id: true, + name: true, + first_name: true, + last_name: true, + email: true, + image: true, + timezone: true, + created_at: true, + updated_at: true, + }, + }); + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + + return returnZodTypeCheckedResponse(FullUserResponseSchema, { + success: true, + user: dbUser, + }); +}); + +export const PATCH = auth(async function PATCH(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw = await req.json(); + const data = await updateUserServerSchema.safeParseAsync(dataRaw); + if (!data.success) { + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + } + if (Object.keys(data.data).length === 0) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { success: false, message: 'No data to update' }, + { status: 400 }, + ); + } + + const updatedUser = await prisma.user.update({ + where: { + id: authCheck.user.id, + }, + data: data.data, + select: { + id: true, + name: true, + first_name: true, + last_name: true, + email: true, + image: true, + timezone: true, + created_at: true, + updated_at: true, + }, + }); + if (!updatedUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + return returnZodTypeCheckedResponse( + FullUserResponseSchema, + { + success: true, + user: updatedUser, + }, + { status: 200 }, + ); +}); diff --git a/src/app/api/user/me/swagger.ts b/src/app/api/user/me/swagger.ts new file mode 100644 index 0000000..e0a36a1 --- /dev/null +++ b/src/app/api/user/me/swagger.ts @@ -0,0 +1,63 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { FullUserResponseSchema } from '../validation'; +import { updateUserServerSchema } from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/user/me', + description: 'Get the currently authenticated user', + responses: { + 200: { + description: 'User information retrieved successfully', + content: { + 'application/json': { + schema: FullUserResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); + + registry.registerPath({ + method: 'patch', + path: '/api/user/me', + description: 'Update the currently authenticated user', + request: { + body: { + description: 'User information to update', + required: true, + content: { + 'application/json': { + schema: updateUserServerSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User information updated successfully', + content: { + 'application/json': { + schema: FullUserResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); +} diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts new file mode 100644 index 0000000..49c6219 --- /dev/null +++ b/src/app/api/user/me/validation.ts @@ -0,0 +1,21 @@ +import zod from 'zod/v4'; +import { + firstNameSchema, + lastNameSchema, + newUserEmailServerSchema, + newUserNameServerSchema, +} from '@/app/api/user/validation'; + +// ---------------------------------------- +// +// Update User Validation +// +// ---------------------------------------- +export const updateUserServerSchema = zod.object({ + name: newUserNameServerSchema.optional(), + first_name: firstNameSchema.optional(), + last_name: lastNameSchema.optional(), + email: newUserEmailServerSchema.optional(), + image: zod.string().optional(), + timezone: zod.string().optional(), +}); diff --git a/src/app/api/user/validation.ts b/src/app/api/user/validation.ts new file mode 100644 index 0000000..79b1e7e --- /dev/null +++ b/src/app/api/user/validation.ts @@ -0,0 +1,149 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { prisma } from '@/prisma'; +import zod from 'zod/v4'; + +extendZodWithOpenApi(zod); + +// ---------------------------------------- +// +// Email Validation +// +// ---------------------------------------- +export const emailSchema = zod + .email('Invalid email address') + .min(3, 'Email is required'); + +export const newUserEmailServerSchema = emailSchema.refine(async (val) => { + const existingUser = await prisma.user.findUnique({ + where: { email: val }, + }); + return !existingUser; +}, 'Email in use by another account'); + +export const existingUserEmailServerSchema = emailSchema.refine(async (val) => { + const existingUser = await prisma.user.findUnique({ + where: { email: val }, + }); + return !!existingUser; +}, 'Email not found'); + +// ---------------------------------------- +// +// First Name Validation +// +// ---------------------------------------- +export const firstNameSchema = zod + .string() + .min(1, 'First name is required') + .max(32, 'First name must be at most 32 characters long'); + +// ---------------------------------------- +// +// Last Name Validation +// +// ---------------------------------------- +export const lastNameSchema = zod + .string() + .min(1, 'Last name is required') + .max(32, 'Last name must be at most 32 characters long'); + +// ---------------------------------------- +// +// Username Validation +// +// ---------------------------------------- +export const userNameSchema = zod + .string() + .min(3, 'Username is required') + .max(32, 'Username must be at most 32 characters long') + .regex( + /^[a-zA-Z0-9_]+$/, + 'Username can only contain letters, numbers, and underscores', + ); + +export const newUserNameServerSchema = userNameSchema.refine(async (val) => { + const existingUser = await prisma.user.findUnique({ + where: { name: val }, + }); + return !existingUser; +}, 'Username in use by another account'); + +export const existingUserNameServerSchema = userNameSchema.refine( + async (val) => { + const existingUser = await prisma.user.findUnique({ + where: { name: val }, + }); + return !!existingUser; + }, + 'Username not found', +); + +// ---------------------------------------- +// +// User ID Validation +// +// ---------------------------------------- +export const existingUserIdServerSchema = zod + .string() + .min(1, 'User ID is required') + .refine(async (val) => { + const user = await prisma.user.findUnique({ + where: { id: val }, + }); + return !!user; + }, 'User not found'); + +// ---------------------------------------- +// +// Password Validation +// +// ---------------------------------------- +export const passwordSchema = zod + .string() + .min(8, 'Password must be at least 8 characters long') + .max(128, 'Password must be at most 128 characters long') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={}\[\]:;"'<>,.?\/\\-]).{8,}$/, + 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + ); + +// ---------------------------------------- +// +// User Schema Validation (for API responses) +// +// ---------------------------------------- +export const FullUserSchema = zod + .object({ + id: zod.string(), + name: zod.string(), + first_name: zod.string().nullish(), + last_name: zod.string().nullish(), + email: zod.email(), + image: zod.string().nullish(), + timezone: zod.string(), + created_at: zod.date(), + updated_at: zod.date(), + }) + .openapi('FullUser', { + description: 'Full user information including all fields', + }); + +export const PublicUserSchema = FullUserSchema.pick({ + id: true, + name: true, + first_name: true, + last_name: true, + image: true, + timezone: true, +}).openapi('PublicUser', { + description: 'Public user information excluding sensitive data', +}); + +export const FullUserResponseSchema = zod.object({ + success: zod.boolean(), + user: FullUserSchema, +}); +export const PublicUserResponseSchema = zod.object({ + success: zod.boolean(), + user: PublicUserSchema, +}); diff --git a/src/app/api/validation.ts b/src/app/api/validation.ts new file mode 100644 index 0000000..38b95bd --- /dev/null +++ b/src/app/api/validation.ts @@ -0,0 +1,87 @@ +import { registry } from '@/lib/swagger'; +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; + +extendZodWithOpenApi(zod); + +export const ErrorResponseSchema = zod + .object({ + success: zod.boolean(), + message: zod.string(), + }) + .openapi('ErrorResponseSchema', { + description: 'Error response schema', + example: { + success: false, + message: 'An error occurred', + }, + }); + +export const ZodErrorResponseSchema = ErrorResponseSchema.extend({ + errors: zod.array( + zod.object({ + expected: zod.string().optional(), + code: zod.string(), + path: zod.array( + zod + .string() + .or(zod.number()) + .or( + zod.symbol().openapi({ + type: 'string', + }), + ), + ), + message: zod.string(), + }), + ), +}).openapi('ZodErrorResponseSchema', { + description: 'Zod error response schema', + example: { + success: false, + message: 'Invalid request data', + errors: [ + { + expected: 'string', + code: 'invalid_type', + path: ['first_name'], + message: 'Invalid input: expected string, received number', + }, + ], + }, +}); + +export const SuccessResponseSchema = zod + .object({ + success: zod.boolean(), + message: zod.string().optional(), + }) + .openapi('SuccessResponseSchema', { + description: 'Success response schema', + example: { + success: true, + message: 'Operation completed successfully', + }, + }); + +export const UserIdParamSchema = registry.registerParameter( + 'UserIdOrNameParam', + zod.string().openapi({ + param: { + name: 'user', + in: 'path', + }, + example: '12345', + }), +); + +export const EventIdParamSchema = registry.registerParameter( + 'EventIdParam', + zod.string().openapi({ + param: { + name: 'eventID', + in: 'path', + }, + example: '67890', + }), +);