feat(people): align people, users, and categories with active inventory records

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent c123170a46
commit 7b8a415c6a
9 changed files with 165 additions and 104 deletions
+18 -5
View File
@@ -3,9 +3,14 @@ import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import type { Category, CategorySummary, CategoryWithItemsCount } from "@/types"
const activeRecordWhere = {
deletedAt: null,
} as const
export const CategoryService = {
findAll: async (): Promise<CategorySummary[]> => {
return prisma.category.findMany({
where: activeRecordWhere,
select: {
id: true,
name: true,
@@ -15,6 +20,7 @@ export const CategoryService = {
findAllWithItemsCount: async (): Promise<CategoryWithItemsCount[]> => {
return prisma.category.findMany({
where: activeRecordWhere,
select: {
id: true,
name: true,
@@ -38,6 +44,7 @@ export const CategoryService = {
page,
pageSize,
where: {
...activeRecordWhere,
...(search
? {
name: { contains: search, mode: "insensitive" },
@@ -58,7 +65,10 @@ export const CategoryService = {
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Category | null> => {
return db.category.findFirst({
where: { name: { equals: name, mode: "insensitive" } },
where: {
name: { equals: name, mode: "insensitive" },
...activeRecordWhere,
},
})
},
@@ -66,15 +76,15 @@ export const CategoryService = {
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Category | null> => {
return db.category.findUnique({ where: { id } })
return db.category.findFirst({ where: { id, ...activeRecordWhere } })
},
findByIdWithItemsCount: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<CategoryWithItemsCount | null> => {
return db.category.findUnique({
where: { id },
return db.category.findFirst({
where: { id, ...activeRecordWhere },
select: {
id: true,
name: true,
@@ -102,6 +112,9 @@ export const CategoryService = {
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Category> => {
return db.category.delete({ where: { id } })
return db.category.update({
where: { id },
data: { deletedAt: new Date() },
})
},
}
+11 -5
View File
@@ -18,6 +18,10 @@ const personWithUserSelect = {
},
} as const
const activeRecordWhere = {
deletedAt: null,
} as const
export type PersonWithUser = Prisma.PersonGetPayload<
typeof personWithUserSelect
>
@@ -25,6 +29,7 @@ export type PersonWithUser = Prisma.PersonGetPayload<
export const PersonService = {
findAll: async (): Promise<Person[]> => {
return prisma.person.findMany({
where: activeRecordWhere,
orderBy: {
firstName: "asc",
},
@@ -45,6 +50,7 @@ export const PersonService = {
pageSize,
include: personWithUserSelect.include,
where: {
...activeRecordWhere,
...(search
? {
OR: [
@@ -58,22 +64,22 @@ export const PersonService = {
})
},
findAllPeopleCount: async (): Promise<number> => {
return prisma.person.count()
return prisma.person.count({ where: activeRecordWhere })
},
findById: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Person | null> => {
return db.person.findUnique({ where: { id } })
return db.person.findFirst({ where: { id, ...activeRecordWhere } })
},
findByIdWithUser: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<PersonWithUser | null> => {
return db.person.findUnique({
where: { id },
return db.person.findFirst({
where: { id, ...activeRecordWhere },
include: personWithUserSelect.include,
})
},
@@ -82,7 +88,7 @@ export const PersonService = {
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Person | null> => {
return db.person.findFirst({ where: { email } })
return db.person.findFirst({ where: { email, ...activeRecordWhere } })
},
create: async (
+14 -8
View File
@@ -43,6 +43,10 @@ type GetUsersParams = {
isActive?: boolean
}
const activeRecordWhere = {
deletedAt: null,
} as const
function userStatusFromActive(isActive: boolean | undefined) {
if (typeof isActive !== "boolean") return undefined
return isActive ? UserStatus.ACTIVE : UserStatus.DISABLED
@@ -85,8 +89,8 @@ export async function getUserById(
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
const user = await db.user.findUnique({
where: { id },
const user = await db.user.findFirst({
where: { id, ...activeRecordWhere },
select: userWithoutPasswordSelect,
})
@@ -96,8 +100,8 @@ export async function getUserById(
export async function getUserProfileById(
id: string,
): Promise<UserWithoutPassword | null> {
const user = await prisma.user.findUnique({
where: { id },
const user = await prisma.user.findFirst({
where: { id, ...activeRecordWhere },
select: userWithoutPasswordSelect,
})
@@ -108,8 +112,8 @@ export async function getUserByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
const user = await db.user.findUnique({
where: { emailNormalized: normalizeEmail(email) },
const user = await db.user.findFirst({
where: { emailNormalized: normalizeEmail(email), ...activeRecordWhere },
select: userWithoutPasswordSelect,
})
@@ -120,8 +124,8 @@ export async function getUserCredentialsByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({
where: { emailNormalized: normalizeEmail(email) },
return await db.user.findFirst({
where: { emailNormalized: normalizeEmail(email), ...activeRecordWhere },
select: {
id: true,
name: true,
@@ -145,6 +149,7 @@ export async function getUsers({
page,
pageSize,
where: {
...activeRecordWhere,
...(typeof isActive === "boolean"
? { status: userStatusFromActive(isActive) }
: {}),
@@ -231,6 +236,7 @@ export async function countActiveAdmins(
): Promise<number> {
return db.user.count({
where: {
...activeRecordWhere,
role: UserRole.ADMIN,
status: UserStatus.ACTIVE,
},
+1 -4
View File
@@ -248,10 +248,7 @@ export async function updatePersonUserUseCase(
try {
return await prisma.$transaction(async (tx) => {
const existing = await tx.person.findUnique({
where: { id },
select: { id: true, userId: true },
})
const existing = await PersonService.findById(id, tx)
if (!existing) {
return personError({ id: ["Person not found"] })
@@ -11,17 +11,20 @@ let prisma: PrismaClient
let createCategoryUseCase: typeof import("@/use-cases/category.use-cases").createCategoryUseCase
let updateCategoryUseCase: typeof import("@/use-cases/category.use-cases").updateCategoryUseCase
let deleteCategoryUseCase: typeof import("@/use-cases/category.use-cases").deleteCategoryUseCase
let CategoryService: typeof import("@/services/category.service").CategoryService
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const categoryUseCases = await import("@/use-cases/category.use-cases")
const categoryService = await import("@/services/category.service")
prisma = prismaModule.prisma
createCategoryUseCase = categoryUseCases.createCategoryUseCase
updateCategoryUseCase = categoryUseCases.updateCategoryUseCase
deleteCategoryUseCase = categoryUseCases.deleteCategoryUseCase
CategoryService = categoryService.CategoryService
})
beforeEach(async () => {
@@ -35,77 +38,83 @@ afterAll(async () => {
describe("category use-cases", () => {
it("creates a category and rejects duplicate names", async () => {
await expect(createCategoryUseCase({ name: "Hardware" })).resolves.toEqual({
expect(await createCategoryUseCase({ name: "Hardware" })).toEqual({
success: true,
})
await expect(
prisma.category.findUniqueOrThrow({ where: { name: "Hardware" } }),
).resolves.toMatchObject({ name: "Hardware" })
expect(
await prisma.category.findUniqueOrThrow({ where: { name: "Hardware" } }),
).toMatchObject({ name: "Hardware" })
await expect(createCategoryUseCase({ name: "Hardware" })).resolves.toEqual({
expect(await createCategoryUseCase({ name: "Hardware" })).toEqual({
success: false,
errors: { name: ["Category already exists"] },
})
await expect(prisma.category.count()).resolves.toBe(1)
expect(await prisma.category.count()).toBe(1)
})
it("updates a category and rejects unchanged or duplicate names", async () => {
const category = await createTestCategory(prisma, { name: "Peripherals" })
const other = await createTestCategory(prisma, { name: "Networking" })
await expect(
updateCategoryUseCase({ id: category.id, name: "Accessories" }),
).resolves.toEqual({ success: true })
expect(
await updateCategoryUseCase({ id: category.id, name: "Accessories" }),
).toEqual({ success: true })
await expect(
prisma.category.findUniqueOrThrow({ where: { id: category.id } }),
).resolves.toMatchObject({ name: "Accessories" })
expect(
await prisma.category.findUniqueOrThrow({ where: { id: category.id } }),
).toMatchObject({ name: "Accessories" })
await expect(
updateCategoryUseCase({ id: category.id, name: "Accessories" }),
).resolves.toEqual({
expect(
await updateCategoryUseCase({ id: category.id, name: "Accessories" }),
).toEqual({
success: false,
errors: { name: ["Category name is the same as the old one"] },
})
await expect(
updateCategoryUseCase({ id: category.id, name: other.name }),
).resolves.toEqual({
expect(
await updateCategoryUseCase({ id: category.id, name: other.name }),
).toEqual({
success: false,
errors: { name: ["Category already exists"] },
})
await expect(
prisma.category.findUniqueOrThrow({ where: { id: category.id } }),
).resolves.toMatchObject({ name: "Accessories" })
expect(
await prisma.category.findUniqueOrThrow({ where: { id: category.id } }),
).toMatchObject({ name: "Accessories" })
})
it("deletes empty categories and blocks deleting categories with items", async () => {
it("soft deletes empty categories and keeps deleted records out of active lists", async () => {
const categoryWithItems = await createTestCategory(prisma, {
name: "Computers",
})
await createTestItem(prisma, { categoryId: categoryWithItems.id })
await expect(deleteCategoryUseCase(categoryWithItems.id)).resolves.toEqual({
expect(await deleteCategoryUseCase(categoryWithItems.id)).toEqual({
success: false,
errors: { id: ["Category has items"] },
})
await expect(
prisma.category.findUnique({ where: { id: categoryWithItems.id } }),
).resolves.not.toBeNull()
await expect(prisma.item.count()).resolves.toBe(1)
expect(
await prisma.category.findUnique({ where: { id: categoryWithItems.id } }),
).not.toBeNull()
expect(await prisma.item.count()).toBe(1)
const emptyCategory = await createTestCategory(prisma, { name: "Cables" })
await expect(deleteCategoryUseCase(emptyCategory.id)).resolves.toEqual({
expect(await deleteCategoryUseCase(emptyCategory.id)).toEqual({
success: true,
})
await expect(
prisma.category.findUnique({ where: { id: emptyCategory.id } }),
).resolves.toBeNull()
const deletedCategory = await prisma.category.findUnique({
where: { id: emptyCategory.id },
})
expect(deletedCategory).not.toBeNull()
expect(deletedCategory?.deletedAt).toBeInstanceOf(Date)
const activeCategories = await CategoryService.findAll()
expect(activeCategories).toHaveLength(1)
expect(activeCategories[0].id).toBe(categoryWithItems.id)
})
})
@@ -58,11 +58,11 @@ describe("createPersonUserUseCase", () => {
})
// No User record created
await expect(
prisma.user.findUnique({
expect(
await prisma.user.findUnique({
where: { emailNormalized: normalizeEmail("john@example.test") },
}),
).resolves.toBeNull()
).toBeNull()
})
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
@@ -196,9 +196,9 @@ describe("createPersonUserUseCase", () => {
if (!user.passwordHash) throw new Error("Expected password hash")
const { verifyPassword } = await import("@/lib/security")
await expect(
verifyPassword("plaintext-password", user.passwordHash),
).resolves.toBe(true)
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe(
true,
)
})
})
@@ -221,7 +221,7 @@ describe("createPersonUserUseCase", () => {
errors: { email: ["Email already exists"] },
})
await expect(prisma.person.count()).resolves.toBe(1)
expect(await prisma.person.count()).toBe(1)
})
it("rejects submission when email already exists in User table", async () => {
@@ -244,8 +244,8 @@ describe("createPersonUserUseCase", () => {
})
// No new Person or User was created
await expect(prisma.person.count()).resolves.toBe(0)
await expect(prisma.user.count()).resolves.toBe(1)
expect(await prisma.person.count()).toBe(0)
expect(await prisma.user.count()).toBe(1)
})
it("accepts submission when email is unique across both tables", async () => {
@@ -10,16 +10,19 @@ import {
let prisma: PrismaClient
let createPersonUseCase: typeof import("@/use-cases/person.use-cases").createPersonUseCase
let updatePersonUseCase: typeof import("@/use-cases/person.use-cases").updatePersonUseCase
let PersonService: typeof import("@/services/person.service").PersonService
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const personUseCases = await import("@/use-cases/person.use-cases")
const personService = await import("@/services/person.service")
prisma = prismaModule.prisma
createPersonUseCase = personUseCases.createPersonUseCase
updatePersonUseCase = personUseCases.updatePersonUseCase
PersonService = personService.PersonService
})
beforeEach(async () => {
@@ -43,11 +46,11 @@ describe("person use-cases", () => {
}),
).resolves.toEqual({ success: true })
await expect(
prisma.person.findFirstOrThrow({
expect(
await prisma.person.findFirstOrThrow({
where: { firstName: "Person", lastName: "One" },
}),
).resolves.toMatchObject({
).toMatchObject({
firstName: "Person",
lastName: "One",
department: "IT",
@@ -71,11 +74,11 @@ describe("person use-cases", () => {
}),
).resolves.toEqual({ success: true })
await expect(
prisma.person.findFirstOrThrow({
expect(
await prisma.person.findFirstOrThrow({
where: { firstName: "Linked" },
}),
).resolves.toMatchObject({
).toMatchObject({
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
@@ -102,7 +105,7 @@ describe("person use-cases", () => {
errors: { email: ["Email already exists"] },
})
await expect(prisma.person.count()).resolves.toBe(1)
expect(await prisma.person.count()).toBe(1)
})
it("updates a person and rejects duplicate emails", async () => {
@@ -125,9 +128,7 @@ describe("person use-cases", () => {
}),
).resolves.toEqual({ success: true })
await expect(
prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).resolves.toMatchObject({
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
@@ -149,12 +150,10 @@ describe("person use-cases", () => {
errors: { email: ["Email already exists"] },
})
await expect(
prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).resolves.toMatchObject({
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
email: "edited@example.test",
})
await expect(prisma.person.count()).resolves.toBe(2)
expect(await prisma.person.count()).toBe(2)
})
it("searches by email and name in paginated results", async () => {
@@ -163,13 +162,15 @@ describe("person use-cases", () => {
lastName: "Smith",
email: "alice@company.com",
})
await createTestPerson(prisma, {
firstName: "Bob",
lastName: "Jones",
email: "bob@other.com",
const archivedPerson = await createTestPerson(prisma, {
firstName: "Archive",
lastName: "Person",
email: "archive@company.com",
})
await prisma.person.update({
where: { id: archivedPerson.id },
data: { deletedAt: new Date() },
})
const { PersonService } = await import("@/services/person.service")
const emailResults = await PersonService.findAllPaginated({
search: "company",
@@ -178,13 +179,16 @@ describe("person use-cases", () => {
})
expect(emailResults.data).toHaveLength(1)
expect(emailResults.data[0].firstName).toBe("Alice")
expect(await PersonService.findAllPeopleCount()).toBe(1)
expect(await PersonService.findById(archivedPerson.id)).toBeNull()
const nameResults = await PersonService.findAllPaginated({
search: "Bob",
search: "Alice",
page: 1,
pageSize: 10,
})
expect(nameResults.data).toHaveLength(1)
expect(nameResults.data[0].firstName).toBe("Bob")
expect(nameResults.data[0].firstName).toBe("Alice")
})
})
@@ -213,7 +213,7 @@ describe("updatePersonUserUseCase", () => {
describe("validation errors", () => {
it("returns error when person is not found", async () => {
const result = await updatePersonUserUseCase({
id: "nonexistent-id",
id: "00000000-0000-0000-0000-000000000000",
firstName: "Ghost",
lastName: "Person",
department: "OTHER",
@@ -14,6 +14,9 @@ let updateUserUseCase: typeof import("@/use-cases/user.use-cases").updateUserUse
let setUserActiveUseCase: typeof import("@/use-cases/user.use-cases").setUserActiveUseCase
let resetUserPasswordUseCase: typeof import("@/use-cases/user.use-cases").resetUserPasswordUseCase
let verifyPassword: typeof import("@/lib/security").verifyPassword
let getUserById: typeof import("@/services/user.service").getUserById
let getUsers: typeof import("@/services/user.service").getUsers
let countActiveAdmins: typeof import("@/services/user.service").countActiveAdmins
beforeAll(async () => {
await startIntegrationTestDatabase()
@@ -21,6 +24,7 @@ beforeAll(async () => {
const prismaModule = await import("@/lib/prisma")
const userUseCases = await import("@/use-cases/user.use-cases")
const security = await import("@/lib/security")
const userService = await import("@/services/user.service")
prisma = prismaModule.prisma
createUserUseCase = userUseCases.createUserUseCase
@@ -28,6 +32,9 @@ beforeAll(async () => {
setUserActiveUseCase = userUseCases.setUserActiveUseCase
resetUserPasswordUseCase = userUseCases.resetUserPasswordUseCase
verifyPassword = security.verifyPassword
getUserById = userService.getUserById
getUsers = userService.getUsers
countActiveAdmins = userService.countActiveAdmins
})
beforeEach(async () => {
@@ -88,12 +95,34 @@ describe("user use-cases", () => {
errors: { email: ["Email already exists"] },
})
await expect(prisma.user.count()).resolves.toBe(1)
await expect(
prisma.user.findUniqueOrThrow({
expect(await prisma.user.count()).toBe(1)
expect(
await prisma.user.findUniqueOrThrow({
where: { emailNormalized: normalizeEmail("existing@example.test") },
}),
).resolves.toMatchObject({ email: "existing@example.test" })
).toMatchObject({ email: "existing@example.test" })
})
it("excludes soft-deleted users from active queries", async () => {
const activeUser = await createTestUser(prisma, {
email: "active-user@example.test",
role: "ADMIN",
})
const softDeletedUser = await createTestUser(prisma, {
email: "deleted-user@example.test",
role: "ADMIN",
})
await prisma.user.update({
where: { id: softDeletedUser.id },
data: { deletedAt: new Date() },
})
const users = await getUsers({ page: 1, pageSize: 10 })
expect(users.data).toHaveLength(1)
expect(users.data[0].id).toBe(activeUser.id)
expect(await getUserById(softDeletedUser.id)).toBeNull()
expect(await countActiveAdmins()).toBe(1)
})
it("updates a user while preserving uniqueness constraints", async () => {
@@ -118,9 +147,7 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
await expect(
prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).resolves.toMatchObject({
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
@@ -141,9 +168,7 @@ describe("user use-cases", () => {
errors: { email: ["Email already exists"] },
})
await expect(
prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).resolves.toMatchObject({
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
email: "edited@example.test",
})
})
@@ -165,9 +190,10 @@ describe("user use-cases", () => {
errors: { id: ["You cannot remove your own administrator access"] },
})
await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).resolves.toMatchObject({ role: "ADMIN", status: "ACTIVE" })
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
role: "ADMIN",
status: "ACTIVE",
})
})
it("protects the last active administrator but allows deactivation when another active admin exists", async () => {
@@ -206,9 +232,9 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
await expect(
prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).resolves.toMatchObject({ status: "DISABLED" })
expect(await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } })).toMatchObject({
status: "DISABLED",
})
})
it("prevents self-deactivation", async () => {
@@ -226,9 +252,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot deactivate your own user"] },
})
await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).resolves.toMatchObject({ status: "ACTIVE" })
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
status: "ACTIVE",
})
})
it("resets a user password and rejects missing users", async () => {