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
+8 -1
View File
@@ -13,4 +13,11 @@ NODE_ENV=production
DEMO_MODE=false
DOMAIN=localhost
AUTH_TRUST_HOST="http://localhost"
AUTH_SECRET=your_secret_key_here
AUTH_SECRET=your_secret_key_here
# ADMIN BOOTSTRAP
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@localhost
ADMIN_NAME=Administrator
ADMIN_PASSWORD=change-me
+1 -1
View File
@@ -39,4 +39,4 @@ COPY --from=builder /app/.next/static ./.next/static
EXPOSE ${PORT}
CMD ["bun", "run", "start"]
CMD ["sh", "-c", "bun run db:deploy && bun run db:seed && bun run start"]
+5
View File
@@ -37,6 +37,11 @@ services:
DOMAIN: ${DOMAIN}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
AUTH_SECRET: ${AUTH_SECRET}
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-"true"}
ADMIN_USERNAME: ${ADMIN_USERNAME:-"admin"}
ADMIN_EMAIL: ${ADMIN_EMAIL:-"admin@localhost"}
ADMIN_NAME: ${ADMIN_NAME:-"Administrator"}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
depends_on:
- db
+2 -1
View File
@@ -16,6 +16,7 @@
"db:migrate:reset": "bunx prisma migrate reset",
"db:deploy": "bunx prisma migrate deploy",
"db:generate": "bunx prisma generate",
"db:seed": "bunx --bun prisma db seed",
"db:studio": "bunx prisma studio"
},
"dependencies": {
@@ -68,4 +69,4 @@
"sharp",
"unrs-resolver"
]
}
}
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "bun ./prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
+80
View File
@@ -0,0 +1,80 @@
import { fileURLToPath } from "node:url"
import { getPasswordHash } from "@/lib/security"
import prisma from "../src/lib/prisma"
type BootstrapAdminInput = {
username: string
email: string
name: string
password: string
}
function getBootstrapAdminInput(): BootstrapAdminInput {
const isProduction = process.env.NODE_ENV === "production"
const username = process.env.ADMIN_USERNAME ?? "admin"
const email = process.env.ADMIN_EMAIL ?? "admin@localhost"
const name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD
if (isProduction && !password) {
throw new Error("ADMIN_PASSWORD is required to bootstrap an admin user")
}
return {
username,
email,
name,
password: password ?? "admin",
}
}
export async function bootstrapAdmin(client: typeof prisma) {
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false"
const existingAdmin = await client.user.findFirst({
where: {
role: "ADMIN",
isActive: true,
},
select: {
id: true,
},
})
if (existingAdmin || !enabled) return
const admin = getBootstrapAdminInput()
await client.user.upsert({
where: {
email: admin.email,
},
update: {
role: "ADMIN",
isActive: true,
},
create: {
name: admin.name,
username: admin.username,
email: admin.email,
role: "ADMIN",
password: await getPasswordHash(admin.password),
isActive: true,
},
})
}
async function main() {
try {
await bootstrapAdmin(prisma)
} finally {
await prisma.$disconnect()
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}
+17
View File
@@ -0,0 +1,17 @@
import prisma from "../src/lib/prisma"
import { bootstrapAdmin } from "./bootstrap-admin"
async function main() {
await bootstrapAdmin(prisma)
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
+144
View File
@@ -0,0 +1,144 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import {
type CreateUserFormType,
createUserSchema,
type ResetUserPasswordFormType,
resetUserPasswordSchema,
type SetUserActiveFormType,
setUserActiveSchema,
type UpdateUserFormType,
updateUserSchema,
} from "@/schemas/user.schema"
import { requireRole } from "@/services/auth.service"
import {
createUserUseCase,
resetUserPasswordUseCase,
setUserActiveUseCase,
updateUserUseCase,
} from "@/use-cases/user.use-cases"
const USERS_PATH = "/admin/users"
export async function createUserAction(formData: CreateUserFormType) {
await requireRole("ADMIN")
const validatedFields = createUserSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const result = await createUserUseCase(validatedFields.data)
if (!result.success) {
return result
}
revalidatePath(USERS_PATH)
return { success: true, message: "User created successfully" }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to create user" }
}
}
export async function updateUserAction(formData: UpdateUserFormType) {
const session = await requireRole("ADMIN")
const validatedFields = updateUserSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updateUserUseCase({
...validatedFields.data,
actorId: session.user.id,
})
if (!result.success) {
return result
}
revalidatePath(USERS_PATH)
return { success: true, message: "User updated successfully" }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to update user" }
}
}
export async function setUserActiveAction(formData: SetUserActiveFormType) {
const session = await requireRole("ADMIN")
const validatedFields = setUserActiveSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await setUserActiveUseCase({
...validatedFields.data,
actorId: session.user.id,
})
if (!result.success) {
return result
}
revalidatePath(USERS_PATH)
return { success: true, message: "User status updated successfully" }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to update user status" }
}
}
export async function resetUserPasswordAction(
formData: ResetUserPasswordFormType,
) {
await requireRole("ADMIN")
const validatedFields = resetUserPasswordSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const result = await resetUserPasswordUseCase(validatedFields.data)
if (!result.success) {
return result
}
revalidatePath(USERS_PATH)
return { success: true, message: "Password reset successfully" }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to reset password" }
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react"
import { requireRole } from "@/services/auth.service"
export default async function AdminLayout({
children,
}: {
children: ReactNode
}) {
await requireRole("ADMIN")
return children
}
@@ -0,0 +1,32 @@
import { notFound } from "next/navigation"
import { getUserProfileById } from "@/services/user.service"
import EditUserForm from "../../_components/edit.user.form"
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
export default async function EditUserPage({
params,
}: {
params: Promise<{ userId: string }>
}) {
const { userId } = await params
const user = await getUserProfileById(userId)
if (!user) {
notFound()
}
return (
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit User</h1>
</div>
<EditUserForm user={user} />
<section className="flex flex-col gap-4 border-t pt-6">
<h2 className="text-xl font-semibold">Reset password</h2>
<ResetUserPasswordForm userId={user.id} />
</section>
</div>
)
}
@@ -0,0 +1,141 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateUserAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type UpdateUserFormType,
updateUserSchema,
} from "@/schemas/user.schema"
import type { UserWithoutPassword } from "@/services/user.service"
export default function EditUserForm({ user }: { user: UserWithoutPassword }) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateUserFormType>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
isActive: user.isActive,
},
})
const onSubmit = async (formData: UpdateUserFormType) => {
const response = await updateUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UpdateUserFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/admin/users")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<UserTextInput
error={errors.name?.message}
id="name"
label="Name"
placeholder="Full name"
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label="Username"
placeholder="username"
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label="Email"
placeholder="user@example.com"
register={register("email")}
type="email"
/>
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
Role
</label>
<select
id="role"
{...register("role")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">Admin</option>
<option value="MANAGER">Manager</option>
<option value="STAFF">Staff</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("isActive")} />
Active user
</label>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update User
</SubmitButton>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
@@ -0,0 +1,145 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createUserAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type CreateUserFormType,
createUserSchema,
} from "@/schemas/user.schema"
export default function NewUserForm() {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateUserFormType>({
resolver: zodResolver(createUserSchema),
defaultValues: {
role: "STAFF",
isActive: true,
},
})
const onSubmit = async (formData: CreateUserFormType) => {
const response = await createUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof CreateUserFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/admin/users")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<UserTextInput
error={errors.name?.message}
id="name"
label="Name"
placeholder="Full name"
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label="Username"
placeholder="username"
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label="Email"
placeholder="user@example.com"
register={register("email")}
type="email"
/>
<UserTextInput
error={errors.password?.message}
id="password"
label="Password"
placeholder="Minimum 8 characters"
register={register("password")}
type="password"
/>
<RoleSelect register={register("role")} />
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create User
</SubmitButton>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({ register }: { register: UseFormRegisterReturn }) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
Role
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">Admin</option>
<option value="MANAGER">Manager</option>
<option value="STAFF">Staff</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
)
}
@@ -0,0 +1,75 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { resetUserPasswordAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type ResetUserPasswordFormType,
resetUserPasswordSchema,
} from "@/schemas/user.schema"
export default function ResetUserPasswordForm({ userId }: { userId: string }) {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<ResetUserPasswordFormType>({
resolver: zodResolver(resetUserPasswordSchema),
defaultValues: {
id: userId,
},
})
const onSubmit = async (formData: ResetUserPasswordFormType) => {
const response = await resetUserPasswordAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof ResetUserPasswordFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
reset({ id: userId, password: "" })
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="password" className="mb-2 block text-lg">
New password
</label>
<input
type="password"
id="password"
placeholder="Minimum 8 characters"
{...register("password")}
className={`w-full rounded-lg border px-4 py-2 ${errors.password ? "border-error" : ""}`}
/>
{errors.password && (
<p className="text-error">{errors.password.message}</p>
)}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Reset Password
</SubmitButton>
</form>
)
}
@@ -0,0 +1,12 @@
import NewUserForm from "../_components/new.user.form"
export default function NewUserPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New User</h1>
</div>
<NewUserForm />
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getUsers } from "@/services/user.service"
export default async function UsersPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: users, totalPages } = await getUsers({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Users"
link="/admin/users/new"
search={search}
data={users}
/>
{users.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No users found.
</div>
</div>
)}
{users.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Username
</th>
<th scope="col" className="p-4">
Email
</th>
<th scope="col" className="p-4">
Role
</th>
<th scope="col" className="p-4">
Status
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4">{user.name}</td>
<td className="p-4">{user.username}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">{user.role}</td>
<td className="p-4">
{user.isActive ? "Active" : "Inactive"}
</td>
<td className="p-4">
<Link href={`/admin/users/${user.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod"
export const userRoleSchema = z.enum(["ADMIN", "MANAGER", "STAFF", "VIEWER"])
const passwordSchema = z
.string()
.min(8, { error: "Password must be at least 8 characters" })
export const createUserSchema = z.object({
username: z.string().trim().min(1, { error: "Username is required" }),
name: z.string().trim().min(1, { error: "Name is required" }),
email: z.email({ error: "Invalid email" }),
password: passwordSchema,
role: userRoleSchema,
isActive: z.boolean(),
})
export const updateUserSchema = z.object({
id: z.string().min(1, { error: "User id is required" }),
username: z.string().trim().min(1, { error: "Username is required" }),
name: z.string().trim().min(1, { error: "Name is required" }),
email: z.email({ error: "Invalid email" }),
role: userRoleSchema,
isActive: z.boolean(),
})
export const setUserActiveSchema = z.object({
id: z.string().min(1, { error: "User id is required" }),
isActive: z.boolean(),
})
export const resetUserPasswordSchema = z.object({
id: z.string().min(1, { error: "User id is required" }),
password: passwordSchema,
})
export type CreateUserFormType = z.infer<typeof createUserSchema>
export type UpdateUserFormType = z.infer<typeof updateUserSchema>
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema>
+150 -8
View File
@@ -1,10 +1,48 @@
import { UserRole } from "@/generated/prisma/client"
import { type Prisma, UserRole } from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security"
import type { User } from "@/lib/types/user"
import type { PaginatedResult } from "@/types"
import type { User } from "@/types/user"
export async function createUser({ data }: { data: User }) {
const newUser = await prisma.user.create({
const userWithoutPasswordSelect = {
id: true,
username: true,
name: true,
email: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.UserSelect
export type UserWithoutPassword = Prisma.UserGetPayload<{
select: typeof userWithoutPasswordSelect
}>
type CreateUserData = Pick<User, "username" | "name" | "email" | "password"> & {
role?: UserRole
isActive?: boolean
}
type UpdateUserData = Partial<Pick<User, "username" | "name" | "email">> & {
role?: UserRole
isActive?: boolean
}
type GetUsersParams = {
page?: number
pageSize?: number
search?: string
role?: UserRole
isActive?: boolean
}
export async function createUser(
{ data }: { data: CreateUserData },
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
const newUser = await db.user.create({
data: {
username: data.username,
name: data.name,
@@ -13,14 +51,118 @@ export async function createUser({ data }: { data: User }) {
role: data.role ?? UserRole.STAFF,
isActive: data.isActive ?? true,
},
select: userWithoutPasswordSelect,
})
return newUser
}
export async function getUserById(id: string) {
return await prisma.user.findUnique({ where: { id } })
export async function getUserById(
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { id } })
}
export async function getUserByUsername(username: string) {
return await prisma.user.findUnique({ where: { username } })
export async function getUserProfileById(
id: string,
): Promise<UserWithoutPassword | null> {
return prisma.user.findUnique({
where: { id },
select: userWithoutPasswordSelect,
})
}
export async function getUserByUsername(
username: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { username } })
}
export async function getUserByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { email } })
}
export async function getUsers({
page,
pageSize,
search,
role,
isActive,
}: GetUsersParams = {}): Promise<PaginatedResult<UserWithoutPassword>> {
return paginate<UserWithoutPassword>({
model: prisma.user,
page,
pageSize,
where: {
...(typeof isActive === "boolean" ? { isActive } : {}),
...(role ? { role } : {}),
...(search
? {
OR: [
{ username: { contains: search, mode: "insensitive" } },
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}
: {}),
},
orderBy: { createdAt: "desc" },
select: userWithoutPasswordSelect,
})
}
export async function updateUser(
id: string,
data: UpdateUserData,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> {
return db.user.update({
where: { id },
data,
select: userWithoutPasswordSelect,
})
}
export async function updateUserRole(
id: string,
role: UserRole,
): Promise<UserWithoutPassword> {
return updateUser(id, { role })
}
export async function setUserActive(
id: string,
isActive: boolean,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> {
return updateUser(id, { isActive }, db)
}
export async function resetUserPassword(
id: string,
password: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> {
return db.user.update({
where: { id },
data: {
password: await getPasswordHash(password),
},
select: userWithoutPasswordSelect,
})
}
export async function countActiveAdmins(
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<number> {
return db.user.count({
where: {
role: UserRole.ADMIN,
isActive: true,
},
})
}
+30
View File
@@ -0,0 +1,30 @@
import type {
Assignment,
Asset as PrismaAsset,
ItemStatus as PrismaItemStatus,
} from "@/generated/prisma/client"
export type Asset = PrismaAsset
export type ItemStatus = PrismaItemStatus
export type UpdateAssetStatus = PrismaItemStatus
export type AssetWithAssignment = Asset & {
assignment: Assignment | null
}
export type AssetWithItemAndCategory = {
id: string
serialNumber: string
deliveryNote?: string | null
status: ItemStatus
item: {
id: string
name: string
category: {
id: string
name: string
}
} | null
}
+16
View File
@@ -0,0 +1,16 @@
import type { Assignment as PrismaAssignment } from "@/generated/prisma/client"
import type { Asset } from "./asset"
import type { Item } from "./item"
import type { Recipient } from "./recipient"
export type Assignment = PrismaAssignment
export type AssignmentSummary = Pick<Assignment, "id" | "quantity">
export type AssignmentWithRecipientItemAsset = Assignment & {
returnDate: Date | null
recipient: Recipient | null
item: Item | null
asset: Asset | null
}
+11
View File
@@ -0,0 +1,11 @@
import type { Category as PrismaCategory } from "@/generated/prisma/client"
export type Category = PrismaCategory
export type CategorySummary = Pick<Category, "id" | "name">
export type CategoryWithItemsCount = CategorySummary & {
_count: {
items: number
}
}
+12
View File
@@ -0,0 +1,12 @@
export interface ImportItem {
name: string
stock?: number
serialNumber?: string
categoryId?: string
category?: string
deliveryNote?: string
assigned?: boolean
username?: string
firstName?: string
lastName?: string
}
+9
View File
@@ -0,0 +1,9 @@
export * from "./asset"
export * from "./assignment"
export * from "./category"
export * from "./import"
export * from "./item"
export * from "./movement"
export * from "./paginate"
export * from "./recipient"
export * from "./user"
+21
View File
@@ -0,0 +1,21 @@
import type { Category, Item as PrismaItem } from "@/generated/prisma/client"
export type Item = PrismaItem
export type ItemSummary = Pick<Item, "id" | "name" | "stock"> & {
category: Pick<Category, "id" | "name">
}
export type ItemWithoutStock = Pick<Item, "id" | "name">
export type ItemWithAssetCount = ItemSummary & {
_count: {
assets: number
}
}
export type ItemWithAssetAndMovementCount = ItemWithAssetCount & {
_count: {
movements: number
}
}
+3
View File
@@ -0,0 +1,3 @@
import type { Movement as PrismaMovement } from "@/generated/prisma/client"
export type Movement = PrismaMovement
+7
View File
@@ -0,0 +1,7 @@
export type PaginatedResult<T> = {
data: T[]
totalItems: number
totalPages: number
currentPage: number
pageSize: number
}
+3
View File
@@ -0,0 +1,3 @@
import type { Recipient as PrismaRecipient } from "@/generated/prisma/client"
export type Recipient = PrismaRecipient
+3
View File
@@ -0,0 +1,3 @@
import type { User as PrismaUser } from "@/generated/prisma/client"
export type User = PrismaUser
+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,
}
})
}