diff --git a/src/services/team.service.ts b/src/services/team.service.ts new file mode 100644 index 0000000..1705154 --- /dev/null +++ b/src/services/team.service.ts @@ -0,0 +1,57 @@ +import type { Prisma } from "@/generated/prisma/client" +import prisma from "@/lib/prisma" +import type { Team, TeamSummary } from "@/types" + +export const TeamService = { + findAll: async ( + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findMany({ + select: { + id: true, + name: true, + }, + orderBy: { name: "asc" }, + }) + }, + + findById: async ( + id: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findUnique({ where: { id } }) + }, + + findByNameCaseInsensitive: async ( + name: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findFirst({ + where: { + name: { equals: name.trim(), mode: "insensitive" }, + }, + }) + }, + + create: async ( + data: Prisma.TeamCreateInput, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.create({ data }) + }, + + update: async ( + id: string, + data: Prisma.TeamUpdateInput, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.update({ where: { id }, data }) + }, + + delete: async ( + id: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.delete({ where: { id } }) + }, +} diff --git a/src/types/index.ts b/src/types/index.ts index 805b46c..fad6ccd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,4 +6,5 @@ export * from "./item" export * from "./movement" export * from "./paginate" export * from "./person" +export * from "./team" export * from "./user" diff --git a/src/types/team.ts b/src/types/team.ts new file mode 100644 index 0000000..d7f4fba --- /dev/null +++ b/src/types/team.ts @@ -0,0 +1,5 @@ +import type { Team as PrismaTeam } from "@/generated/prisma/client" + +export type Team = PrismaTeam + +export type TeamSummary = Pick diff --git a/src/use-cases/team.use-cases.ts b/src/use-cases/team.use-cases.ts new file mode 100644 index 0000000..2241ac0 --- /dev/null +++ b/src/use-cases/team.use-cases.ts @@ -0,0 +1,123 @@ +import { Prisma } from "@/generated/prisma/client" +import prisma from "@/lib/prisma" +import type { + CreateTeamFormType, + UpdateTeamFormType, +} from "@/schemas/team.schema" +import { TeamService } from "@/services/team.service" + +type FieldErrors = Record + +type TeamUseCaseResult = + | { + success: true + } + | { + success: false + errors: FieldErrors + } + +function teamError(errors: FieldErrors): TeamUseCaseResult { + return { + success: false, + errors, + } +} + +function isUniqueConstraintError(error: unknown) { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) +} + +export async function listTeamsUseCase() { + return TeamService.findAll() +} + +export async function createTeamUseCase( + input: CreateTeamFormType, +): Promise { + const name = input.name.trim() + + try { + return await prisma.$transaction(async (tx) => { + const existingTeam = await TeamService.findByNameCaseInsensitive(name, tx) + + if (existingTeam) { + return teamError({ name: ["Team already exists"] }) + } + + await TeamService.create({ name }, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} + +export async function updateTeamUseCase( + input: UpdateTeamFormType, +): Promise { + const { id } = input + const name = input.name.trim() + + try { + return await prisma.$transaction(async (tx) => { + const existingTeam = await TeamService.findById(id, tx) + + if (!existingTeam) { + return teamError({ id: ["Team not found"] }) + } + + if (existingTeam.name.toLowerCase() === name.toLowerCase()) { + return teamError({ name: ["Team name is the same"] }) + } + + const teamWithName = await TeamService.findByNameCaseInsensitive(name, tx) + + if (teamWithName) { + return teamError({ name: ["Team already exists"] }) + } + + await TeamService.update(id, { name }, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} + +export async function deleteTeamUseCase( + id: string, +): Promise { + try { + return await prisma.$transaction(async (tx) => { + const team = await TeamService.findById(id, tx) + + if (!team) { + return teamError({ id: ["Team not found"] }) + } + + await TeamService.delete(id, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} diff --git a/tests/integration/helpers/factories.ts b/tests/integration/helpers/factories.ts index 07068c7..e05ca0e 100644 --- a/tests/integration/helpers/factories.ts +++ b/tests/integration/helpers/factories.ts @@ -77,6 +77,19 @@ export async function createTestPerson( }) } +export async function createTestTeam( + prisma: PrismaClient, + overrides: Partial<{ name: string }> = {}, +) { + const suffix = nextSuffix() + + return prisma.team.create({ + data: { + name: overrides.name ?? `Test Team ${suffix}`, + }, + }) +} + export async function createTestItem( prisma: PrismaClient, overrides: Partial<{ diff --git a/tests/integration/helpers/test-db.ts b/tests/integration/helpers/test-db.ts index 3593111..d722a94 100644 --- a/tests/integration/helpers/test-db.ts +++ b/tests/integration/helpers/test-db.ts @@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [ "Asset", "Item", "Category", + "Team", "Person", "User", ] diff --git a/tests/integration/use-cases/team.use-cases.test.ts b/tests/integration/use-cases/team.use-cases.test.ts new file mode 100644 index 0000000..76022ba --- /dev/null +++ b/tests/integration/use-cases/team.use-cases.test.ts @@ -0,0 +1,175 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import type { PrismaClient } from "@/generated/prisma/client" +import { createTestPerson, createTestTeam } from "../helpers/factories" +import { + resetIntegrationTestDatabase, + startIntegrationTestDatabase, + stopIntegrationTestDatabase, +} from "../helpers/test-db" + +let prisma: PrismaClient +let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUseCase +let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase +let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase +let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase +let TeamService: typeof import("@/services/team.service").TeamService + +beforeAll(async () => { + await startIntegrationTestDatabase() + + const prismaModule = await import("@/lib/prisma") + const teamUseCases = await import("@/use-cases/team.use-cases") + const teamService = await import("@/services/team.service") + + prisma = prismaModule.prisma + createTeamUseCase = teamUseCases.createTeamUseCase + updateTeamUseCase = teamUseCases.updateTeamUseCase + deleteTeamUseCase = teamUseCases.deleteTeamUseCase + listTeamsUseCase = teamUseCases.listTeamsUseCase + TeamService = teamService.TeamService +}) + +beforeEach(async () => { + await resetIntegrationTestDatabase(prisma) +}) + +afterAll(async () => { + await prisma?.$disconnect() + await stopIntegrationTestDatabase() +}) + +describe("team use-cases", () => { + it("creates a team and rejects duplicate names", async () => { + expect(await createTeamUseCase({ name: "Engineering" })).toEqual({ + success: true, + }) + + expect( + await prisma.team.findFirst({ where: { name: "Engineering" } }), + ).toMatchObject({ name: "Engineering" }) + + expect(await createTeamUseCase({ name: "Engineering" })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await prisma.team.count()).toBe(1) + }) + + it("rejects duplicate names case-insensitively and trims whitespace", async () => { + await createTestTeam(prisma, { name: "Engineering" }) + + expect(await createTeamUseCase({ name: "engineering" })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await createTeamUseCase({ name: " ENGINEERING " })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await prisma.team.count()).toBe(1) + }) + + it("trims the name before saving", async () => { + await createTeamUseCase({ name: " Engineering " }) + + const team = await prisma.team.findFirst({ + where: { name: "Engineering" }, + }) + + expect(team).not.toBeNull() + expect(team?.name).toBe("Engineering") + }) + + it("updates a team and rejects unchanged or duplicate names", async () => { + const team = await createTestTeam(prisma, { name: "Peripherals" }) + const other = await createTestTeam(prisma, { name: "Networking" }) + + expect( + await updateTeamUseCase({ id: team.id, name: "Accessories" }), + ).toEqual({ success: true }) + + expect( + await prisma.team.findUniqueOrThrow({ where: { id: team.id } }), + ).toMatchObject({ name: "Accessories" }) + + expect( + await updateTeamUseCase({ id: team.id, name: "Accessories" }), + ).toEqual({ + success: false, + errors: { name: ["Team name is the same"] }, + }) + + expect( + await updateTeamUseCase({ id: team.id, name: other.name }), + ).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect( + await prisma.team.findUniqueOrThrow({ where: { id: team.id } }), + ).toMatchObject({ name: "Accessories" }) + }) + + it("returns not found when updating a missing team", async () => { + expect( + await updateTeamUseCase({ + id: "00000000-0000-0000-0000-000000000000", + name: "Ghost", + }), + ).toEqual({ + success: false, + errors: { id: ["Team not found"] }, + }) + }) + + it("hard deletes a team", async () => { + const team = await createTestTeam(prisma, { name: "Legacy" }) + + expect(await deleteTeamUseCase(team.id)).toEqual({ success: true }) + + expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull() + }) + + it("returns not found when deleting a missing team", async () => { + expect( + await deleteTeamUseCase("00000000-0000-0000-0000-000000000000"), + ).toEqual({ + success: false, + errors: { id: ["Team not found"] }, + }) + }) + + it("nulls linked Person.teamId when a team is deleted", async () => { + const team = await createTestTeam(prisma, { name: "Assigned" }) + const person = await createTestPerson(prisma) + + await prisma.person.update({ + where: { id: person.id }, + data: { teamId: team.id }, + }) + + expect(await deleteTeamUseCase(team.id)).toEqual({ success: true }) + + const updatedPerson = await prisma.person.findUnique({ + where: { id: person.id }, + }) + + expect(updatedPerson).not.toBeNull() + expect(updatedPerson?.teamId).toBeNull() + }) + + it("lists all teams ordered by name", async () => { + await createTestTeam(prisma, { name: "Beta" }) + await createTestTeam(prisma, { name: "Alpha" }) + await createTestTeam(prisma, { name: "Gamma" }) + + const teams = await listTeamsUseCase() + + expect(teams).toHaveLength(3) + expect(teams.map((team) => team.name)).toEqual(["Alpha", "Beta", "Gamma"]) + }) +})