feat(people): cutover Person from department to teamId (Slice 2/2) #5

Merged
aferrer merged 12 commits from feature/add-teams-slice-2 into feature/add-teams 2026-06-26 00:29:09 +00:00
52 changed files with 836 additions and 488 deletions
@@ -0,0 +1,41 @@
BEGIN;
-- Seed legacy teams from the old PersonDepartment enum English display names.
INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt")
VALUES
(gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (lower("name")) DO NOTHING;
-- Backfill Person.teamId from the legacy Person.department enum values.
UPDATE "Person"
SET "teamId" = (
SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department"
WHEN 'IT' THEN 'IT'
WHEN 'ENGINEERING' THEN 'Engineering'
WHEN 'LOGISTICS' THEN 'Logistics'
WHEN 'TRAFFIC' THEN 'Traffic'
WHEN 'DRIVER' THEN 'Driver'
WHEN 'ADMINISTRATION' THEN 'Administration'
WHEN 'SALES' THEN 'Sales'
WHEN 'OTHER' THEN 'Other'
END)
)
WHERE "department" IS NOT NULL;
-- Drop the legacy department index.
DROP INDEX "Person_department_deletedAt_idx";
-- Drop the legacy department column.
ALTER TABLE "Person" DROP COLUMN "department";
-- Drop the legacy enum type.
DROP TYPE "PersonDepartment";
COMMIT;
+3 -16
View File
@@ -110,22 +110,10 @@ model UserInvitation {
// PEOPLE
// ======================================================
enum PersonDepartment {
IT
ENGINEERING
LOGISTICS
TRAFFIC
DRIVER
ADMINISTRATION
SALES
OTHER
}
model Person {
id String @id @default(uuid(7)) @db.Uuid
firstName String
lastName String
department PersonDepartment?
id String @id @default(uuid(7)) @db.Uuid
firstName String
lastName String
email String?
phone String?
@@ -143,7 +131,6 @@ model Person {
assignments Assignment[]
@@index([lastName, firstName])
@@index([department, deletedAt])
@@index([teamId, deletedAt])
@@index([teamId])
@@index([deletedAt])
+14 -6
View File
@@ -35,14 +35,18 @@ export async function createAssignment(formData: CreateAssignmentFormType) {
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await createAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId: validatedFields.data.itemId!,
quantity: validatedFields.data.quantity!,
notes: validatedFields.data.notes,
itemId,
quantity,
notes,
},
],
actorId: createdBy,
@@ -86,14 +90,18 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await updateAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId: validatedFields.data.itemId!,
quantity: validatedFields.data.quantity!,
notes: validatedFields.data.notes,
itemId,
quantity,
notes,
},
],
actorId: createdBy,
-1
View File
@@ -303,7 +303,6 @@ export async function importItems(formData: ImportFormType) {
lastName,
email: undefined,
phone: "",
department: "OTHER",
})
} else {
newPerson = existingPerson.data[0]
+1
View File
@@ -6,6 +6,7 @@ type FieldErrors = Record<string, string[]>
const personErrorMessageKeys = {
"Email already exists": "duplicateEmail",
"Team not found": "teamNotFound",
} as const satisfies Record<string, keyof PersonActionCopy>
function isPersonErrorMessage(
+1 -1
View File
@@ -76,7 +76,7 @@ export function localizeUnifiedCreateFieldErrors(
return message
if (field === "lastName" && message === schemaCopy.lastNameRequired)
return message
if (field === "department" && message === schemaCopy.departmentRequired)
if (field === "teamId" && message === schemaCopy.teamIdInvalid)
return message
if (field === "email" && message === schemaCopy.emailInvalid)
return message
@@ -7,7 +7,10 @@ import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type { AssetDetailCopy, AssetStatusCopy } from "../_components/asset.copy"
import type {
AssetDetailCopy,
AssetStatusCopy,
} from "../_components/asset.copy"
function formatAssetStatus(
status: string,
@@ -77,7 +80,9 @@ export default async function AssetDetailPage({
<dd>{asset.serialNumber}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.assetTag}</dt>
<dt className="text-sm text-muted-foreground">
{copy.labels.assetTag}
</dt>
<dd>{asset.assetTag ?? missingValue}</dd>
</div>
<div>
@@ -119,11 +124,19 @@ export default async function AssetDetailPage({
<dd>{asset.notes ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.status}</dt>
<dd>{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}</dd>
<dt className="text-sm text-muted-foreground">
{copy.labels.status}
</dt>
<dd>
{formatAssetStatus(asset.status, statusCopy, {
unknownStatus: missingValue,
})}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.person}</dt>
<dt className="text-sm text-muted-foreground">
{copy.labels.person}
</dt>
<dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd>
</div>
</dl>
@@ -1,5 +1,6 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import EditPersonForm from "../../_components/edit.person.form"
@@ -13,6 +14,7 @@ export default async function PersonEditPage({
const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const teams = await listTeamsUseCase()
if (!person) {
return <div>{personCopy.edit.notFound}</div>
@@ -28,10 +30,8 @@ export default async function PersonEditPage({
formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles}
userFallbackCopy={userCopy.fallback}
departmentCopy={personCopy.departments}
fallbackCopy={personCopy.fallback}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
+2 -11
View File
@@ -4,7 +4,6 @@ import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "../_components/person.copy"
import {
formatUserRole,
type UserFallbackCopy,
@@ -45,16 +44,8 @@ export default async function PersonInfoPage({
<span>{person.phone}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.department}
</span>
<span>
{formatPersonDepartment(
person.department,
copy.departments,
copy.fallback,
)}
</span>
<span className="text-gray-600">{copy.detail.labels.team}</span>
<span>{person.team?.name ?? copy.fallback.noTeam}</span>
</div>
{person.user ? (
<>
@@ -12,20 +12,16 @@ import {
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { UserStatus } from "@/generated/prisma/client"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import {
buildUnifiedUpdateSchema,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import type { PersonWithUser } from "@/services/person.service"
import type { TeamSummary } from "@/types"
import {
formatPersonDepartment,
formatUserRole,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type UserFallbackCopy,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
@@ -35,19 +31,15 @@ export default function EditPersonForm({
formCopy,
schemaCopy,
roleLabels,
userFallbackCopy,
departmentCopy,
fallbackCopy,
submitButtonCopy,
teams,
}: {
person: PersonWithUser
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
userFallbackCopy: UserFallbackCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
@@ -68,7 +60,7 @@ export default function EditPersonForm({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: person.department ?? "OTHER",
teamId: person.teamId ?? null,
email: person.email ?? "",
phone: person.phone ?? "",
...(hasUser && user
@@ -116,12 +108,11 @@ export default function EditPersonForm({
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<DepartmentSelect
error={errors.department?.message}
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
departmentCopy={departmentCopy}
fallbackCopy={fallbackCopy}
register={register("department")}
register={register("teamId")}
teams={teams}
/>
<TextInput
error={errors.email?.message}
@@ -238,33 +229,31 @@ function RoleSelect({
)
}
function DepartmentSelect({
function TeamSelect({
error,
formCopy,
departmentCopy,
fallbackCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="department" className="mb-2 block text-lg">
{formCopy.departmentLabel}
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="department"
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.departmentPlaceholder}</option>
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
<option key={department} value={department}>
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
@@ -11,35 +11,27 @@ import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import {
buildUnifiedCreateSchema,
type UnifiedCreateFormType,
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
import type { TeamSummary } from "@/types"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
import type { UserFormCopy, UserRoleCopy } from "./user.copy"
export default function NewUserForm({
formCopy,
schemaCopy,
roleLabels,
departmentCopy,
fallbackCopy,
submitButtonCopy,
teams,
}: {
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
@@ -101,12 +93,11 @@ export default function NewUserForm({
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<DepartmentSelect
error={errors.department?.message}
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
departmentCopy={departmentCopy}
fallbackCopy={fallbackCopy}
register={register("department")}
register={register("teamId")}
teams={teams}
/>
<UserTextInput
error={errors.email?.message}
@@ -210,33 +201,31 @@ function RoleSelect({
)
}
function DepartmentSelect({
function TeamSelect({
error,
formCopy,
departmentCopy,
fallbackCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="department" className="mb-2 block text-lg">
{formCopy.departmentLabel}
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="department"
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.departmentPlaceholder}</option>
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
<option key={department} value={department}>
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
@@ -3,20 +3,4 @@ import type { Dictionary } from "@/i18n/dictionaries"
export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
export type PersonDepartmentCopy =
Dictionary["inventory"]["people"]["departments"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatPersonDepartment(
department: string | null | undefined,
departmentCopy: PersonDepartmentCopy,
fallbackCopy: PersonFallbackCopy,
) {
if (!department) {
return fallbackCopy.unknownDepartment
}
return department in departmentCopy
? departmentCopy[department as keyof PersonDepartmentCopy]
: fallbackCopy.unknownDepartment
}
@@ -6,8 +6,6 @@ export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
export type UserResetPasswordCopy =
Dictionary["admin"]["users"]["resetPassword"]
export type PersonDepartmentCopy =
Dictionary["inventory"]["people"]["departments"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatUserRole(
@@ -19,17 +17,3 @@ export function formatUserRole(
? roleCopy[role as keyof UserRoleCopy]
: fallbackCopy.unknownRole
}
export function formatPersonDepartment(
department: string | null | undefined,
departmentCopy: PersonDepartmentCopy,
fallbackCopy: PersonFallbackCopy,
): string {
if (!department) {
return fallbackCopy.unknownDepartment
}
return department in departmentCopy
? departmentCopy[department as keyof PersonDepartmentCopy]
: fallbackCopy.unknownDepartment
}
+3 -2
View File
@@ -1,10 +1,12 @@
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import NewPersonForm from "../_components/new.person.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const teams = await listTeamsUseCase()
return (
<div className="flex flex-col gap-4">
@@ -15,9 +17,8 @@ export default async function NewUserPage() {
formCopy={copy.form}
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
roleLabels={copy.roles}
departmentCopy={dictionary.inventory.people.departments}
fallbackCopy={dictionary.inventory.people.fallback}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
+3 -13
View File
@@ -8,11 +8,6 @@ import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
} from "./_components/person.copy"
import TeamsTab from "./_components/teams.tab"
import {
formatUserRole,
@@ -55,8 +50,7 @@ export default async function PeoplePage(props: {
const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const departmentCopy = copy.departments as PersonDepartmentCopy
const personFallbackCopy = copy.fallback as PersonFallbackCopy
const personFallbackCopy = copy.fallback
const peopleList = (
<div className="flex flex-col gap-4">
@@ -83,7 +77,7 @@ export default async function PeoplePage(props: {
{copy.list.columns.phone}
</th>
<th scope="col" className="p-4">
{copy.list.columns.department}
{copy.list.columns.team}
</th>
<th scope="col" className="p-4">
{copy.list.columns.role}
@@ -105,11 +99,7 @@ export default async function PeoplePage(props: {
<td className="p-4">{person.email}</td>
<td className="p-4">{person.phone}</td>
<td className="p-4">
{formatPersonDepartment(
person.department,
departmentCopy,
personFallbackCopy,
)}
{person.team?.name ?? personFallbackCopy.noTeam}
</td>
<td className="p-4">
{person.user
+9 -18
View File
@@ -431,7 +431,7 @@ export const en = {
name: "Name",
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
actions: "Actions",
@@ -446,7 +446,7 @@ export const en = {
labels: {
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
noUser: "No user account",
@@ -464,8 +464,8 @@ export const en = {
firstNamePlaceholder: "First name",
lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name",
departmentLabel: "Department",
departmentPlaceholder: "Select a department",
teamLabel: "Team",
teamPlaceholder: "Select a team",
emailLabel: "Email",
emailPlaceholder: "Email",
phoneLabel: "Phone",
@@ -479,19 +479,9 @@ export const en = {
updateSubmit: "Update Person",
},
fallback: {
unknownDepartment: "Unknown department",
noTeam: "",
unknownStatus: "Unknown status",
},
departments: {
IT: "IT",
ENGINEERING: "Engineering",
LOGISTICS: "Logistics",
TRAFFIC: "Traffic",
DRIVER: "Driver",
ADMINISTRATION: "Administration",
SALES: "Sales",
OTHER: "Other",
},
actions: {
createSuccess: "Person created successfully",
createFailure: "Failed to create person",
@@ -499,14 +489,15 @@ export const en = {
updateFailure: "Failed to update person",
duplicateEmail: "Email already exists",
notFound: "Person not found",
teamNotFound: "Team not found",
},
schema: {
firstNameRequired: "First name is required",
lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid",
idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
},
},
movements: {
@@ -573,8 +564,8 @@ export const en = {
firstNamePlaceholder: "First name",
lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name",
departmentLabel: "Department",
departmentPlaceholder: "Select a department",
teamLabel: "Team",
teamPlaceholder: "Select a team",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
phoneLabel: "Phone",
+9 -18
View File
@@ -437,7 +437,7 @@ export const es = {
name: "Nombre",
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
team: "Equipo",
role: "Rol",
status: "Estado",
actions: "Acciones",
@@ -452,7 +452,7 @@ export const es = {
labels: {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
team: "Equipo",
role: "Rol",
status: "Estado",
noUser: "Sin cuenta de usuario",
@@ -470,8 +470,8 @@ export const es = {
firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento",
departmentPlaceholder: "Selecciona un departamento",
teamLabel: "Equipo",
teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico",
emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono",
@@ -486,19 +486,9 @@ export const es = {
updateSubmit: "Actualizar persona",
},
fallback: {
unknownDepartment: "Departamento desconocido",
noTeam: "",
unknownStatus: "Estado desconocido",
},
departments: {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
},
actions: {
createSuccess: "Persona creada correctamente",
createFailure: "Error al crear la persona",
@@ -506,14 +496,15 @@ export const es = {
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
},
schema: {
firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
},
},
movements: {
@@ -580,8 +571,8 @@ export const es = {
firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento",
departmentPlaceholder: "Selecciona un departamento",
teamLabel: "Equipo",
teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
phoneLabel: "Teléfono",
+3 -1
View File
@@ -39,7 +39,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (!success) throw new Error("Invalid email or password")
const user = await getUserCredentialsByEmail(normalizeEmail(data.email))
const user = await getUserCredentialsByEmail(
normalizeEmail(data.email),
)
if (!user) {
throw new Error("Invalid email or password")
-11
View File
@@ -8,17 +8,6 @@ export const SIGN_IN_URL = "/login"
export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour
export const PERSON_DEPARTMENTS = {
IT: "IT",
ENGINEERING: "ENGINEERING",
LOGISTICS: "LOGISTICS",
TRAFFIC: "TRAFFIC",
DRIVER: "DRIVER",
ADMINISTRATION: "ADMINISTRATION",
SALES: "SALES",
OTHER: "OTHER",
} as const
export const ITEM_STATUS = {
AVAILABLE: "AVAILABLE",
ASSIGNED: "ASSIGNED",
+16 -12
View File
@@ -44,12 +44,14 @@ function buildTrackingTypeSchema(copy: ItemSchemaCopy) {
}
function buildOptionalTrackingTypeSchema(copy: ItemSchemaCopy) {
return z.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.enum(itemTrackingTypes, {
error: () => copy.invalidTrackingType,
}),
).optional()
return z
.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.enum(itemTrackingTypes, {
error: () => copy.invalidTrackingType,
}),
)
.optional()
}
function buildStatusSchema(copy: ItemSchemaCopy) {
@@ -65,12 +67,14 @@ function buildStatusSchema(copy: ItemSchemaCopy) {
}
function buildOptionalStatusSchema(copy: ItemSchemaCopy) {
return z.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.enum(itemStatuses, {
error: () => copy.invalidStatus,
}),
).optional()
return z
.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.enum(itemStatuses, {
error: () => copy.invalidStatus,
}),
)
.optional()
}
function buildOptionalReasonSchema() {
+6 -15
View File
@@ -7,23 +7,12 @@ export type PersonSchemaCopy = Dictionary["inventory"]["people"]["schema"]
const defaultPersonSchemaCopy: PersonSchemaCopy = {
firstNameRequired: "First name is required",
lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid",
idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
}
export const personDepartments = [
"IT",
"ENGINEERING",
"TRAFFIC",
"DRIVER",
"LOGISTICS",
"ADMINISTRATION",
"SALES",
"OTHER",
] as const
function buildPersonBaseSchema(copy: PersonSchemaCopy) {
return z.object({
id: z.string().optional(),
@@ -33,9 +22,11 @@ function buildPersonBaseSchema(copy: PersonSchemaCopy) {
lastName: z.string().min(1, {
error: copy.lastNameRequired,
}),
department: z.enum(personDepartments, {
error: copy.departmentRequired,
}),
teamId: z
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
.transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
userId: z
+10 -7
View File
@@ -1,7 +1,6 @@
import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries"
import { personDepartments } from "@/schemas/person.schema"
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
@@ -93,9 +92,11 @@ export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
id: z.string().nonempty(copy.idRequired),
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,
}),
teamId: z
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
.transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
.optional(),
@@ -129,9 +130,11 @@ export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
.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,
}),
teamId: z
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
.transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z.email({ error: copy.emailInvalid }),
phone: z.string().optional().nullable(),
role: unifiedFormRoleSchema,
+5 -1
View File
@@ -1,4 +1,8 @@
import type { AssetStatus, InventoryMovementType, Prisma } from "@/generated/prisma/client"
import type {
AssetStatus,
InventoryMovementType,
Prisma,
} from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import type { CreateMovementFormType } from "@/schemas/movement.schema"
+6
View File
@@ -4,6 +4,12 @@ import prisma from "@/lib/prisma"
const personWithUserSelect = {
include: {
team: {
select: {
id: true,
name: true,
},
},
user: {
select: {
id: true,
+1 -1
View File
@@ -1,7 +1,7 @@
import type {
Prisma,
Asset as PrismaAsset,
AssetStatus as PrismaAssetStatus,
Prisma,
} from "@/generated/prisma/client"
import type { Assignment } from "./assignment"
+3 -4
View File
@@ -9,8 +9,8 @@ type FieldErrors = Record<string, string[]>
type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> &
Partial<Pick<CreateItemData, "trackingType" | "status">> & {
actorId: string
}
actorId: string
}
type UpdateItemUseCaseInput = UpdateItemData & {
actorId: string
@@ -145,8 +145,7 @@ export async function updateItemUseCase(
return itemError({ name: ["An item with this name already exists"] })
}
const effectiveTrackingType =
trackingType ?? existingItem.trackingType
const effectiveTrackingType = trackingType ?? existingItem.trackingType
const isSerialized = effectiveTrackingType === "SERIALIZED"
await ItemService.update(
+88 -15
View File
@@ -11,6 +11,7 @@ import type {
UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import { PersonService } from "@/services/person.service"
import { TeamService } from "@/services/team.service"
import { getUserByEmail } from "@/services/user.service"
type FieldErrors = Record<string, string[]>
@@ -48,10 +49,70 @@ function uniqueErrorFor(error: unknown): FieldErrors | null {
return { email: ["Email already exists"] }
}
function foreignKeyErrorFor(error: unknown): FieldErrors | null {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== "P2003"
) {
return null
}
const fieldName = error.meta?.field_name
if (fieldName === "Person_teamId_fkey" || fieldName === "teamId") {
return { teamId: ["Team not found"] }
}
return null
}
function errorFor(error: unknown): FieldErrors | null {
return uniqueErrorFor(error) ?? foreignKeyErrorFor(error)
}
function teamRelationInputForCreate(teamId: string | null | undefined) {
if (teamId) {
return { team: { connect: { id: teamId } } }
}
return {}
}
function teamRelationInputForUpdate(teamId: string | null | undefined) {
if (teamId) {
return { team: { connect: { id: teamId } } }
}
return { team: { disconnect: true } }
}
function userRelationInputForCreate(userId: string | null | undefined) {
if (userId) {
return { user: { connect: { id: userId } } }
}
return {}
}
async function validateTeamId(
teamId: string | null | undefined,
tx: Prisma.TransactionClient,
): Promise<PersonUseCaseResult | null> {
if (!teamId) return null
const team = await TeamService.findById(teamId, tx)
if (!team) {
return personError({ teamId: ["Team not found"] })
}
return null
}
export async function createPersonUseCase(
input: CreatePersonFormType,
): Promise<PersonUseCaseResult> {
const { firstName, lastName, department, email, phone, userId } = input
const { firstName, lastName, teamId, email, phone, userId } = input
try {
return await prisma.$transaction(async (tx) => {
@@ -63,14 +124,17 @@ export async function createPersonUseCase(
}
}
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.create(
{
firstName,
lastName,
department,
...teamRelationInputForCreate(teamId),
email: email || null,
phone: phone || null,
...(userId ? { user: { connect: { id: userId } } } : {}),
...userRelationInputForCreate(userId),
},
tx,
)
@@ -80,7 +144,7 @@ export async function createPersonUseCase(
}
})
} catch (error) {
const errors = uniqueErrorFor(error)
const errors = errorFor(error)
if (errors) {
return personError(errors)
@@ -93,7 +157,7 @@ export async function createPersonUseCase(
export async function updatePersonUseCase(
input: UpdatePersonFormType,
): Promise<PersonUseCaseResult> {
const { id, firstName, lastName, department, email, phone, userId } = input
const { id, firstName, lastName, teamId, email, phone, userId } = input
try {
return await prisma.$transaction(async (tx) => {
@@ -105,17 +169,20 @@ export async function updatePersonUseCase(
}
}
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.update(
id,
{
firstName,
lastName,
department,
...teamRelationInputForUpdate(teamId),
email: email || null,
phone: phone || null,
...(userId
? { user: { connect: { id: userId } } }
: { userId: null }),
: { user: { disconnect: true } }),
},
tx,
)
@@ -125,7 +192,7 @@ export async function updatePersonUseCase(
}
})
} catch (error) {
const errors = uniqueErrorFor(error)
const errors = errorFor(error)
if (errors) {
return personError(errors)
@@ -141,7 +208,7 @@ export async function createPersonUserUseCase(
const {
firstName,
lastName,
department,
teamId,
email,
phone,
role,
@@ -162,13 +229,16 @@ export async function createPersonUserUseCase(
return personError({ email: ["Email already exists"] })
}
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
if (role === "NO_USER") {
// Person-only creation — no User record
await PersonService.create(
{
firstName,
lastName,
department,
...teamRelationInputForCreate(teamId),
email,
phone: phone ?? null,
},
@@ -187,7 +257,7 @@ export async function createPersonUserUseCase(
{
firstName,
lastName,
department,
...teamRelationInputForCreate(teamId),
email,
phone: phone ?? null,
},
@@ -221,7 +291,7 @@ export async function createPersonUserUseCase(
return { success: true }
})
} catch (error) {
const errors = uniqueErrorFor(error)
const errors = errorFor(error)
if (errors) {
return personError(errors)
@@ -238,7 +308,7 @@ export async function updatePersonUserUseCase(
id,
firstName,
lastName,
department,
teamId,
email,
phone,
role,
@@ -261,12 +331,15 @@ export async function updatePersonUserUseCase(
}
}
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.update(
id,
{
firstName,
lastName,
department,
...teamRelationInputForUpdate(teamId),
email: email || null,
phone: phone || null,
},
@@ -302,7 +375,7 @@ export async function updatePersonUserUseCase(
return { success: true }
})
} catch (error) {
const errors = uniqueErrorFor(error)
const errors = errorFor(error)
if (errors) {
return personError(errors)
+1 -1
View File
@@ -27,7 +27,7 @@ async function createPerson(page: Page, name: string, email: string) {
await page.goto("/people/new")
await page.getByLabel("Nombre").fill(name)
await page.getByLabel("Apellido").fill("E2E")
await page.getByLabel("Departamento").selectOption("OTHER")
await page.getByLabel("Equipo").selectOption({ label: "Other" })
await page.getByLabel("Correo electrónico").fill(email)
await page.getByLabel("Teléfono").fill("123456789")
await page.getByLabel("Rol").selectOption("NO_USER")
+142
View File
@@ -0,0 +1,142 @@
import { expect, type Page, test } from "@playwright/test"
async function setLocaleCookie(
page: Page,
locale: "en" | "es",
baseURL?: string,
) {
await page.context().addCookies([
{
name: "stock-manager-locale",
value: locale,
url: baseURL ?? "http://127.0.0.1:3100",
},
])
}
async function signInAsAdmin(page: Page, baseURL?: string) {
await setLocaleCookie(page, "en", baseURL)
await page.goto("/login")
await page.getByLabel("Email").fill("admin@example.test")
await page.getByLabel("Password").fill("admin-password")
await page.getByRole("button", { name: "Sign In" }).click()
await expect(page).toHaveURL("/")
}
async function createTeam(page: Page, name: string) {
await page.goto("/people?tab=teams")
await page.getByLabel("Team name").fill(name)
await page.getByRole("button", { name: "Create Team" }).click()
await expect(page.getByText("Team created successfully")).toBeVisible()
}
test.describe("people and teams", () => {
test("switches between people and teams tabs via URL", async ({
baseURL,
page,
}) => {
await signInAsAdmin(page, baseURL)
await page.goto("/people")
const sections = page.getByRole("navigation", { name: "People sections" })
await expect(sections).toBeVisible()
await page.goto("/people?tab=people")
await expect(
sections.getByRole("link", { name: "People" }),
).toHaveAttribute("aria-current", "page")
await page.goto("/people?tab=teams")
await expect(sections.getByRole("link", { name: "Teams" })).toHaveAttribute(
"aria-current",
"page",
)
await expect(page.getByLabel("Team name")).toBeVisible()
await page.goto("/people?tab=invalid")
await expect(
sections.getByRole("link", { name: "People" }),
).toHaveAttribute("aria-current", "page")
})
test("creates, renames, and deletes a team", async ({ baseURL, page }) => {
const timestamp = Date.now()
const originalName = `E2E Team ${timestamp}`
const updatedName = `E2E Team Updated ${timestamp}`
await signInAsAdmin(page, baseURL)
await createTeam(page, originalName)
const row = page.getByRole("row", { name: new RegExp(originalName) })
await expect(row).toBeVisible()
await row.getByRole("button", { name: "Edit team" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await dialog.getByLabel("Team name").fill(updatedName)
await dialog.getByRole("button", { name: "Update Team" }).click()
await expect(dialog).not.toBeVisible()
await expect(page.getByText("Team updated successfully")).toBeVisible()
await expect(page.getByRole("row", { name: updatedName })).toBeVisible()
await page
.getByRole("row", { name: updatedName })
.getByRole("button", { name: "Delete team" })
.click()
await expect(page.getByText("Team deleted successfully")).toBeVisible()
await expect(page.getByRole("row", { name: updatedName })).not.toBeVisible()
})
test("creates a person with a team and shows the team in the list", async ({
baseURL,
page,
}) => {
const timestamp = Date.now()
const teamName = `E2E Person Team ${timestamp}`
const personName = `E2E Person ${timestamp}`
await signInAsAdmin(page, baseURL)
await createTeam(page, teamName)
await page.goto("/people/new")
await page.getByLabel("First Name").fill(personName)
await page.getByLabel("Last Name").fill("E2E")
await page.getByLabel("Team").selectOption({ label: teamName })
await page.getByLabel("Email").fill(`e2e-${timestamp}@example.test`)
await page.getByLabel("Phone").fill("123456789")
await page.getByLabel("Role").selectOption("NO_USER")
await page.getByRole("button", { name: "Create User" }).click()
await expect(page.getByText("User created successfully")).toBeVisible()
await page.goto("/people?tab=people")
const row = page.getByRole("row", { name: new RegExp(personName) })
await expect(row).toContainText(teamName)
await row.getByRole("link", { name: "View person" }).click()
await expect(page.getByText(teamName)).toBeVisible()
})
test("shows no team fallback for a person without a team", async ({
baseURL,
page,
}) => {
const timestamp = Date.now()
const personName = `E2E No Team ${timestamp}`
await signInAsAdmin(page, baseURL)
await page.goto("/people/new")
await page.getByLabel("First Name").fill(personName)
await page.getByLabel("Last Name").fill("E2E")
await page.getByLabel("Team").selectOption({ value: "" })
await page.getByLabel("Email").fill(`e2e-noteam-${timestamp}@example.test`)
await page.getByLabel("Phone").fill("123456789")
await page.getByLabel("Role").selectOption("NO_USER")
await page.getByRole("button", { name: "Create User" }).click()
await expect(page.getByText("User created successfully")).toBeVisible()
await page.goto("/people?tab=people")
const row = page.getByRole("row", { name: new RegExp(personName) })
await expect(row).toContainText("—")
})
})
+16 -20
View File
@@ -1,8 +1,4 @@
import type {
PersonDepartment,
PrismaClient,
UserRole,
} from "@/generated/prisma/client"
import type { PrismaClient, UserRole } from "@/generated/prisma/client"
import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
@@ -54,12 +50,25 @@ export async function createTestCategory(
})
}
export async function createTestTeam(
prisma: PrismaClient,
overrides: Partial<{ name: string }> = {},
) {
const suffix = nextSuffix()
return prisma.team.create({
data: {
name: overrides.name ?? `Test Team ${suffix}`,
},
})
}
export async function createTestPerson(
prisma: PrismaClient,
overrides: Partial<{
firstName: string
lastName: string
department: PersonDepartment
teamId: string | null
email: string | null
phone: string | null
}> = {},
@@ -70,26 +79,13 @@ export async function createTestPerson(
data: {
firstName: overrides.firstName ?? "Test",
lastName: overrides.lastName ?? `Person-${suffix}`,
department: overrides.department ?? "OTHER",
teamId: overrides.teamId ?? null,
email: overrides.email ?? null,
phone: overrides.phone ?? null,
},
})
}
export async function createTestTeam(
prisma: PrismaClient,
overrides: Partial<{ name: string }> = {},
) {
const suffix = nextSuffix()
return prisma.team.create({
data: {
name: overrides.name ?? `Test Team ${suffix}`,
},
})
}
export async function createTestItem(
prisma: PrismaClient,
overrides: Partial<{
@@ -1,7 +1,11 @@
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 {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -33,10 +37,12 @@ afterAll(async () => {
describe("createPersonUserUseCase", () => {
describe("NO_USER role (person-only creation)", () => {
it("creates a Person without a User record when role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: team.id,
email: "john@example.test",
phone: null,
role: "NO_USER",
@@ -51,7 +57,7 @@ describe("createPersonUserUseCase", () => {
expect(person).toMatchObject({
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: team.id,
email: "john@example.test",
phone: null,
userId: null,
@@ -66,10 +72,12 @@ describe("createPersonUserUseCase", () => {
})
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
teamId: team.id,
email: "jane-noemail@example.test",
phone: "555-1234",
role: "NO_USER",
@@ -87,10 +95,12 @@ describe("createPersonUserUseCase", () => {
describe("real role (person + user creation)", () => {
it("creates Person and User with linked userId when role is ADMIN", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({
firstName: "Admin",
lastName: "User",
department: "IT",
teamId: team.id,
email: "admin@example.test",
phone: null,
role: "ADMIN",
@@ -101,12 +111,12 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({
where: { firstName: "Admin", lastName: "User" },
where: { firstName: "Admin" },
})
expect(person).toMatchObject({
firstName: "Admin",
lastName: "User",
department: "IT",
teamId: team.id,
email: "admin@example.test",
})
@@ -128,6 +138,7 @@ describe("createPersonUserUseCase", () => {
})
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
const team = await createTestTeam(prisma)
const roles = ["MANAGER", "STAFF", "VIEWER"] as const
for (const role of roles) {
@@ -135,7 +146,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({
firstName: "Person",
lastName: suffix,
department: "IT",
teamId: team.id,
email: `${suffix}@example.test`,
phone: null,
role,
@@ -160,10 +171,12 @@ describe("createPersonUserUseCase", () => {
})
it("derives User.name from firstName + lastName", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({
firstName: "Maria",
lastName: "Garcia",
department: "SALES",
teamId: team.id,
email: "maria@example.test",
phone: null,
role: "STAFF",
@@ -178,10 +191,12 @@ describe("createPersonUserUseCase", () => {
})
it("hashes the password when creating a User", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({
firstName: "Hash",
lastName: "Test",
department: "IT",
teamId: team.id,
email: "hash-test@example.test",
phone: null,
role: "STAFF",
@@ -196,20 +211,21 @@ describe("createPersonUserUseCase", () => {
if (!user.passwordHash) throw new Error("Expected password hash")
const { verifyPassword } = await import("@/lib/security")
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe(
true,
)
expect(
await verifyPassword("plaintext-password", user.passwordHash),
).toBe(true)
})
})
describe("cross-table email uniqueness", () => {
it("rejects submission when email already exists in Person table", async () => {
const team = await createTestTeam(prisma)
await createTestPerson(prisma, { email: "existing-person@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "Person",
department: "IT",
teamId: team.id,
email: "existing-person@example.test",
phone: null,
role: "NO_USER",
@@ -225,12 +241,13 @@ describe("createPersonUserUseCase", () => {
})
it("rejects submission when email already exists in User table", async () => {
const team = await createTestTeam(prisma)
await createTestUser(prisma, { email: "existing-user@example.test" })
const result = await createPersonUserUseCase({
firstName: "Duplicate",
lastName: "User",
department: "IT",
teamId: team.id,
email: "existing-user@example.test",
phone: null,
role: "STAFF",
@@ -249,6 +266,7 @@ describe("createPersonUserUseCase", () => {
})
it("accepts submission when email is unique across both tables", async () => {
const team = await createTestTeam(prisma)
// Create a Person and a User with different emails
await createTestPerson(prisma, { email: "person@example.test" })
await createTestUser(prisma, { email: "user@example.test" })
@@ -256,7 +274,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({
firstName: "New",
lastName: "Person",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: null,
role: "NO_USER",
@@ -266,4 +284,24 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true })
})
})
describe("team validation", () => {
it("rejects an unknown team id", async () => {
const result = await createPersonUserUseCase({
firstName: "No",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "no-team@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
})
})
@@ -1,6 +1,10 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -40,7 +44,7 @@ describe("person use-cases", () => {
createPersonUseCase({
firstName: "Person",
lastName: "One",
department: "IT",
teamId: null,
email: "",
phone: "",
}),
@@ -53,7 +57,7 @@ describe("person use-cases", () => {
).toMatchObject({
firstName: "Person",
lastName: "One",
department: "IT",
teamId: null,
email: null,
phone: null,
userId: null,
@@ -62,12 +66,13 @@ describe("person use-cases", () => {
it("creates a person with linked userId", async () => {
const user = await createTestUser(prisma)
const team = await createTestTeam(prisma)
await expect(
createPersonUseCase({
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "linked@example.test",
phone: null,
userId: user.id,
@@ -81,7 +86,7 @@ describe("person use-cases", () => {
).toMatchObject({
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "linked@example.test",
userId: user.id,
})
@@ -96,7 +101,7 @@ describe("person use-cases", () => {
createPersonUseCase({
firstName: "Duplicate",
lastName: "Email",
department: "OTHER",
teamId: null,
email: "existing@example.test",
phone: null,
}),
@@ -108,7 +113,24 @@ describe("person use-cases", () => {
expect(await prisma.person.count()).toBe(1)
})
it("rejects an unknown team id on create", async () => {
const result = await createPersonUseCase({
firstName: "Unknown",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "unknown-team@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
it("updates a person and rejects duplicate emails", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
email: "person@example.test",
phone: "111111111",
@@ -122,16 +144,18 @@ describe("person use-cases", () => {
id: person.id,
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "edited@example.test",
phone: "222222222",
}),
).resolves.toEqual({ success: true })
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "edited@example.test",
phone: "222222222",
})
@@ -141,7 +165,7 @@ describe("person use-cases", () => {
id: person.id,
firstName: "Edited",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: other.email,
phone: "222222222",
}),
@@ -150,12 +174,37 @@ describe("person use-cases", () => {
errors: { email: ["Email already exists"] },
})
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
email: "edited@example.test",
})
expect(await prisma.person.count()).toBe(2)
})
it("updates a person team to null", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
teamId: team.id,
})
await expect(
updatePersonUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: null,
email: person.email,
phone: person.phone,
}),
).resolves.toEqual({ success: true })
const updated = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
})
expect(updated.teamId).toBeNull()
})
it("searches by email and name in paginated results", async () => {
await createTestPerson(prisma, {
firstName: "Alice",
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
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 {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
@@ -34,6 +38,7 @@ afterAll(async () => {
describe("updatePersonUserUseCase", () => {
describe("person-only update", () => {
it("updates only the Person when person has no linked User", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
firstName: "Old",
lastName: "Name",
@@ -44,7 +49,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "New",
lastName: "Name",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: "1234",
})
@@ -57,7 +62,7 @@ describe("updatePersonUserUseCase", () => {
expect(updated).toMatchObject({
firstName: "New",
lastName: "Name",
department: "IT",
teamId: team.id,
email: "new@example.test",
phone: "1234",
userId: null,
@@ -71,7 +76,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Empty",
lastName: "Email",
department: "OTHER",
teamId: null,
email: "",
phone: null,
})
@@ -87,6 +92,7 @@ describe("updatePersonUserUseCase", () => {
describe("person+user update", () => {
it("updates Person fields and User role/isActive when person has a User linked", async () => {
const team = await createTestTeam(prisma)
const user = await createTestUser(prisma, {
email: "user-update@example.test",
name: "Old Name",
@@ -107,7 +113,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
teamId: team.id,
email: "user-update@example.test",
phone: null,
role: "ADMIN",
@@ -120,7 +126,7 @@ describe("updatePersonUserUseCase", () => {
where: { id: person.id },
include: { user: true },
})
expect(updatedPerson.department).toBe("ENGINEERING")
expect(updatedPerson.teamId).toBe(team.id)
expect(updatedPerson.user).toMatchObject({
id: user.id,
role: "ADMIN",
@@ -144,7 +150,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
teamId: null,
email: "pw-reset@example.test",
phone: null,
role: "STAFF",
@@ -190,7 +196,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
teamId: null,
email: "no-pw@example.test",
phone: null,
role: "STAFF",
@@ -216,7 +222,7 @@ describe("updatePersonUserUseCase", () => {
id: "00000000-0000-0000-0000-000000000000",
firstName: "Ghost",
lastName: "Person",
department: "OTHER",
teamId: null,
email: "ghost@example.test",
phone: null,
})
@@ -239,7 +245,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id,
firstName: "Mine",
lastName: "Person",
department: "OTHER",
teamId: null,
email: "theirs@example.test",
phone: null,
})
@@ -249,5 +255,23 @@ describe("updatePersonUserUseCase", () => {
errors: { email: ["Email already exists"] },
})
})
it("rejects an unknown team id", async () => {
const person = await createTestPerson(prisma)
const result = await updatePersonUserUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: "00000000-0000-0000-0000-000000000000",
email: person.email,
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
})
})
})
@@ -147,7 +147,9 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
@@ -168,7 +170,9 @@ describe("user use-cases", () => {
errors: { email: ["Email already exists"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
email: "edited@example.test",
})
})
@@ -190,7 +194,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot remove your own administrator access"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
role: "ADMIN",
status: "ACTIVE",
})
@@ -232,7 +238,9 @@ describe("user use-cases", () => {
}),
).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).toMatchObject({
status: "DISABLED",
})
})
@@ -252,7 +260,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot deactivate your own user"] },
})
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
status: "ACTIVE",
})
})
+8 -5
View File
@@ -25,6 +25,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
import { createNewPerson, updatePerson } from "@/actions/person.actions"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("person actions localization", () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -35,7 +37,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({
firstName: "",
lastName: "",
department: "",
teamId: "not-a-uuid",
email: "not-an-email",
} as unknown as Parameters<typeof createNewPerson>[0])
@@ -46,7 +48,8 @@ describe("person actions localization", () => {
errors: {
firstName: [es.inventory.people.schema.firstNameRequired],
lastName: [es.inventory.people.schema.lastNameRequired],
department: [es.inventory.people.schema.departmentRequired],
teamId: [es.inventory.people.schema.teamIdInvalid],
email: [es.inventory.people.schema.emailInvalid],
},
})
})
@@ -62,7 +65,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
})
@@ -83,7 +86,7 @@ describe("person actions localization", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
})
@@ -98,7 +101,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
userId: "not-a-uuid",
} as unknown as Parameters<typeof createNewPerson>[0])
@@ -9,6 +9,7 @@ const actionCopy = {
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
}
describe("person action message localization", () => {
@@ -25,6 +26,19 @@ describe("person action message localization", () => {
})
})
it("localizes team not found errors", () => {
expect(
localizePersonFieldErrors(
{
teamId: ["Team not found"],
},
actionCopy,
),
).toEqual({
teamId: [actionCopy.teamNotFound],
})
})
it("keeps unknown messages unchanged", () => {
expect(
localizePersonFieldErrors(
@@ -28,6 +28,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
import { updatePersonUserAction } from "@/actions/person.actions"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("updatePersonUserAction", () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -41,7 +43,7 @@ describe("updatePersonUserAction", () => {
id: "",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
})
@@ -60,7 +62,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "not-an-email",
phone: null,
})
@@ -74,12 +76,31 @@ describe("updatePersonUserAction", () => {
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects invalid teamId with localized teamIdInvalid error", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
teamId: "not-a-uuid",
email: "ada@example.test",
phone: null,
})
expect(result).toEqual({
success: false,
errors: {
teamId: [es.inventory.people.schema.teamIdInvalid],
},
})
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects short password when role is provided", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
role: "ADMIN",
@@ -101,7 +122,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
role: "NO_USER" as unknown as "ADMIN",
@@ -117,7 +138,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
role: "SUPER_ADMIN" as unknown as "ADMIN",
@@ -140,7 +161,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "duplicate@example.test",
phone: null,
})
@@ -164,7 +185,7 @@ describe("updatePersonUserAction", () => {
id: "missing",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
})
@@ -184,7 +205,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
})
@@ -204,7 +225,7 @@ describe("updatePersonUserAction", () => {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "ada@example.test",
phone: null,
})
@@ -24,7 +24,7 @@ vi.mock("@/components/common/pageheader", () => ({
vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children),
createElement("button", { type: "button" }, children),
}))
vi.mock("next/link", () => ({
@@ -109,7 +109,9 @@ describe("asset detail page", () => {
)
const html = renderToStaticMarkup(
await AssetDetailPage({ params: Promise.resolve({ assetId: "asset-1" }) }),
await AssetDetailPage({
params: Promise.resolve({ assetId: "asset-1" }),
}),
)
expect(html).toContain("Asset Details")
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock("@/components/common/pagination", () => ({
vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children),
createElement("button", { type: "button" }, children),
}))
vi.mock("next/link", () => ({
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findByIdWithUser: vi.fn(),
findById: vi.fn(),
listTeamsUseCase: vi.fn(),
personForm: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
@@ -27,6 +28,10 @@ vi.mock("@/services/person.service", () => ({
},
}))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
default: (props: unknown) => {
mocks.personForm(props)
@@ -54,8 +59,8 @@ const basePerson: PersonWithUser = {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
team: null,
email: "ada@example.test",
phone: "1234",
userId: null,
@@ -80,10 +85,16 @@ const personWithUser: PersonWithUser = {
},
}
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("edit person page wiring", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
})
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
@@ -112,6 +123,7 @@ describe("edit person page wiring", () => {
...en.inventory.people.schema,
},
roleLabels: en.admin.users.roles,
teams,
}),
)
})
@@ -142,10 +154,7 @@ describe("edit person page wiring", () => {
}),
formCopy: es.admin.users.form,
roleLabels: es.admin.users.roles,
departmentCopy: es.inventory.people.departments,
fallbackCopy: expect.objectContaining({
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
}),
teams,
}),
)
})
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findByIdWithUser: vi.fn(),
findById: vi.fn(),
listTeamsUseCase: vi.fn(),
redirect: vi.fn(),
personForm: vi.fn(),
}))
@@ -22,6 +23,10 @@ vi.mock("@/services/person.service", () => ({
},
}))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("next/navigation", () => ({
redirect: mocks.redirect,
useRouter: () => ({
@@ -49,10 +54,16 @@ vi.mock("sonner", () => ({
},
}))
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("person pages", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
})
it("renders the edit person page with Person heading and passes person to unified form", async () => {
@@ -66,7 +77,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -88,6 +100,7 @@ describe("person pages", () => {
firstName: "Ada",
lastName: "Lovelace",
}),
teams,
}),
)
})
+14 -8
View File
@@ -67,7 +67,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -87,7 +88,7 @@ describe("person pages", () => {
expect(html).toContain("Add Person")
// No username column — username header must not appear
expect(html).not.toContain("Username")
// No standalone username cell — only name, email, phone, department columns
// No standalone username cell — only name, email, phone, team columns
expect(html).not.toContain(">ada<")
// Name and other fields rendered
expect(html).toContain("Ada Lovelace")
@@ -110,7 +111,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: "user-1",
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -137,7 +139,8 @@ describe("person pages", () => {
lastName: "Jones",
email: "bob@example.test",
phone: null,
department: "IT",
teamId: null,
team: null,
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -193,7 +196,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
teamId: "team-2",
team: { id: "team-2", name: "Driver" },
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -221,7 +225,7 @@ describe("person pages", () => {
// Person detail fields
expect(html).toContain("Email")
expect(html).toContain("Phone")
expect(html).toContain("Department")
expect(html).toContain("Team")
expect(html).toContain("ada@example.test")
expect(html).toContain("Driver")
// Embedded assignments
@@ -239,7 +243,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
teamId: null,
team: null,
userId: "user-1",
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -287,7 +292,8 @@ describe("person pages", () => {
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
teamId: null,
team: null,
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
@@ -7,6 +7,7 @@ import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
createPersonUser: vi.fn(),
getI18n: vi.fn(),
listTeamsUseCase: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
@@ -26,6 +27,10 @@ vi.mock("@/services/person.service", () => ({
},
}))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mocks.push,
@@ -39,10 +44,16 @@ vi.mock("sonner", () => ({
},
}))
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("unified creation form page", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
})
it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => {
@@ -55,7 +66,7 @@ describe("unified creation form page", () => {
// Person fields
expect(html).toContain("Nombre")
expect(html).toContain("Apellido")
expect(html).toContain("Departamento")
expect(html).toContain("Equipo")
expect(html).toContain("Teléfono")
// User fields
@@ -86,7 +97,7 @@ describe("unified creation form page", () => {
// Person fields
expect(html).toContain("First Name")
expect(html).toContain("Last Name")
expect(html).toContain("Department")
expect(html).toContain("Team")
expect(html).toContain("Phone")
// User fields
@@ -108,18 +119,20 @@ describe("unified creation form page", () => {
// Person field placeholders
expect(html).toContain('placeholder="Nombre"') // firstNamePlaceholder (es)
expect(html).toContain('placeholder="Apellido"') // lastNamePlaceholder (es)
expect(html).toContain("Selecciona un departamento") // departmentPlaceholder
expect(html).toContain("Selecciona un equipo") // teamPlaceholder
expect(html).toContain('placeholder="Teléfono"') // phonePlaceholder (es)
})
it("renders department select with all PERSON_DEPARTMENTS values", async () => {
it("renders team select with active teams from listTeamsUseCase", async () => {
const { default: NewUserPage } = await import(
"@/app/(dashboard)/people/new/page"
)
const html = renderToStaticMarkup(await NewUserPage())
// Department values must use canonical enum values
expect(html).toContain('value="ADMINISTRATION"')
expect(html).toContain('value="team-1"')
expect(html).toContain("Engineering")
expect(html).toContain('value="team-2"')
expect(html).toContain("Sales")
})
})
+1 -49
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest"
import {
formatPersonDepartment,
formatUserRole,
} from "@/app/(dashboard)/people/_components/user.copy"
import { formatUserRole } from "@/app/(dashboard)/people/_components/user.copy"
describe("user copy helpers", () => {
const roleCopy = {
@@ -37,48 +34,3 @@ describe("user copy helpers", () => {
).toBe("Rol desconocido")
})
})
describe("formatPersonDepartment helper", () => {
const departmentCopy = {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
}
const fallbackCopy = {
unknownDepartment: "Departamento desconocido",
unknownStatus: "Estado desconocido",
}
it("formats known department values with localized labels", () => {
expect(
formatPersonDepartment("ENGINEERING", departmentCopy, fallbackCopy),
).toBe("Ingeniería")
expect(
formatPersonDepartment("ADMINISTRATION", departmentCopy, fallbackCopy),
).toBe("Administración")
})
it("falls back for unknown department values", () => {
expect(
formatPersonDepartment("UNKNOWN_DEPT", departmentCopy, fallbackCopy),
).toBe("Departamento desconocido")
})
it("falls back for null department values", () => {
expect(formatPersonDepartment(null, departmentCopy, fallbackCopy)).toBe(
"Departamento desconocido",
)
})
it("falls back for undefined department values", () => {
expect(
formatPersonDepartment(undefined, departmentCopy, fallbackCopy),
).toBe("Departamento desconocido")
})
})
@@ -32,8 +32,8 @@ describe("admin users dictionary", () => {
firstNamePlaceholder: "First name",
lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name",
departmentLabel: "Department",
departmentPlaceholder: "Select a department",
teamLabel: "Team",
teamPlaceholder: "Select a team",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
phoneLabel: "Phone",
@@ -127,8 +127,8 @@ describe("admin users dictionary", () => {
firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento",
departmentPlaceholder: "Selecciona un departamento",
teamLabel: "Equipo",
teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
phoneLabel: "Teléfono",
+14 -32
View File
@@ -832,7 +832,7 @@ describe("i18n dictionaries", () => {
name: "Name",
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
actions: "Actions",
@@ -847,7 +847,7 @@ describe("i18n dictionaries", () => {
labels: {
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
noUser: "No user account",
@@ -865,8 +865,8 @@ describe("i18n dictionaries", () => {
firstNamePlaceholder: "First name",
lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name",
departmentLabel: "Department",
departmentPlaceholder: "Select a department",
teamLabel: "Team",
teamPlaceholder: "Select a team",
emailLabel: "Email",
emailPlaceholder: "Email",
phoneLabel: "Phone",
@@ -880,19 +880,9 @@ describe("i18n dictionaries", () => {
updateSubmit: "Update Person",
},
fallback: {
unknownDepartment: "Unknown department",
noTeam: "",
unknownStatus: "Unknown status",
},
departments: {
IT: "IT",
ENGINEERING: "Engineering",
LOGISTICS: "Logistics",
TRAFFIC: "Traffic",
DRIVER: "Driver",
ADMINISTRATION: "Administration",
SALES: "Sales",
OTHER: "Other",
},
actions: {
createSuccess: "Person created successfully",
createFailure: "Failed to create person",
@@ -900,14 +890,15 @@ describe("i18n dictionaries", () => {
updateFailure: "Failed to update person",
duplicateEmail: "Email already exists",
notFound: "Person not found",
teamNotFound: "Team not found",
},
schema: {
firstNameRequired: "First name is required",
lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid",
idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
},
})
@@ -920,7 +911,7 @@ describe("i18n dictionaries", () => {
name: "Nombre",
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
team: "Equipo",
role: "Rol",
status: "Estado",
actions: "Acciones",
@@ -935,7 +926,7 @@ describe("i18n dictionaries", () => {
labels: {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
team: "Equipo",
role: "Rol",
status: "Estado",
noUser: "Sin cuenta de usuario",
@@ -953,8 +944,8 @@ describe("i18n dictionaries", () => {
firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento",
departmentPlaceholder: "Selecciona un departamento",
teamLabel: "Equipo",
teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico",
emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono",
@@ -969,19 +960,9 @@ describe("i18n dictionaries", () => {
updateSubmit: "Actualizar persona",
},
fallback: {
unknownDepartment: "Departamento desconocido",
noTeam: "",
unknownStatus: "Estado desconocido",
},
departments: {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
},
actions: {
createSuccess: "Persona creada correctamente",
createFailure: "Error al crear la persona",
@@ -989,14 +970,15 @@ describe("i18n dictionaries", () => {
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
},
schema: {
firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
},
})
})
@@ -10,8 +10,8 @@ describe("admin users unified form dictionary", () => {
expect(form.firstNamePlaceholder).toBe("First name")
expect(form.lastNameLabel).toBe("Last Name")
expect(form.lastNamePlaceholder).toBe("Last name")
expect(form.departmentLabel).toBe("Department")
expect(form.departmentPlaceholder).toBe("Select a department")
expect(form.teamLabel).toBe("Team")
expect(form.teamPlaceholder).toBe("Select a team")
expect(form.phoneLabel).toBe("Phone")
expect(form.phonePlaceholder).toBe("Phone")
})
@@ -23,8 +23,8 @@ describe("admin users unified form dictionary", () => {
expect(form.firstNamePlaceholder).toBe("Nombre")
expect(form.lastNameLabel).toBe("Apellido")
expect(form.lastNamePlaceholder).toBe("Apellido")
expect(form.departmentLabel).toBe("Departamento")
expect(form.departmentPlaceholder).toBe("Selecciona un departamento")
expect(form.teamLabel).toBe("Equipo")
expect(form.teamPlaceholder).toBe("Selecciona un equipo")
expect(form.phoneLabel).toBe("Teléfono")
expect(form.phonePlaceholder).toBe("Teléfono")
})
+3 -3
View File
@@ -125,7 +125,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({
firstName: "Per",
lastName: "Son",
department: "IT",
teamId: null,
email: "person@example.test",
}).success,
).toBe(true)
@@ -134,7 +134,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({
firstName: "Per",
lastName: "Son",
department: "IT",
teamId: null,
email: "not-an-email",
}).success,
).toBe(false)
@@ -143,7 +143,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({
firstName: "Per",
lastName: "Son",
department: "IT",
teamId: null,
email: "",
}).success,
).toBe(true)
+28 -12
View File
@@ -8,18 +8,20 @@ import {
const schemaCopy = {
firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
}
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("person schema validation", () => {
it("uses localized required-field validation messages for create (no username)", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "",
lastName: "",
department: "",
teamId: null,
})
expect(result.success).toBe(false)
@@ -28,7 +30,6 @@ describe("person schema validation", () => {
expect(errors.firstName).toContain(schemaCopy.firstNameRequired)
expect(errors.lastName).toContain(schemaCopy.lastNameRequired)
expect(errors.department).toContain(schemaCopy.departmentRequired)
}
})
@@ -36,7 +37,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
email: "not-an-email",
})
@@ -52,7 +53,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
userId: "not-a-uuid",
})
@@ -64,12 +65,27 @@ describe("person schema validation", () => {
}
})
it("rejects an invalid teamId", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
teamId: "not-a-uuid",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.teamId).toContain(
schemaCopy.teamIdInvalid,
)
}
})
it("uses localized update identifier validation messages", () => {
const result = buildUpdatePersonSchema(schemaCopy).safeParse({
id: "",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
email: "ada@example.test",
})
@@ -81,20 +97,20 @@ describe("person schema validation", () => {
}
})
it("preserves canonical department values and accepts optional userId UUID", () => {
it("accepts a valid teamId UUID and optional userId UUID", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: validTeamId,
email: "",
userId: "550e8400-e29b-41d4-a716-446655440000",
userId: validTeamId,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.department).toBe("ENGINEERING")
expect(result.data.teamId).toBe(validTeamId)
expect(result.data.email).toBe("")
expect(result.data.userId).toBe("550e8400-e29b-41d4-a716-446655440000")
expect(result.data.userId).toBe(validTeamId)
}
})
@@ -102,7 +118,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
})
expect(result.success).toBe(true)
@@ -6,34 +6,36 @@ import {
unifiedFormRoleSchema,
} from "@/schemas/user.schema"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
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",
teamIdInvalid: "Team must be a valid id",
}
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",
teamIdInvalid: "El equipo debe ser un id válido",
}
const validPersonOnlyData = {
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: null,
email: "john@example.test",
phone: null,
role: "NO_USER" as const,
@@ -44,7 +46,7 @@ const validPersonOnlyData = {
const validPersonWithUserData = {
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
teamId: validTeamId,
email: "jane@example.test",
phone: "1234567890",
role: "ADMIN" as const,
@@ -96,7 +98,7 @@ describe("buildUnifiedCreateSchema", () => {
const result = schema.safeParse({
firstName: "",
lastName: "",
department: "",
teamId: "not-a-uuid",
email: "not-an-email",
role: "NO_USER",
phone: null,
@@ -108,7 +110,7 @@ describe("buildUnifiedCreateSchema", () => {
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.teamId).toContain(esCopy.teamIdInvalid)
expect(errors.email).toContain(esCopy.emailInvalid)
}
})
@@ -185,7 +187,7 @@ describe("buildUnifiedCreateSchema", () => {
const result = schema.safeParse({
firstName: "Jane",
lastName: "Smith",
department: "ENGINEERING",
teamId: validTeamId,
email: "jane@example.test",
role: "ADMIN",
password: "corta",
@@ -228,36 +230,35 @@ describe("buildUnifiedCreateSchema", () => {
})
})
describe("department validation", () => {
it("rejects invalid department", () => {
describe("teamId validation", () => {
it("rejects invalid teamId", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
department: "INVALID_DEPT",
teamId: "INVALID_TEAM",
})
expect(result.success).toBe(false)
})
it("accepts valid departments", () => {
it("accepts null teamId", () => {
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)
}
const result = schema.safeParse({
...validPersonOnlyData,
teamId: null,
})
expect(result.success).toBe(true)
})
it("accepts a valid teamId UUID", () => {
const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnlyData,
teamId: validTeamId,
})
expect(result.success).toBe(true)
})
})
})
@@ -5,35 +5,37 @@ import {
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
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",
teamIdInvalid: "Team must be a valid id",
}
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",
teamIdInvalid: "El equipo debe ser un id válido",
}
const validPersonOnly = {
id: "person-1",
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: null,
email: "john@example.test",
phone: null,
}
@@ -73,7 +75,7 @@ describe("buildUnifiedUpdateSchema", () => {
id: "",
firstName: "John",
lastName: "Doe",
department: "IT",
teamId: null,
email: "john@example.test",
phone: null,
})
@@ -85,6 +87,31 @@ describe("buildUnifiedUpdateSchema", () => {
)
}
})
it("rejects invalid teamId", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnly,
teamId: "not-a-uuid",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.teamId).toContain(
enCopy.teamIdInvalid,
)
}
})
it("accepts a valid teamId UUID", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnly,
teamId: validTeamId,
})
expect(result.success).toBe(true)
})
})
describe("person+user update (when person has User linked)", () => {
+1 -1
View File
@@ -5,8 +5,8 @@ vi.mock("@/lib/prisma", () => ({
}))
import {
getUserById,
getUserByEmail,
getUserById,
getUserCredentialsByEmail,
} from "@/services/user.service"