feat(people): adapt person user flows to status model

This commit is contained in:
2026-06-19 01:05:33 +02:00
parent 01d89cd21b
commit 8e6a00c2a9
8 changed files with 75 additions and 31 deletions
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
@@ -74,7 +75,7 @@ export default async function PersonInfoPage({
{copy.detail.labels.status} {copy.detail.labels.status}
</span> </span>
<span> <span>
{person.user.isActive {person.user.status === UserStatus.ACTIVE
? userCopy.status.active ? userCopy.status.active
: userCopy.status.inactive} : userCopy.status.inactive}
</span> </span>
@@ -11,6 +11,7 @@ import {
SubmitButton, SubmitButton,
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { UserStatus } from "@/generated/prisma/client"
import { PERSON_DEPARTMENTS } from "@/lib/constants" import { PERSON_DEPARTMENTS } from "@/lib/constants"
import { import {
buildUnifiedUpdateSchema, buildUnifiedUpdateSchema,
@@ -70,7 +71,9 @@ export default function EditPersonForm({
department: person.department ?? "OTHER", department: person.department ?? "OTHER",
email: person.email ?? "", email: person.email ?? "",
phone: person.phone ?? "", phone: person.phone ?? "",
...(hasUser && user ? { role: user.role, isActive: user.isActive } : {}), ...(hasUser && user
? { role: user.role, isActive: user.status === UserStatus.ACTIVE }
: {}),
}, },
}) })
+2 -1
View File
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
@@ -105,7 +106,7 @@ export default async function PeoplePage(props: {
</td> </td>
<td className="p-4"> <td className="p-4">
{person.user {person.user
? person.user.isActive ? person.user.status === UserStatus.ACTIVE
? userStatusCopy.active ? userStatusCopy.active
: userStatusCopy.inactive : userStatusCopy.inactive
: "—"} : "—"}
+14 -2
View File
@@ -3,7 +3,19 @@ import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
const personWithUserSelect = { const personWithUserSelect = {
include: { user: true }, include: {
user: {
select: {
id: true,
name: true,
email: true,
role: true,
status: true,
createdAt: true,
updatedAt: true,
},
},
},
} as const } as const
export type PersonWithUser = Prisma.PersonGetPayload< export type PersonWithUser = Prisma.PersonGetPayload<
@@ -70,7 +82,7 @@ export const PersonService = {
email: string, email: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Person | null> => { ): Promise<Person | null> => {
return db.person.findUnique({ where: { email } }) return db.person.findFirst({ where: { email } })
}, },
create: async ( create: async (
+22 -8
View File
@@ -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 prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security" import { getPasswordHash } from "@/lib/security"
import type { import type {
@@ -178,6 +179,10 @@ export async function createPersonUserUseCase(
} }
// Person + User creation // Person + User creation
if (!password) {
return personError({ password: ["Password is required"] })
}
const person = await PersonService.create( const person = await PersonService.create(
{ {
firstName, firstName,
@@ -190,15 +195,20 @@ export async function createPersonUserUseCase(
) )
const userName = `${firstName} ${lastName}` 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({ const user = await tx.user.create({
data: { data: {
name: userName, name: userName,
email, email,
password: hashedPassword, emailNormalized: normalizeEmail(email),
passwordHash: hashedPassword,
role, role,
isActive, status,
...(status === UserStatus.ACTIVE ? { activatedAt: now } : {}),
passwordChangedAt: now,
}, },
}) })
@@ -240,7 +250,7 @@ export async function updatePersonUserUseCase(
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const existing = await tx.person.findUnique({ const existing = await tx.person.findUnique({
where: { id }, where: { id },
include: { user: true }, select: { id: true, userId: true },
}) })
if (!existing) { if (!existing) {
@@ -267,17 +277,21 @@ export async function updatePersonUserUseCase(
) )
// If the person has a linked user, update User fields. // If the person has a linked user, update User fields.
if (existing.userId && existing.user) { if (existing.userId) {
const userData: Prisma.UserUpdateInput = {} const userData: Prisma.UserUpdateInput = {}
if (role !== undefined) { if (role !== undefined) {
userData.role = role userData.role = role
} }
if (isActive !== undefined) { if (isActive !== undefined) {
userData.isActive = isActive userData.status = isActive ? UserStatus.ACTIVE : UserStatus.DISABLED
if (isActive) {
userData.activatedAt = new Date()
}
} }
if (password && password.length >= 8) { 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) { if (Object.keys(userData).length > 0) {
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client" import type { PrismaClient } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { createTestPerson, createTestUser } from "../helpers/factories" import { createTestPerson, createTestUser } from "../helpers/factories"
import { import {
resetIntegrationTestDatabase, resetIntegrationTestDatabase,
@@ -58,7 +59,9 @@ describe("createPersonUserUseCase", () => {
// No User record created // No User record created
await expect( await expect(
prisma.user.findUnique({ where: { email: "john@example.test" } }), prisma.user.findUnique({
where: { emailNormalized: normalizeEmail("john@example.test") },
}),
).resolves.toBeNull() ).resolves.toBeNull()
}) })
@@ -109,16 +112,19 @@ describe("createPersonUserUseCase", () => {
// User record should exist with derived name // User record should exist with derived name
expect(person.userId).not.toBeNull() expect(person.userId).not.toBeNull()
if (!person.userId) throw new Error("Expected linked user")
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { id: person.userId! }, where: { id: person.userId },
}) })
expect(user).toMatchObject({ expect(user).toMatchObject({
name: "Admin User", name: "Admin User",
email: "admin@example.test", email: "admin@example.test",
role: "ADMIN", 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 () => { it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
@@ -143,9 +149,10 @@ describe("createPersonUserUseCase", () => {
where: { lastName: suffix }, where: { lastName: suffix },
}) })
expect(person.userId).not.toBeNull() expect(person.userId).not.toBeNull()
if (!person.userId) throw new Error("Expected linked user")
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { id: person.userId! }, where: { id: person.userId },
}) })
expect(user.role).toBe(role) expect(user.role).toBe(role)
expect(user.name).toBe(`Person ${suffix}`) expect(user.name).toBe(`Person ${suffix}`)
@@ -165,7 +172,7 @@ describe("createPersonUserUseCase", () => {
}) })
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { email: "maria@example.test" }, where: { emailNormalized: normalizeEmail("maria@example.test") },
}) })
expect(user.name).toBe("Maria Garcia") expect(user.name).toBe("Maria Garcia")
}) })
@@ -183,13 +190,14 @@ describe("createPersonUserUseCase", () => {
}) })
const user = await prisma.user.findUniqueOrThrow({ 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") const { verifyPassword } = await import("@/lib/security")
await expect( await expect(
verifyPassword("plaintext-password", user.password), verifyPassword("plaintext-password", user.passwordHash),
).resolves.toBe(true) ).resolves.toBe(true)
}) })
}) })
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" 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 { getPasswordHash } from "@/lib/security"
import { createTestPerson, createTestUser } from "../helpers/factories" import { createTestPerson, createTestUser } from "../helpers/factories"
import { import {
@@ -123,7 +124,7 @@ describe("updatePersonUserUseCase", () => {
expect(updatedPerson.user).toMatchObject({ expect(updatedPerson.user).toMatchObject({
id: user.id, id: user.id,
role: "ADMIN", role: "ADMIN",
isActive: false, status: "DISABLED",
}) })
}) })
@@ -157,8 +158,9 @@ describe("updatePersonUserUseCase", () => {
where: { id: user.id }, where: { id: user.id },
}) })
const { verifyPassword } = await import("@/lib/security") const { verifyPassword } = await import("@/lib/security")
if (!updated.passwordHash) throw new Error("Expected password hash")
await expect( await expect(
verifyPassword("new-password-1", updated.password), verifyPassword("new-password-1", updated.passwordHash),
).resolves.toBe(true) ).resolves.toBe(true)
}) })
@@ -167,10 +169,13 @@ describe("updatePersonUserUseCase", () => {
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email: "no-pw@example.test", email: "no-pw@example.test",
emailNormalized: normalizeEmail("no-pw@example.test"),
name: "No PW", name: "No PW",
password: originalHash, passwordHash: originalHash,
role: "STAFF", role: "STAFF",
isActive: true, status: UserStatus.ACTIVE,
activatedAt: new Date(),
passwordChangedAt: new Date(),
}, },
}) })
const person = await createTestPerson(prisma, { const person = await createTestPerson(prisma, {
@@ -198,8 +203,9 @@ describe("updatePersonUserUseCase", () => {
where: { id: user.id }, where: { id: user.id },
}) })
const { verifyPassword: verify } = await import("@/lib/security") const { verifyPassword: verify } = await import("@/lib/security")
if (!updated.passwordHash) throw new Error("Expected password hash")
await expect( await expect(
verify("original-password-1", updated.password), verify("original-password-1", updated.passwordHash),
).resolves.toBe(true) ).resolves.toBe(true)
}) })
}) })
@@ -58,9 +58,9 @@ const basePerson: PersonWithUser = {
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
userId: null, userId: null,
isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"), updatedAt: new Date("2024-01-01"),
deletedAt: null,
user: null, user: null,
} }
@@ -73,10 +73,9 @@ const personWithUser: PersonWithUser = {
name: "Ada Lovelace", name: "Ada Lovelace",
email: "ada@example.test", email: "ada@example.test",
role: "ADMIN", role: "ADMIN",
isActive: true, status: "ACTIVE",
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
updatedAt: 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({ user: expect.objectContaining({
id: "user-1", id: "user-1",
role: "ADMIN", role: "ADMIN",
isActive: true, status: "ACTIVE",
}), }),
}), }),
formCopy: es.admin.users.form, formCopy: es.admin.users.form,