feat(users): add admin user management and bootstrap seed

This commit is contained in:
2026-06-04 22:03:13 +02:00
parent 12cbec92a0
commit 5034ec0646
28 changed files with 1318 additions and 11 deletions
+241
View File
@@ -0,0 +1,241 @@
import { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type {
CreateUserFormType,
ResetUserPasswordFormType,
SetUserActiveFormType,
UpdateUserFormType,
} from "@/schemas/user.schema"
import {
countActiveAdmins,
createUser,
getUserByEmail,
getUserById,
getUserByUsername,
resetUserPassword,
setUserActive,
updateUser,
} from "@/services/user.service"
type FieldErrors = Record<string, string[]>
type UserUseCaseResult =
| {
success: true
}
| {
success: false
errors: FieldErrors
}
type ActorInput = {
actorId: string
}
function userError(errors: FieldErrors): UserUseCaseResult {
return {
success: false,
errors,
}
}
function uniqueErrorFor(error: unknown): FieldErrors | null {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== "P2002"
) {
return null
}
const target = Array.isArray(error.meta?.target) ? error.meta.target : []
if (target.includes("username")) {
return { username: ["Username already exists"] }
}
if (target.includes("email")) {
return { email: ["Email already exists"] }
}
return { username: ["Username already exists"] }
}
function isTransactionConflictError(error: unknown) {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2034"
)
}
async function runSerializableUserTransaction<T>(
callback: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
const maxAttempts = 3
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await prisma.$transaction(callback, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
})
} catch (error) {
if (attempt < maxAttempts && isTransactionConflictError(error)) {
continue
}
throw error
}
}
throw new Error("Serializable transaction retry exhausted")
}
async function getAdminAccessLossError(
userId: string,
db: Prisma.TransactionClient,
): Promise<string | null> {
const user = await getUserById(userId, db)
if (!user) {
return "User not found"
}
if (
user.role === "ADMIN" &&
user.isActive &&
(await countActiveAdmins(db)) <= 1
) {
return "Cannot remove access from the last active administrator"
}
return null
}
export async function createUserUseCase(
input: CreateUserFormType,
): Promise<UserUseCaseResult> {
const { username, email } = input
try {
return await prisma.$transaction(async (tx) => {
if (await getUserByUsername(username, tx)) {
return userError({ username: ["Username already exists"] })
}
if (await getUserByEmail(email, tx)) {
return userError({ email: ["Email already exists"] })
}
await createUser({ data: input }, tx)
return {
success: true,
}
})
} catch (error) {
const errors = uniqueErrorFor(error)
if (errors) {
return userError(errors)
}
throw error
}
}
export async function updateUserUseCase(
input: UpdateUserFormType & ActorInput,
): Promise<UserUseCaseResult> {
const { actorId, id, username, email, role, isActive, ...data } = input
try {
return await runSerializableUserTransaction(async (tx) => {
const existingUser = await getUserById(id, tx)
if (!existingUser) {
return userError({ id: ["User not found"] })
}
if (actorId === id && (!isActive || role !== "ADMIN")) {
return userError({
id: ["You cannot remove your own administrator access"],
})
}
if ((existingUser.role === "ADMIN" && role !== "ADMIN") || !isActive) {
const error = await getAdminAccessLossError(id, tx)
if (error) return userError({ id: [error] })
}
const existingUsername = await getUserByUsername(username, tx)
if (existingUsername && existingUsername.id !== id) {
return userError({ username: ["Username already exists"] })
}
const existingEmail = await getUserByEmail(email, tx)
if (existingEmail && existingEmail.id !== id) {
return userError({ email: ["Email already exists"] })
}
await updateUser(id, { ...data, username, email, role, isActive }, tx)
return {
success: true,
}
})
} catch (error) {
const errors = uniqueErrorFor(error)
if (errors) {
return userError(errors)
}
throw error
}
}
export async function setUserActiveUseCase(
input: SetUserActiveFormType & ActorInput,
): Promise<UserUseCaseResult> {
const { actorId, id, isActive } = input
return runSerializableUserTransaction(async (tx) => {
const existingUser = await getUserById(id, tx)
if (!existingUser) {
return userError({ id: ["User not found"] })
}
if (actorId === id && !isActive) {
return userError({ id: ["You cannot deactivate your own user"] })
}
if (!isActive) {
const error = await getAdminAccessLossError(id, tx)
if (error) return userError({ id: [error] })
}
await setUserActive(id, isActive, tx)
return {
success: true,
}
})
}
export async function resetUserPasswordUseCase(
input: ResetUserPasswordFormType,
): Promise<UserUseCaseResult> {
const { id, password } = input
return prisma.$transaction(async (tx) => {
if (!(await getUserById(id, tx))) {
return userError({ id: ["User not found"] })
}
await resetUserPassword(id, password, tx)
return {
success: true,
}
})
}