diff --git a/src/services/category.service.ts b/src/services/category.service.ts index 0256e37..668371d 100644 --- a/src/services/category.service.ts +++ b/src/services/category.service.ts @@ -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 => { return prisma.category.findMany({ + where: activeRecordWhere, select: { id: true, name: true, @@ -15,6 +20,7 @@ export const CategoryService = { findAllWithItemsCount: async (): Promise => { 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 => { 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 => { - return db.category.findUnique({ where: { id } }) + return db.category.findFirst({ where: { id, ...activeRecordWhere } }) }, findByIdWithItemsCount: async ( id: string, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise => { - 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 => { - return db.category.delete({ where: { id } }) + return db.category.update({ + where: { id }, + data: { deletedAt: new Date() }, + }) }, } diff --git a/src/services/person.service.ts b/src/services/person.service.ts index 6cfd8c0..d3674b7 100644 --- a/src/services/person.service.ts +++ b/src/services/person.service.ts @@ -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 => { 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 => { - return prisma.person.count() + return prisma.person.count({ where: activeRecordWhere }) }, findById: async ( id: string, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise => { - return db.person.findUnique({ where: { id } }) + return db.person.findFirst({ where: { id, ...activeRecordWhere } }) }, findByIdWithUser: async ( id: string, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise => { - 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 => { - return db.person.findFirst({ where: { email } }) + return db.person.findFirst({ where: { email, ...activeRecordWhere } }) }, create: async ( diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 8129ffc..5a768fb 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -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 { - 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 { return db.user.count({ where: { + ...activeRecordWhere, role: UserRole.ADMIN, status: UserStatus.ACTIVE, }, diff --git a/src/use-cases/person.use-cases.ts b/src/use-cases/person.use-cases.ts index dbada95..b279bcf 100644 --- a/src/use-cases/person.use-cases.ts +++ b/src/use-cases/person.use-cases.ts @@ -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"] }) diff --git a/tests/integration/use-cases/category.use-cases.test.ts b/tests/integration/use-cases/category.use-cases.test.ts index ea82113..fd799d7 100644 --- a/tests/integration/use-cases/category.use-cases.test.ts +++ b/tests/integration/use-cases/category.use-cases.test.ts @@ -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) }) }) diff --git a/tests/integration/use-cases/person-user.use-cases.test.ts b/tests/integration/use-cases/person-user.use-cases.test.ts index ef5ceaf..a8bb61a 100644 --- a/tests/integration/use-cases/person-user.use-cases.test.ts +++ b/tests/integration/use-cases/person-user.use-cases.test.ts @@ -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 () => { diff --git a/tests/integration/use-cases/person.use-cases.test.ts b/tests/integration/use-cases/person.use-cases.test.ts index b0fb5c0..4b6fedc 100644 --- a/tests/integration/use-cases/person.use-cases.test.ts +++ b/tests/integration/use-cases/person.use-cases.test.ts @@ -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") }) }) diff --git a/tests/integration/use-cases/update-person-user.use-cases.test.ts b/tests/integration/use-cases/update-person-user.use-cases.test.ts index bc68c8d..335d7ce 100644 --- a/tests/integration/use-cases/update-person-user.use-cases.test.ts +++ b/tests/integration/use-cases/update-person-user.use-cases.test.ts @@ -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", diff --git a/tests/integration/use-cases/user.use-cases.test.ts b/tests/integration/use-cases/user.use-cases.test.ts index 24a7a25..dac1909 100644 --- a/tests/integration/use-cases/user.use-cases.test.ts +++ b/tests/integration/use-cases/user.use-cases.test.ts @@ -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 () => {