feat(teams): add Team entity and cutover Person.department to Person.teamId

Co-authored-by: Asis Ferrer <aferrer@aferrer.dev>
Co-committed-by: Asis Ferrer <aferrer@aferrer.dev>
This commit was merged in pull request #5.
This commit is contained in:
2026-06-26 00:29:09 +00:00
committed by Asis ferrer
parent cd38621f8b
commit 428dd0482d
52 changed files with 836 additions and 488 deletions
@@ -1,7 +1,11 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -33,10 +37,12 @@ afterAll(async () => {
describe("createPersonUserUseCase", () => {
describe("NO_USER role (person-only creation)", () => {
it("creates a Person without a User record when role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: team.id,
email: "john@example.test",
phone: null,
role: "NO_USER",
@@ -51,7 +57,7 @@ describe("createPersonUserUseCase", () => {
expect(person).toMatchObject({
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: team.id,
email: "john@example.test",
phone: null,
userId: null,
@@ -66,10 +72,12 @@ describe("createPersonUserUseCase", () => {
})
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
teamId: team.id,
email: "jane-noemail@example.test",
phone: "555-1234",
role: "NO_USER",
@@ -87,10 +95,12 @@ describe("createPersonUserUseCase", () => {
describe("real role (person + user creation)", () => {
it("creates Person and User with linked userId when role is ADMIN", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "Admin",
lastName: "User",
department: "IT",
teamId: team.id,
email: "admin@example.test",
phone: null,
role: "ADMIN",
@@ -101,12 +111,12 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { firstName: "Admin", lastName: "User" },
where: { firstName: "Admin" },
})
expect(person).toMatchObject({
firstName: "Admin",
lastName: "User",
department: "IT",
teamId: team.id,
email: "admin@example.test",
})
@@ -128,6 +138,7 @@ describe("createPersonUserUseCase", () => {
})
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
const team = await createTestTeam(prisma)
const roles = ["MANAGER", "STAFF", "VIEWER"] as const
for (const role of roles) {
@@ -135,7 +146,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({
firstName: "Person",
lastName: suffix,
department: "IT",
teamId: team.id,
email: `${suffix}@example.test`,
phone: null,
role,
@@ -160,10 +171,12 @@ describe("createPersonUserUseCase", () => {
})
it("derives User.name from firstName + lastName", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({
firstName: "Maria",
lastName: "Garcia",
department: "SALES",
teamId: team.id,
email: "maria@example.test",
phone: null,
role: "STAFF",
@@ -178,10 +191,12 @@ describe("createPersonUserUseCase", () => {
})
it("hashes the password when creating a User", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({
firstName: "Hash",
lastName: "Test",
department: "IT",
teamId: team.id,
email: "hash-test@example.test",
phone: null,
role: "STAFF",
@@ -196,20 +211,21 @@ describe("createPersonUserUseCase", () => {
if (!user.passwordHash) throw new Error("Expected password hash")
const { verifyPassword } = await import("@/lib/security")
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe(
true,
)
expect(
await verifyPassword("plaintext-password", user.passwordHash),
).toBe(true)
})
})
describe("cross-table email uniqueness", () => {
it("rejects submission when email already exists in Person table", async () => {
const team = await createTestTeam(prisma)
await createTestPerson(prisma, { email: "existing-person@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "Person",
department: "IT",
teamId: team.id,
email: "existing-person@example.test",
phone: null,
role: "NO_USER",
@@ -225,12 +241,13 @@ describe("createPersonUserUseCase", () => {
})
it("rejects submission when email already exists in User table", async () => {
const team = await createTestTeam(prisma)
await createTestUser(prisma, { email: "existing-user@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "User",
department: "IT",
teamId: team.id,
email: "existing-user@example.test",
phone: null,
role: "STAFF",
@@ -249,6 +266,7 @@ describe("createPersonUserUseCase", () => {
})
it("accepts submission when email is unique across both tables", async () => {
const team = await createTestTeam(prisma)
// Create a Person and a User with different emails
await createTestPerson(prisma, { email: "person@example.test" })
await createTestUser(prisma, { email: "user@example.test" })
@@ -256,7 +274,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({
firstName: "New",
lastName: "Person",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: null,
role: "NO_USER",
@@ -266,4 +284,24 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true })
})
})
describe("team validation", () => {
it("rejects an unknown team id", async () => {
const result = await createPersonUserUseCase({
firstName: "No",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "no-team@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
})
})
@@ -1,6 +1,10 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -40,7 +44,7 @@ describe("person use-cases", () => {
createPersonUseCase({
firstName: "Person",
lastName: "One",
department: "IT",
teamId: null,
email: "",
phone: "",
}),
@@ -53,7 +57,7 @@ describe("person use-cases", () => {
).toMatchObject({
firstName: "Person",
lastName: "One",
department: "IT",
teamId: null,
email: null,
phone: null,
userId: null,
@@ -62,12 +66,13 @@ describe("person use-cases", () => {
it("creates a person with linked userId", async () => {
const user = await createTestUser(prisma)
const team = await createTestTeam(prisma)
await expect(
createPersonUseCase({
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "linked@example.test",
phone: null,
userId: user.id,
@@ -81,7 +86,7 @@ describe("person use-cases", () => {
).toMatchObject({
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "linked@example.test",
userId: user.id,
})
@@ -96,7 +101,7 @@ describe("person use-cases", () => {
createPersonUseCase({
firstName: "Duplicate",
lastName: "Email",
department: "OTHER",
teamId: null,
email: "existing@example.test",
phone: null,
}),
@@ -108,7 +113,24 @@ describe("person use-cases", () => {
expect(await prisma.person.count()).toBe(1)
})
it("rejects an unknown team id on create", async () => {
const result = await createPersonUseCase({
firstName: "Unknown",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "unknown-team@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
it("updates a person and rejects duplicate emails", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
email: "person@example.test",
phone: "111111111",
@@ -122,16 +144,18 @@ describe("person use-cases", () => {
id: person.id,
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "edited@example.test",
phone: "222222222",
}),
).resolves.toEqual({ success: true })
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "edited@example.test",
phone: "222222222",
})
@@ -141,7 +165,7 @@ describe("person use-cases", () => {
id: person.id,
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: other.email,
phone: "222222222",
}),
@@ -150,12 +174,37 @@ describe("person use-cases", () => {
errors: { email: ["Email already exists"] },
})
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
email: "edited@example.test",
})
expect(await prisma.person.count()).toBe(2)
})
it("updates a person team to null", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
teamId: team.id,
})
await expect(
updatePersonUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: null,
email: person.email,
phone: person.phone,
}),
).resolves.toEqual({ success: true })
const updated = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
})
expect(updated.teamId).toBeNull()
})
it("searches by email and name in paginated results", async () => {
await createTestPerson(prisma, {
firstName: "Alice",
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { type PrismaClient, UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { getPasswordHash } from "@/lib/security"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -34,6 +38,7 @@ afterAll(async () => {
describe("updatePersonUserUseCase", () => {
describe("person-only update", () => {
it("updates only the Person when person has no linked User", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
firstName: "Old",
lastName: "Name",
@@ -44,7 +49,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "New",
lastName: "Name",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: "1234",
})
@@ -57,7 +62,7 @@ describe("updatePersonUserUseCase", () => {
expect(updated).toMatchObject({
firstName: "New",
lastName: "Name",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: "1234",
userId: null,
@@ -71,7 +76,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Empty",
lastName: "Email",
department: "OTHER",
teamId: null,
email: "",
phone: null,
})
@@ -87,6 +92,7 @@ describe("updatePersonUserUseCase", () => {
describe("person+user update", () => {
it("updates Person fields and User role/isActive when person has a User linked", async () => {
const team = await createTestTeam(prisma)
const user = await createTestUser(prisma, {
email: "user-update@example.test",
name: "Old Name",
@@ -107,7 +113,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "user-update@example.test",
phone: null,
role: "ADMIN",
@@ -120,7 +126,7 @@ describe("updatePersonUserUseCase", () => {
where: { id: person.id },
include: { user: true },
})
expect(updatedPerson.department).toBe("ENGINEERING")
expect(updatedPerson.teamId).toBe(team.id)
expect(updatedPerson.user).toMatchObject({
id: user.id,
role: "ADMIN",
@@ -144,7 +150,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
teamId: null,
email: "pw-reset@example.test",
phone: null,
role: "STAFF",
@@ -190,7 +196,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
teamId: null,
email: "no-pw@example.test",
phone: null,
role: "STAFF",
@@ -216,7 +222,7 @@ describe("updatePersonUserUseCase", () => {
id: "00000000-0000-0000-0000-000000000000",
firstName: "Ghost",
lastName: "Person",
department: "OTHER",
teamId: null,
email: "ghost@example.test",
phone: null,
})
@@ -239,7 +245,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Mine",
lastName: "Person",
department: "OTHER",
teamId: null,
email: "theirs@example.test",
phone: null,
})
@@ -249,5 +255,23 @@ describe("updatePersonUserUseCase", () => {
errors: { email: ["Email already exists"] },
})
})
it("rejects an unknown team id", async () => {
const person = await createTestPerson(prisma)
const result = await updatePersonUserUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: "00000000-0000-0000-0000-000000000000",
email: person.email,
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
})
})
})
@@ -147,7 +147,9 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
@@ -168,7 +170,9 @@ describe("user use-cases", () => {
errors: { email: ["Email already exists"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
email: "edited@example.test",
})
})
@@ -190,7 +194,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot remove your own administrator access"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
role: "ADMIN",
status: "ACTIVE",
})
@@ -232,7 +238,9 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).toMatchObject({
status: "DISABLED",
})
})
@@ -252,7 +260,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot deactivate your own user"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
status: "ACTIVE",
})
})