diff --git a/src/app/(dashboard)/people/[personId]/page.tsx b/src/app/(dashboard)/people/[personId]/page.tsx
index 5949ee3..fd462d3 100644
--- a/src/app/(dashboard)/people/[personId]/page.tsx
+++ b/src/app/(dashboard)/people/[personId]/page.tsx
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
@@ -74,7 +75,7 @@ export default async function PersonInfoPage({
{copy.detail.labels.status}
- {person.user.isActive
+ {person.user.status === UserStatus.ACTIVE
? userCopy.status.active
: userCopy.status.inactive}
diff --git a/src/app/(dashboard)/people/_components/edit.person.form.tsx b/src/app/(dashboard)/people/_components/edit.person.form.tsx
index 10b24e4..d7594da 100644
--- a/src/app/(dashboard)/people/_components/edit.person.form.tsx
+++ b/src/app/(dashboard)/people/_components/edit.person.form.tsx
@@ -11,6 +11,7 @@ import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
+import { UserStatus } from "@/generated/prisma/client"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import {
buildUnifiedUpdateSchema,
@@ -70,7 +71,9 @@ export default function EditPersonForm({
department: person.department ?? "OTHER",
email: person.email ?? "",
phone: person.phone ?? "",
- ...(hasUser && user ? { role: user.role, isActive: user.isActive } : {}),
+ ...(hasUser && user
+ ? { role: user.role, isActive: user.status === UserStatus.ACTIVE }
+ : {}),
},
})
diff --git a/src/app/(dashboard)/people/page.tsx b/src/app/(dashboard)/people/page.tsx
index 43e33fe..dca1681 100644
--- a/src/app/(dashboard)/people/page.tsx
+++ b/src/app/(dashboard)/people/page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
+import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
@@ -105,7 +106,7 @@ export default async function PeoplePage(props: {
{person.user
- ? person.user.isActive
+ ? person.user.status === UserStatus.ACTIVE
? userStatusCopy.active
: userStatusCopy.inactive
: "—"}
diff --git a/src/services/person.service.ts b/src/services/person.service.ts
index d3bf52f..6cfd8c0 100644
--- a/src/services/person.service.ts
+++ b/src/services/person.service.ts
@@ -3,7 +3,19 @@ import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
const personWithUserSelect = {
- include: { user: true },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ role: true,
+ status: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ },
+ },
} as const
export type PersonWithUser = Prisma.PersonGetPayload<
@@ -70,7 +82,7 @@ export const PersonService = {
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise => {
- return db.person.findUnique({ where: { email } })
+ return db.person.findFirst({ where: { email } })
},
create: async (
diff --git a/src/use-cases/person.use-cases.ts b/src/use-cases/person.use-cases.ts
index 6a94961..dbada95 100644
--- a/src/use-cases/person.use-cases.ts
+++ b/src/use-cases/person.use-cases.ts
@@ -1,4 +1,5 @@
-import { Prisma } from "@/generated/prisma/client"
+import { Prisma, UserStatus } from "@/generated/prisma/client"
+import { normalizeEmail } from "@/lib/email"
import prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security"
import type {
@@ -178,6 +179,10 @@ export async function createPersonUserUseCase(
}
// Person + User creation
+ if (!password) {
+ return personError({ password: ["Password is required"] })
+ }
+
const person = await PersonService.create(
{
firstName,
@@ -190,15 +195,20 @@ export async function createPersonUserUseCase(
)
const userName = `${firstName} ${lastName}`
- const hashedPassword = await getPasswordHash(password!)
+ const hashedPassword = await getPasswordHash(password)
+ const status = isActive ? UserStatus.ACTIVE : UserStatus.DISABLED
+ const now = new Date()
const user = await tx.user.create({
data: {
name: userName,
email,
- password: hashedPassword,
+ emailNormalized: normalizeEmail(email),
+ passwordHash: hashedPassword,
role,
- isActive,
+ status,
+ ...(status === UserStatus.ACTIVE ? { activatedAt: now } : {}),
+ passwordChangedAt: now,
},
})
@@ -240,7 +250,7 @@ export async function updatePersonUserUseCase(
return await prisma.$transaction(async (tx) => {
const existing = await tx.person.findUnique({
where: { id },
- include: { user: true },
+ select: { id: true, userId: true },
})
if (!existing) {
@@ -267,17 +277,21 @@ export async function updatePersonUserUseCase(
)
// If the person has a linked user, update User fields.
- if (existing.userId && existing.user) {
+ if (existing.userId) {
const userData: Prisma.UserUpdateInput = {}
if (role !== undefined) {
userData.role = role
}
if (isActive !== undefined) {
- userData.isActive = isActive
+ userData.status = isActive ? UserStatus.ACTIVE : UserStatus.DISABLED
+ if (isActive) {
+ userData.activatedAt = new Date()
+ }
}
if (password && password.length >= 8) {
- userData.password = await getPasswordHash(password)
+ userData.passwordHash = await getPasswordHash(password)
+ userData.passwordChangedAt = new Date()
}
if (Object.keys(userData).length > 0) {
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 30412ee..ef5ceaf 100644
--- a/tests/integration/use-cases/person-user.use-cases.test.ts
+++ b/tests/integration/use-cases/person-user.use-cases.test.ts
@@ -1,5 +1,6 @@
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 {
resetIntegrationTestDatabase,
@@ -58,7 +59,9 @@ describe("createPersonUserUseCase", () => {
// No User record created
await expect(
- prisma.user.findUnique({ where: { email: "john@example.test" } }),
+ prisma.user.findUnique({
+ where: { emailNormalized: normalizeEmail("john@example.test") },
+ }),
).resolves.toBeNull()
})
@@ -109,16 +112,19 @@ describe("createPersonUserUseCase", () => {
// User record should exist with derived name
expect(person.userId).not.toBeNull()
+ if (!person.userId) throw new Error("Expected linked user")
const user = await prisma.user.findUniqueOrThrow({
- where: { id: person.userId! },
+ where: { id: person.userId },
})
expect(user).toMatchObject({
name: "Admin User",
email: "admin@example.test",
role: "ADMIN",
- isActive: true,
+ status: "ACTIVE",
})
+ expect(user.activatedAt).toBeInstanceOf(Date)
+ expect(user.passwordChangedAt).toBeInstanceOf(Date)
})
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
@@ -143,9 +149,10 @@ describe("createPersonUserUseCase", () => {
where: { lastName: suffix },
})
expect(person.userId).not.toBeNull()
+ if (!person.userId) throw new Error("Expected linked user")
const user = await prisma.user.findUniqueOrThrow({
- where: { id: person.userId! },
+ where: { id: person.userId },
})
expect(user.role).toBe(role)
expect(user.name).toBe(`Person ${suffix}`)
@@ -165,7 +172,7 @@ describe("createPersonUserUseCase", () => {
})
const user = await prisma.user.findUniqueOrThrow({
- where: { email: "maria@example.test" },
+ where: { emailNormalized: normalizeEmail("maria@example.test") },
})
expect(user.name).toBe("Maria Garcia")
})
@@ -183,13 +190,14 @@ describe("createPersonUserUseCase", () => {
})
const user = await prisma.user.findUniqueOrThrow({
- where: { email: "hash-test@example.test" },
+ where: { emailNormalized: normalizeEmail("hash-test@example.test") },
})
- expect(user.password).not.toBe("plaintext-password")
+ expect(user.passwordHash).not.toBe("plaintext-password")
+ if (!user.passwordHash) throw new Error("Expected password hash")
const { verifyPassword } = await import("@/lib/security")
await expect(
- verifyPassword("plaintext-password", user.password),
+ verifyPassword("plaintext-password", user.passwordHash),
).resolves.toBe(true)
})
})
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 92a94ca..bc68c8d 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
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
-import type { PrismaClient } from "@/generated/prisma/client"
+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 {
@@ -123,7 +124,7 @@ describe("updatePersonUserUseCase", () => {
expect(updatedPerson.user).toMatchObject({
id: user.id,
role: "ADMIN",
- isActive: false,
+ status: "DISABLED",
})
})
@@ -157,8 +158,9 @@ describe("updatePersonUserUseCase", () => {
where: { id: user.id },
})
const { verifyPassword } = await import("@/lib/security")
+ if (!updated.passwordHash) throw new Error("Expected password hash")
await expect(
- verifyPassword("new-password-1", updated.password),
+ verifyPassword("new-password-1", updated.passwordHash),
).resolves.toBe(true)
})
@@ -167,10 +169,13 @@ describe("updatePersonUserUseCase", () => {
const user = await prisma.user.create({
data: {
email: "no-pw@example.test",
+ emailNormalized: normalizeEmail("no-pw@example.test"),
name: "No PW",
- password: originalHash,
+ passwordHash: originalHash,
role: "STAFF",
- isActive: true,
+ status: UserStatus.ACTIVE,
+ activatedAt: new Date(),
+ passwordChangedAt: new Date(),
},
})
const person = await createTestPerson(prisma, {
@@ -198,8 +203,9 @@ describe("updatePersonUserUseCase", () => {
where: { id: user.id },
})
const { verifyPassword: verify } = await import("@/lib/security")
+ if (!updated.passwordHash) throw new Error("Expected password hash")
await expect(
- verify("original-password-1", updated.password),
+ verify("original-password-1", updated.passwordHash),
).resolves.toBe(true)
})
})
diff --git a/tests/unit/app/people/edit-person-form-wiring.test.ts b/tests/unit/app/people/edit-person-form-wiring.test.ts
index c5942e6..9b93141 100644
--- a/tests/unit/app/people/edit-person-form-wiring.test.ts
+++ b/tests/unit/app/people/edit-person-form-wiring.test.ts
@@ -58,9 +58,9 @@ const basePerson: PersonWithUser = {
email: "ada@example.test",
phone: "1234",
userId: null,
- isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
user: null,
}
@@ -73,10 +73,9 @@ const personWithUser: PersonWithUser = {
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
- isActive: true,
+ status: "ACTIVE",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
- password: "hashed",
},
}
@@ -137,7 +136,7 @@ describe("edit person page wiring", () => {
user: expect.objectContaining({
id: "user-1",
role: "ADMIN",
- isActive: true,
+ status: "ACTIVE",
}),
}),
formCopy: es.admin.users.form,
|