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 { 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 }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
: "—"}
|
: "—"}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user