feat: add unified Person+User creation backend

This commit is contained in:
2026-06-16 21:21:17 +02:00
parent 68c2983d36
commit e5717461cf
11 changed files with 772 additions and 1 deletions
+52
View File
@@ -9,12 +9,20 @@ import {
type CreatePersonFormType, type CreatePersonFormType,
type UpdatePersonFormType, type UpdatePersonFormType,
} from "@/schemas/person.schema" } from "@/schemas/person.schema"
import {
buildUnifiedCreateSchema,
type UnifiedCreateFormType,
} from "@/schemas/user.schema"
import { import {
createPersonUseCase, createPersonUseCase,
createPersonUserUseCase,
updatePersonUseCase, updatePersonUseCase,
} from "@/use-cases/person.use-cases" } from "@/use-cases/person.use-cases"
import { localizePersonFieldErrors } from "./person.messages" import { localizePersonFieldErrors } from "./person.messages"
import { localizeUnifiedCreateFieldErrors } from "./user.messages"
const PERSON_USER_PATH = "/admin/users"
export async function createNewPerson(formData: CreatePersonFormType) { export async function createNewPerson(formData: CreatePersonFormType) {
const { dictionary } = await getI18n() const { dictionary } = await getI18n()
@@ -56,6 +64,50 @@ export async function createNewPerson(formData: CreatePersonFormType) {
} }
} }
export async function createPersonUserAction(
formData: UnifiedCreateFormType,
) {
const { dictionary } = await getI18n()
const userCopy = dictionary.admin.users
const schemaCopy = {
...userCopy.schema,
...dictionary.inventory.people.schema,
}
const validatedFields = buildUnifiedCreateSchema(schemaCopy).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createPersonUserUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUnifiedCreateFieldErrors(
result.errors,
userCopy.actions,
schemaCopy,
),
message: userCopy.actions.createFailure,
}
}
revalidatePath(PERSON_USER_PATH)
return { success: true, message: userCopy.actions.createSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: userCopy.actions.createFailure }
}
}
export async function updatePerson(formData: UpdatePersonFormType) { export async function updatePerson(formData: UpdatePersonFormType) {
const { dictionary } = await getI18n() const { dictionary } = await getI18n()
const copy = dictionary.inventory.people const copy = dictionary.inventory.people
+64
View File
@@ -1,4 +1,5 @@
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import type { UnifiedSchemaCopy } from "@/schemas/user.schema"
type UserActionCopy = Dictionary["admin"]["users"]["actions"] type UserActionCopy = Dictionary["admin"]["users"]["actions"]
@@ -37,3 +38,66 @@ export function localizeUserFieldErrors(
]), ]),
) )
} }
type UnifiedCreateActionCopy = Dictionary["admin"]["users"]["actions"]
const unifiedCreateErrorMessageKeys = {
"Email already exists": "duplicateEmail",
} as const satisfies Record<string, keyof UnifiedCreateActionCopy>
function isUnifiedCreateErrorMessage(
message: string,
): message is keyof typeof unifiedCreateErrorMessageKeys {
return message in unifiedCreateErrorMessageKeys
}
function localizeUnifiedCreateMessage(
message: string,
copy: UnifiedCreateActionCopy,
): string {
if (!isUnifiedCreateErrorMessage(message)) return message
return copy[unifiedCreateErrorMessageKeys[message]]
}
export function localizeUnifiedCreateFieldErrors(
errors: FieldErrors | undefined,
copy: UnifiedCreateActionCopy,
schemaCopy: UnifiedSchemaCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => {
// Schema-level validation messages come from schemaCopy
if (
field === "firstName" &&
message === schemaCopy.firstNameRequired
)
return message
if (
field === "lastName" &&
message === schemaCopy.lastNameRequired
)
return message
if (
field === "department" &&
message === schemaCopy.departmentRequired
)
return message
if (field === "email" && message === schemaCopy.emailInvalid)
return message
if (
field === "password" &&
message === schemaCopy.passwordMinLength
)
return message
// Action-level messages (like "Email already exists") come from action copy
return localizeUnifiedCreateMessage(message, copy)
}),
]),
)
}
+1
View File
@@ -473,6 +473,7 @@ export const en = {
MANAGER: "Manager", MANAGER: "Manager",
STAFF: "Staff", STAFF: "Staff",
VIEWER: "Viewer", VIEWER: "Viewer",
NO_USER: "No user account",
}, },
status: { status: {
active: "Active", active: "Active",
+1
View File
@@ -478,6 +478,7 @@ export const es = {
MANAGER: "Gerente", MANAGER: "Gerente",
STAFF: "Personal", STAFF: "Personal",
VIEWER: "Visor", VIEWER: "Visor",
NO_USER: "Sin cuenta de usuario",
}, },
status: { status: {
active: "Activo", active: "Activo",
+1 -1
View File
@@ -13,7 +13,7 @@ const defaultPersonSchemaCopy: PersonSchemaCopy = {
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
} }
const personDepartments = [ export const personDepartments = [
"IT", "IT",
"ENGINEERING", "ENGINEERING",
"TRAFFIC", "TRAFFIC",
+44
View File
@@ -1,9 +1,13 @@
import { z } from "zod" import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import { personDepartments } from "@/schemas/person.schema"
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"] export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
export type UnifiedSchemaCopy = Dictionary["admin"]["users"]["schema"] &
Dictionary["inventory"]["people"]["schema"]
export const defaultUserSchemaCopy: UserSchemaCopy = { export const defaultUserSchemaCopy: UserSchemaCopy = {
nameRequired: "Name is required", nameRequired: "Name is required",
emailInvalid: "Invalid email", emailInvalid: "Invalid email",
@@ -67,3 +71,43 @@ export type CreateUserFormType = z.infer<typeof createUserSchema>
export type UpdateUserFormType = z.infer<typeof updateUserSchema> export type UpdateUserFormType = z.infer<typeof updateUserSchema>
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema> export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema> export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema>
export const unifiedFormRoleSchema = z.enum([
"ADMIN",
"MANAGER",
"STAFF",
"VIEWER",
"NO_USER",
])
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
return z
.object({
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
department: z.enum(personDepartments, {
error: copy.departmentRequired,
}),
email: z.email({ error: copy.emailInvalid }),
phone: z.string().optional().nullable(),
role: unifiedFormRoleSchema,
password: z.string().optional(),
isActive: z.boolean(),
})
.superRefine((data, ctx) => {
if (
data.role !== "NO_USER" &&
(!data.password || data.password.length < 8)
) {
ctx.addIssue({
code: "custom",
message: copy.passwordMinLength,
path: ["password"],
})
}
})
}
export type UnifiedCreateFormType = z.infer<
ReturnType<typeof buildUnifiedCreateSchema>
>
+82
View File
@@ -1,9 +1,12 @@
import { Prisma } from "@/generated/prisma/client" import { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security"
import type { import type {
CreatePersonFormType, CreatePersonFormType,
UpdatePersonFormType, UpdatePersonFormType,
} from "@/schemas/person.schema" } from "@/schemas/person.schema"
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
import { getUserByEmail } from "@/services/user.service"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
@@ -127,3 +130,82 @@ export async function updatePersonUseCase(
throw error throw error
} }
} }
export async function createPersonUserUseCase(
input: UnifiedCreateFormType,
): Promise<PersonUseCaseResult> {
const { firstName, lastName, department, email, phone, role, password, isActive } =
input
try {
return await prisma.$transaction(async (tx) => {
// Cross-table email uniqueness: check both Person and User tables
const existingPersonEmail = await PersonService.findByEmail(email, tx)
if (existingPersonEmail) {
return personError({ email: ["Email already exists"] })
}
const existingUserEmail = await getUserByEmail(email, tx)
if (existingUserEmail) {
return personError({ email: ["Email already exists"] })
}
if (role === "NO_USER") {
// Person-only creation — no User record
await PersonService.create(
{
firstName,
lastName,
department,
email,
phone: phone ?? null,
},
tx,
)
return { success: true }
}
// Person + User creation
const person = await PersonService.create(
{
firstName,
lastName,
department,
email,
phone: phone ?? null,
},
tx,
)
const userName = `${firstName} ${lastName}`
const hashedPassword = await getPasswordHash(password!)
const user = await tx.user.create({
data: {
name: userName,
email,
password: hashedPassword,
role,
isActive,
},
})
await PersonService.update(
person.id,
{ user: { connect: { id: user.id } } },
tx,
)
return { success: true }
})
} catch (error) {
const errors = uniqueErrorFor(error)
if (errors) {
return personError(errors)
}
throw error
}
}
@@ -0,0 +1,261 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
stopIntegrationTestDatabase,
} from "../helpers/test-db"
let prisma: PrismaClient
let createPersonUserUseCase: typeof import("@/use-cases/person.use-cases").createPersonUserUseCase
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const personUseCases = await import("@/use-cases/person.use-cases")
prisma = prismaModule.prisma
createPersonUserUseCase = personUseCases.createPersonUserUseCase
})
beforeEach(async () => {
await resetIntegrationTestDatabase(prisma)
})
afterAll(async () => {
await prisma?.$disconnect()
await stopIntegrationTestDatabase()
})
describe("createPersonUserUseCase", () => {
describe("NO_USER role (person-only creation)", () => {
it("creates a Person without a User record when role is NO_USER", async () => {
const result = await createPersonUserUseCase({
firstName: "John",
lastName: "Doe",
department: "IT",
email: "john@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { firstName: "John", lastName: "Doe" },
})
expect(person).toMatchObject({
firstName: "John",
lastName: "Doe",
department: "IT",
email: "john@example.test",
phone: null,
userId: null,
})
// No User record created
await expect(
prisma.user.findUnique({ where: { email: "john@example.test" } }),
).resolves.toBeNull()
})
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
const result = await createPersonUserUseCase({
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
email: "jane-noemail@example.test",
phone: "555-1234",
role: "NO_USER",
isActive: true,
})
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { firstName: "Jane", lastName: "Smith" },
})
expect(person.phone).toBe("555-1234")
})
})
describe("real role (person + user creation)", () => {
it("creates Person and User with linked userId when role is ADMIN", async () => {
const result = await createPersonUserUseCase({
firstName: "Admin",
lastName: "User",
department: "IT",
email: "admin@example.test",
phone: null,
role: "ADMIN",
password: "secure-password",
isActive: true,
})
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { firstName: "Admin", lastName: "User" },
})
expect(person).toMatchObject({
firstName: "Admin",
lastName: "User",
department: "IT",
email: "admin@example.test",
})
// User record should exist with derived name
expect(person.userId).not.toBeNull()
const user = await prisma.user.findUniqueOrThrow({
where: { id: person.userId! },
})
expect(user).toMatchObject({
name: "Admin User",
email: "admin@example.test",
role: "ADMIN",
isActive: true,
})
})
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
const roles = ["MANAGER", "STAFF", "VIEWER"] as const
for (const role of roles) {
const suffix = role.toLowerCase()
const result = await createPersonUserUseCase({
firstName: "Person",
lastName: suffix,
department: "IT",
email: `${suffix}@example.test`,
phone: null,
role,
password: "secure-password",
isActive: true,
})
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { lastName: suffix },
})
expect(person.userId).not.toBeNull()
const user = await prisma.user.findUniqueOrThrow({
where: { id: person.userId! },
})
expect(user.role).toBe(role)
expect(user.name).toBe(`Person ${suffix}`)
}
})
it("derives User.name from firstName + lastName", async () => {
await createPersonUserUseCase({
firstName: "Maria",
lastName: "Garcia",
department: "SALES",
email: "maria@example.test",
phone: null,
role: "STAFF",
password: "secure-password",
isActive: true,
})
const user = await prisma.user.findUniqueOrThrow({
where: { email: "maria@example.test" },
})
expect(user.name).toBe("Maria Garcia")
})
it("hashes the password when creating a User", async () => {
await createPersonUserUseCase({
firstName: "Hash",
lastName: "Test",
department: "IT",
email: "hash-test@example.test",
phone: null,
role: "STAFF",
password: "plaintext-password",
isActive: true,
})
const user = await prisma.user.findUniqueOrThrow({
where: { email: "hash-test@example.test" },
})
expect(user.password).not.toBe("plaintext-password")
const { verifyPassword } = await import("@/lib/security")
await expect(
verifyPassword("plaintext-password", user.password),
).resolves.toBe(true)
})
})
describe("cross-table email uniqueness", () => {
it("rejects submission when email already exists in Person table", async () => {
await createTestPerson(prisma, { email: "existing-person@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "Person",
department: "IT",
email: "existing-person@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result).toEqual({
success: false,
errors: { email: ["Email already exists"] },
})
await expect(prisma.person.count()).resolves.toBe(1)
})
it("rejects submission when email already exists in User table", async () => {
await createTestUser(prisma, { email: "existing-user@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "User",
department: "IT",
email: "existing-user@example.test",
phone: null,
role: "STAFF",
password: "secure-password",
isActive: true,
})
expect(result).toEqual({
success: false,
errors: { email: ["Email already exists"] },
})
// No new Person or User was created
await expect(prisma.person.count()).resolves.toBe(0)
await expect(prisma.user.count()).resolves.toBe(1)
})
it("accepts submission when email is unique across both tables", async () => {
// Create a Person and a User with different emails
await createTestPerson(prisma, { email: "person@example.test" })
await createTestUser(prisma, { email: "user@example.test" })
const result = await createPersonUserUseCase({
firstName: "New",
lastName: "Person",
department: "IT",
email: "new@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result).toEqual({ success: true })
})
})
})
+1
View File
@@ -8,6 +8,7 @@ describe("user copy helpers", () => {
MANAGER: "Gerente", MANAGER: "Gerente",
STAFF: "Personal", STAFF: "Personal",
VIEWER: "Visor", VIEWER: "Visor",
NO_USER: "Sin cuenta de usuario",
} }
const fallbackCopy = { const fallbackCopy = {
@@ -50,6 +50,7 @@ describe("admin users dictionary", () => {
MANAGER: "Manager", MANAGER: "Manager",
STAFF: "Staff", STAFF: "Staff",
VIEWER: "Viewer", VIEWER: "Viewer",
NO_USER: "No user account",
}) })
expect(users.status).toEqual({ expect(users.status).toEqual({
@@ -133,6 +134,7 @@ describe("admin users dictionary", () => {
MANAGER: "Gerente", MANAGER: "Gerente",
STAFF: "Personal", STAFF: "Personal",
VIEWER: "Visor", VIEWER: "Visor",
NO_USER: "Sin cuenta de usuario",
}) })
expect(users.status).toEqual({ expect(users.status).toEqual({
@@ -0,0 +1,263 @@
import { describe, expect, it } from "vitest"
import {
buildUnifiedCreateSchema,
unifiedFormRoleSchema,
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
const enCopy: UnifiedSchemaCopy = {
firstNameRequired: "First name is required",
lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
nameRequired: "Name is required",
userIdRequired: "User id is required",
idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID",
}
const esCopy: UnifiedSchemaCopy = {
firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
nameRequired: "El nombre es obligatorio",
userIdRequired: "El ID de usuario es obligatorio",
idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido",
}
const validPersonOnlyData = {
firstName: "John",
lastName: "Doe",
department: "IT",
email: "john@example.test",
phone: null,
role: "NO_USER" as const,
password: undefined,
isActive: true,
}
const validPersonWithUserData = {
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
email: "jane@example.test",
phone: "1234567890",
role: "ADMIN" as const,
password: "securepassword",
isActive: true,
}
describe("unifiedFormRoleSchema", () => {
it("accepts all standard roles plus NO_USER", () => {
const roles = ["ADMIN", "MANAGER", "STAFF", "VIEWER", "NO_USER"]
for (const role of roles) {
expect(unifiedFormRoleSchema.safeParse(role).success).toBe(true)
}
})
it("rejects invalid roles", () => {
const result = unifiedFormRoleSchema.safeParse("SUPER_ADMIN")
expect(result.success).toBe(false)
})
})
describe("buildUnifiedCreateSchema", () => {
describe("with NO_USER role (person-only creation)", () => {
it("accepts valid data without password when role is NO_USER", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse(validPersonOnlyData)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.firstName).toBe("John")
expect(result.data.lastName).toBe("Doe")
expect(result.data.role).toBe("NO_USER")
expect(result.data.password).toBeUndefined()
}
})
it("accepts valid data with empty string password when role is NO_USER", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
password: "",
})
expect(result.success).toBe(true)
})
it("uses localized error messages for required fields", () => {
const schema = buildUnifiedCreateSchema(esCopy)
const result = schema.safeParse({
firstName: "",
lastName: "",
department: "",
email: "not-an-email",
role: "NO_USER",
phone: null,
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.firstName).toContain(esCopy.firstNameRequired)
expect(errors.lastName).toContain(esCopy.lastNameRequired)
expect(errors.department).toContain(esCopy.departmentRequired)
expect(errors.email).toContain(esCopy.emailInvalid)
}
})
})
describe("with a real User role (person + user creation)", () => {
it("accepts valid data with password when role is ADMIN", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse(validPersonWithUserData)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.firstName).toBe("Jane")
expect(result.data.role).toBe("ADMIN")
expect(result.data.password).toBe("securepassword")
}
})
it("accepts valid data with password for all real roles", () => {
const schema = buildUnifiedCreateSchema(enCopy)
for (const role of ["ADMIN", "MANAGER", "STAFF", "VIEWER"] as const) {
const result = schema.safeParse({
...validPersonWithUserData,
role,
})
expect(result.success).toBe(true)
}
})
it("rejects short password when role is not NO_USER", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUserData,
password: "short",
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.password).toContain(enCopy.passwordMinLength)
}
})
it("rejects missing password when role is not NO_USER", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUserData,
password: undefined,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.password).toContain(enCopy.passwordMinLength)
}
})
it("rejects empty string password when role is not NO_USER", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUserData,
password: "",
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.password).toContain(enCopy.passwordMinLength)
}
})
it("uses localized password error message", () => {
const schema = buildUnifiedCreateSchema(esCopy)
const result = schema.safeParse({
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
email: "jane@example.test",
role: "ADMIN",
password: "corta",
phone: null,
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.password).toContain(esCopy.passwordMinLength)
}
})
})
describe("email validation", () => {
it("rejects invalid email format", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
email: "not-an-email",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.email).toContain(
enCopy.emailInvalid,
)
}
})
it("accepts valid email", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
email: "valid@example.com",
})
expect(result.success).toBe(true)
})
})
describe("department validation", () => {
it("rejects invalid department", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
department: "INVALID_DEPT",
})
expect(result.success).toBe(false)
})
it("accepts valid departments", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const validDepartments = [
"IT",
"ENGINEERING",
"TRAFFIC",
"DRIVER",
"LOGISTICS",
"ADMINISTRATION",
"SALES",
"OTHER",
]
for (const dept of validDepartments) {
const result = schema.safeParse({
...validPersonOnlyData,
department: dept,
})
expect(result.success).toBe(true)
}
})
})
})