feat(users): add admin user management and bootstrap seed
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user