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,