Merge pull request 'feat(api): implement missing user update and delete endpoints' (#102)
All checks were successful
container-scan / Container Scan (push) Successful in 4m58s
docker-build / docker (push) Successful in 7m17s

Reviewed-on: #102
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
This commit is contained in:
Maximilian Liebmann 2025-06-23 09:37:13 +00:00
commit c98a72f2f1
7 changed files with 3973 additions and 4 deletions

View file

@ -0,0 +1,122 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import { updateUserPasswordServerSchema } from '../validation';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { FullUserResponseSchema } from '../../validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import bcrypt from 'bcryptjs';
export const PATCH = auth(async function PATCH(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const body = await req.json();
const parsedBody = updateUserPasswordServerSchema.safeParse(body);
if (!parsedBody.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: parsedBody.error.issues,
},
{ status: 400 },
);
const { current_password, new_password } = parsedBody.data;
const dbUser = await prisma.user.findUnique({
where: {
id: authCheck.user.id,
},
include: {
accounts: true,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
if (!dbUser.password_hash)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User does not have a password set',
},
{ status: 400 },
);
if (
dbUser.accounts.length === 0 ||
dbUser.accounts[0].provider !== 'credentials'
)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Credentials login is not enabled for this user',
},
{ status: 400 },
);
const isCurrentPasswordValid = await bcrypt.compare(
current_password,
dbUser.password_hash || '',
);
if (!isCurrentPasswordValid)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Current password is incorrect',
},
{ status: 401 },
);
const hashedNewPassword = await bcrypt.hash(new_password, 10);
const updatedUser = await prisma.user.update({
where: {
id: dbUser.id,
},
data: {
password_hash: hashedNewPassword,
},
select: {
id: true,
name: true,
first_name: true,
last_name: true,
email: true,
image: true,
timezone: true,
created_at: true,
updated_at: true,
},
});
return returnZodTypeCheckedResponse(FullUserResponseSchema, {
success: true,
user: updatedUser,
});
});

View file

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

View file

@ -8,6 +8,7 @@ import {
import { FullUserResponseSchema } from '../validation'; import { FullUserResponseSchema } from '../validation';
import { import {
ErrorResponseSchema, ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema, ZodErrorResponseSchema,
} from '@/app/api/validation'; } from '@/app/api/validation';
@ -117,3 +118,43 @@ export const PATCH = auth(async function PATCH(req) {
{ status: 200 }, { status: 200 },
); );
}); });
export const DELETE = auth(async function DELETE(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,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
await prisma.user.delete({
where: {
id: authCheck.user.id,
},
});
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{
success: true,
message: 'User deleted successfully',
},
{ status: 200 },
);
});

View file

@ -7,6 +7,7 @@ import {
serverReturnedDataValidationErrorResponse, serverReturnedDataValidationErrorResponse,
userNotFoundResponse, userNotFoundResponse,
} from '@/lib/defaultApiResponses'; } from '@/lib/defaultApiResponses';
import { SuccessResponseSchema } from '../../validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) { export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({ registry.registerPath({
@ -60,4 +61,24 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
}, },
tags: ['User'], tags: ['User'],
}); });
registry.registerPath({
method: 'delete',
path: '/api/user/me',
description: 'Delete the currently authenticated user',
responses: {
200: {
description: 'User deleted successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
} }

View file

@ -4,6 +4,8 @@ import {
lastNameSchema, lastNameSchema,
newUserEmailServerSchema, newUserEmailServerSchema,
newUserNameServerSchema, newUserNameServerSchema,
passwordSchema,
timezoneSchema,
} from '@/app/api/user/validation'; } from '@/app/api/user/validation';
// ---------------------------------------- // ----------------------------------------
@ -16,6 +18,16 @@ export const updateUserServerSchema = zod.object({
first_name: firstNameSchema.optional(), first_name: firstNameSchema.optional(),
last_name: lastNameSchema.optional(), last_name: lastNameSchema.optional(),
email: newUserEmailServerSchema.optional(), email: newUserEmailServerSchema.optional(),
image: zod.string().optional(), image: zod.url().optional(),
timezone: zod.string().optional(), timezone: timezoneSchema.optional(),
}); });
export const updateUserPasswordServerSchema = zod
.object({
current_password: zod.string().min(1, 'Current password is required'),
new_password: passwordSchema,
confirm_new_password: passwordSchema,
})
.refine((data) => data.new_password === data.confirm_new_password, {
message: 'New password and confirm new password must match',
});

View file

@ -1,6 +1,7 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import zod from 'zod/v4'; import zod from 'zod/v4';
import { allTimeZones } from '@/lib/timezones';
extendZodWithOpenApi(zod); extendZodWithOpenApi(zod);
@ -107,6 +108,15 @@ export const passwordSchema = zod
'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
); );
// ----------------------------------------
//
// Timezone Validation
//
// ----------------------------------------
export const timezoneSchema = zod.enum(allTimeZones).openapi('Timezone', {
description: 'Valid timezone from the list of supported timezones',
});
// ---------------------------------------- // ----------------------------------------
// //
// User Schema Validation (for API responses) // User Schema Validation (for API responses)
@ -119,8 +129,11 @@ export const FullUserSchema = zod
first_name: zod.string().nullish(), first_name: zod.string().nullish(),
last_name: zod.string().nullish(), last_name: zod.string().nullish(),
email: zod.email(), email: zod.email(),
image: zod.string().nullish(), image: zod.url().nullish(),
timezone: zod.string(), timezone: zod
.string()
.refine((i) => (allTimeZones as string[]).includes(i))
.nullish(),
created_at: zod.date(), created_at: zod.date(),
updated_at: zod.date(), updated_at: zod.date(),
}) })

3717
src/lib/timezones.ts Normal file

File diff suppressed because it is too large Load diff