feat(people): adapt person user flows to status model
This commit is contained in:
@@ -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}
|
||||
</span>
|
||||
<span>
|
||||
{person.user.isActive
|
||||
{person.user.status === UserStatus.ACTIVE
|
||||
? userCopy.status.active
|
||||
: userCopy.status.inactive}
|
||||
</span>
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? person.user.isActive
|
||||
? person.user.status === UserStatus.ACTIVE
|
||||
? userStatusCopy.active
|
||||
: userStatusCopy.inactive
|
||||
: "—"}
|
||||
|
||||
@@ -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<Person | null> => {
|
||||
return db.person.findUnique({ where: { email } })
|
||||
return db.person.findFirst({ where: { email } })
|
||||
},
|
||||
|
||||
create: async (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user