feat(teams): add Team entity and replace PersonDepartment with teamId (#6)

Co-authored-by: Asis Ferrer <aferrer@aferrer.dev>
Co-committed-by: Asis Ferrer <aferrer@aferrer.dev>
This commit was merged in pull request #6.
This commit is contained in:
2026-06-26 00:32:37 +00:00
committed by Asis ferrer
parent b401f254ec
commit efda051aa3
67 changed files with 2037 additions and 476 deletions
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Team" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "team_name_lower_unique" ON "Team" (lower("name"));
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "teamId" UUID;
-- CreateIndex
CREATE INDEX "Person_teamId_deletedAt_idx" ON "Person"("teamId", "deletedAt");
-- CreateIndex
CREATE INDEX "Person_teamId_idx" ON "Person"("teamId");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+17 -16
View File
@@ -110,26 +110,17 @@ 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?
teamId String? @db.Uuid
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String? @unique @db.Uuid
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@ -140,10 +131,20 @@ model Person {
assignments Assignment[]
@@index([lastName, firstName])
@@index([department, deletedAt])
@@index([teamId, deletedAt])
@@index([teamId])
@@index([deletedAt])
}
model Team {
id String @id @default(uuid(7)) @db.Uuid
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
people Person[]
}
// ======================================================
// CATALOG
// ======================================================
+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(
+143
View File
@@ -0,0 +1,143 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateTeamSchema,
buildUpdateTeamSchema,
type CreateTeamFormType,
type UpdateTeamFormType,
} from "@/schemas/team.schema"
import { getAuthenticatedSession, requireRole } from "@/services/auth.service"
import {
createTeamUseCase,
deleteTeamUseCase,
listTeamsUseCase,
updateTeamUseCase,
} from "@/use-cases/team.use-cases"
import { localizeTeamFieldErrors } from "./team.messages"
export async function createTeamAction(formData: CreateTeamFormType) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const validatedFields = buildCreateTeamSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createTeamUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.createFailure,
errors: {
name: [copy.actions.duplicateName],
},
}
}
}
export async function updateTeamAction(formData: UpdateTeamFormType) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const validatedFields = buildUpdateTeamSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updateTeamUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.updateFailure,
}
}
}
export async function deleteTeamAction(formData: FormData) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const { id } = Object.fromEntries(formData) as { id: string }
try {
const result = await deleteTeamUseCase(id)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
revalidatePath("/people")
return {
success: true as const,
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.deleteFailure,
errors: {},
}
}
}
export async function listTeamsAction() {
await getAuthenticatedSession()
return listTeamsUseCase()
}
+38
View File
@@ -0,0 +1,38 @@
import type { Dictionary } from "@/i18n/dictionaries"
type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"]
type FieldErrors = Record<string, string[]>
const teamErrorMessageKeys = {
"Team already exists": "duplicateName",
"Team name is the same": "unchangedName",
"Team name unchanged": "unchangedName",
"Team not found": "notFound",
} as const satisfies Record<string, keyof TeamActionCopy>
function isTeamErrorMessage(
message: string,
): message is keyof typeof teamErrorMessageKeys {
return message in teamErrorMessageKeys
}
function localizeTeamMessage(message: string, copy: TeamActionCopy): string {
if (!isTeamErrorMessage(message)) return message
return copy[teamErrorMessageKeys[message]]
}
export function localizeTeamFieldErrors(
errors: FieldErrors | undefined,
copy: TeamActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeTeamMessage(message, copy)),
]),
)
}
+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
}
@@ -0,0 +1,94 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createTeamAction } from "@/actions/team.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildCreateTeamSchema,
type CreateTeamFormType,
} from "@/schemas/team.schema"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
export default function TeamCreateForm({
formCopy,
schemaCopy,
submitButtonCopy,
}: {
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildCreateTeamSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateTeamFormType>({
resolver: zodResolver(schema),
})
const onSubmit = async (formData: CreateTeamFormType) => {
const response = await createTeamAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateTeamFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
reset()
router.refresh()
}
}
return (
<form
className="flex flex-col gap-4 rounded-lg border p-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-2">
<label htmlFor="team-name" className="mb-2 block text-lg">
{formCopy.nameLabel}
</label>
<input
type="text"
id="team-name"
placeholder={formCopy.namePlaceholder}
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${errors.name ? "border-error" : ""}`}
/>
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
</SubmitButton>
</form>
)
}
@@ -0,0 +1,141 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Pencil } from "lucide-react"
import { useRouter } from "next/navigation"
import { useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateTeamAction } from "@/actions/team.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildUpdateTeamSchema,
type UpdateTeamFormType,
} from "@/schemas/team.schema"
import type { TeamSummary } from "@/types"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
export default function TeamEditForm({
team,
formCopy,
schemaCopy,
listCopy,
submitButtonCopy,
}: {
team: TeamSummary
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
listCopy: TeamListCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
const schema = useMemo(() => buildUpdateTeamSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateTeamFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: team.id,
name: team.name,
},
})
const onSubmit = async (formData: UpdateTeamFormType) => {
const response = await updateTeamAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof UpdateTeamFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
setOpen(false)
router.refresh()
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={listCopy.actions.edit}
>
<Pencil />
</Button>
</DialogTrigger>
<DialogContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>{formCopy.updateSubmit}</DialogTitle>
<DialogDescription>{team.name}</DialogDescription>
</DialogHeader>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label
htmlFor={`team-name-${team.id}`}
className="mb-2 block text-lg"
>
{formCopy.nameLabel}
</label>
<input
type="text"
id={`team-name-${team.id}`}
placeholder={formCopy.namePlaceholder}
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${errors.name ? "border-error" : ""}`}
/>
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{formCopy.cancel}
</Button>
</DialogClose>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
</SubmitButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,113 @@
"use client"
import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { deleteTeamAction } from "@/actions/team.actions"
import { Button } from "@/components/ui/button"
import type { Dictionary } from "@/i18n/dictionaries"
import type { TeamSummary } from "@/types"
import TeamEditForm from "./team.edit.form"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
type SubmitButtonCopy = Dictionary["common"]["submitButton"]
function DeleteTeamButton({
team,
copy,
}: {
team: TeamSummary
copy: TeamListCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const handleDelete = (formData: FormData) => {
startTransition(async () => {
const response = await deleteTeamAction(formData)
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
return
}
if (response.success) {
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? copy.actions.delete)
}
})
}
return (
<form action={handleDelete}>
<input type="hidden" name="id" value={team.id} />
<Button
type="submit"
variant="outline"
size="icon"
disabled={isPending}
aria-label={copy.actions.delete}
>
<Trash />
</Button>
</form>
)
}
export default function TeamListTable({
teams,
formCopy,
schemaCopy,
listCopy,
submitButtonCopy,
}: {
teams: TeamSummary[]
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
listCopy: TeamListCopy
submitButtonCopy: SubmitButtonCopy
}) {
if (teams.length === 0) {
return <div>{listCopy.empty}</div>
}
return (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
{listCopy.columns.name}
</th>
<th scope="col" className="p-4">
{listCopy.columns.actions}
</th>
</tr>
</thead>
<tbody>
{teams.map((team) => (
<tr key={team.id} className="border-b">
<td className="p-4">{team.name}</td>
<td className="flex items-center gap-2 p-4">
<TeamEditForm
team={team}
formCopy={formCopy}
schemaCopy={schemaCopy}
listCopy={listCopy}
submitButtonCopy={submitButtonCopy}
/>
<DeleteTeamButton team={team} copy={listCopy} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,34 @@
import PageHeader from "@/components/common/pageheader"
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import TeamCreateForm from "./team.create.form"
import TeamListTable from "./team.list.table"
export default async function TeamsTab() {
const teams = await listTeamsUseCase()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
addLabel={copy.list.addLabel}
data={teams}
/>
<TeamCreateForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
<TeamListTable
teams={teams}
formCopy={copy.form}
schemaCopy={copy.schema}
listCopy={copy.list}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -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>
)
+49 -14
View File
@@ -8,26 +8,36 @@ 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,
type UserFallbackCopy,
type UserRoleCopy,
} from "./_components/user.copy"
const VALID_TABS = ["people", "teams"] as const
type Tab = (typeof VALID_TABS)[number]
function resolveTab(raw: string | undefined): Tab {
if (raw && VALID_TABS.includes(raw as Tab)) {
return raw as Tab
}
return "people"
}
export default async function PeoplePage(props: {
searchParams?: Promise<{
page?: string
search?: string
tab?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const activeTab = resolveTab(searchParams?.tab)
const { data: people, totalPages } = await PersonService.findAllPaginated({
page: currentPage,
pageSize: 10,
@@ -35,14 +45,14 @@ export default async function PeoplePage(props: {
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const teamCopy = dictionary.inventory.teams
const userCopy = dictionary.admin.users
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
return (
const peopleList = (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
@@ -67,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}
@@ -89,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
@@ -160,4 +166,33 @@ export default async function PeoplePage(props: {
)}
</div>
)
return (
<div className="flex flex-col gap-4">
<nav aria-label="People sections">
<ul className="flex gap-4 border-b">
<li>
<Link
href="/people?tab=people"
aria-current={activeTab === "people" ? "page" : undefined}
className={`inline-block px-4 py-2 ${activeTab === "people" ? "border-b-2 border-primary font-semibold" : ""}`}
>
{copy.list.title}
</Link>
</li>
<li>
<Link
href="/people?tab=teams"
aria-current={activeTab === "teams" ? "page" : undefined}
className={`inline-block px-4 py-2 ${activeTab === "teams" ? "border-b-2 border-primary font-semibold" : ""}`}
>
{teamCopy.list.title}
</Link>
</li>
</ul>
</nav>
{activeTab === "teams" && <TeamsTab />}
{activeTab === "people" && peopleList}
</div>
)
}
+47 -18
View File
@@ -384,6 +384,44 @@ export const en = {
value: "{remaining} of {total}",
},
},
teams: {
list: {
title: "Teams",
addLabel: "Add Team",
empty: "No teams found.",
columns: {
name: "Name",
actions: "Actions",
},
actions: {
edit: "Edit team",
delete: "Delete team",
},
},
form: {
nameLabel: "Team name",
namePlaceholder: "Team name",
createSubmit: "Create Team",
updateSubmit: "Update Team",
cancel: "Cancel",
},
actions: {
createSuccess: "Team created successfully",
createFailure: "Failed to create team",
updateSuccess: "Team updated successfully",
updateFailure: "Failed to update team",
deleteSuccess: "Team deleted successfully",
deleteFailure: "Failed to delete team",
duplicateName: "Team already exists",
unchangedName: "Team name unchanged",
notFound: "Team not found",
},
schema: {
nameRequired: "Team name is required",
nameMaxLength: "Team name must be at most 80 characters",
idRequired: "Team ID is required",
},
},
people: {
list: {
title: "People",
@@ -393,7 +431,7 @@ export const en = {
name: "Name",
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
actions: "Actions",
@@ -408,7 +446,7 @@ export const en = {
labels: {
email: "Email",
phone: "Phone",
department: "Department",
team: "Team",
role: "Role",
status: "Status",
noUser: "No user account",
@@ -426,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",
@@ -441,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",
@@ -461,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: {
@@ -535,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",
+48 -18
View File
@@ -389,6 +389,45 @@ export const es = {
value: "{remaining} de {total}",
},
},
teams: {
list: {
title: "Equipos",
addLabel: "Agregar equipo",
empty: "No se encontraron equipos.",
columns: {
name: "Nombre",
actions: "Acciones",
},
actions: {
edit: "Editar equipo",
delete: "Eliminar equipo",
},
},
form: {
nameLabel: "Nombre del equipo",
namePlaceholder: "Nombre del equipo",
createSubmit: "Crear equipo",
updateSubmit: "Actualizar equipo",
cancel: "Cancelar",
},
actions: {
createSuccess: "Equipo creado correctamente",
createFailure: "Error al crear el equipo",
updateSuccess: "Equipo actualizado correctamente",
updateFailure: "Error al actualizar el equipo",
deleteSuccess: "Equipo eliminado correctamente",
deleteFailure: "Error al eliminar el equipo",
duplicateName: "El equipo ya existe",
unchangedName: "El nombre del equipo no cambió",
notFound: "Equipo no encontrado",
},
schema: {
nameRequired: "El nombre del equipo es obligatorio",
nameMaxLength:
"El nombre del equipo no puede superar los 80 caracteres",
idRequired: "El ID del equipo es obligatorio",
},
},
people: {
list: {
title: "Personas",
@@ -398,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",
@@ -413,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",
@@ -431,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",
@@ -447,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",
@@ -467,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: {
@@ -541,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
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries"
export type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
const defaultTeamSchemaCopy: TeamSchemaCopy = {
nameRequired: "Name is required",
nameMaxLength: "Name must be at most 80 characters",
idRequired: "ID is required",
}
export function buildCreateTeamSchema(copy: TeamSchemaCopy) {
return z.object({
name: z
.string()
.trim()
.min(1, { error: copy.nameRequired })
.max(80, { error: copy.nameMaxLength }),
})
}
export function buildUpdateTeamSchema(copy: TeamSchemaCopy) {
return buildCreateTeamSchema(copy).extend({
id: z.string().nonempty(copy.idRequired),
})
}
export const createTeamSchema = buildCreateTeamSchema(defaultTeamSchemaCopy)
export type CreateTeamFormType = z.infer<typeof createTeamSchema>
export const updateTeamSchema = buildUpdateTeamSchema(defaultTeamSchemaCopy)
export type UpdateTeamFormType = z.infer<typeof updateTeamSchema>
+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,
+57
View File
@@ -0,0 +1,57 @@
import type { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type { Team, TeamSummary } from "@/types"
export const TeamService = {
findAll: async (
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<TeamSummary[]> => {
return db.team.findMany({
select: {
id: true,
name: true,
},
orderBy: { name: "asc" },
})
},
findById: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Team | null> => {
return db.team.findUnique({ where: { id } })
},
findByNameCaseInsensitive: async (
name: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Team | null> => {
return db.team.findFirst({
where: {
name: { equals: name.trim(), mode: "insensitive" },
},
})
},
create: async (
data: Prisma.TeamCreateInput,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Team> => {
return db.team.create({ data })
},
update: async (
id: string,
data: Prisma.TeamUpdateInput,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Team> => {
return db.team.update({ where: { id }, data })
},
delete: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Team> => {
return db.team.delete({ where: { id } })
},
}
+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"
+1
View File
@@ -6,4 +6,5 @@ export * from "./item"
export * from "./movement"
export * from "./paginate"
export * from "./person"
export * from "./team"
export * from "./user"
+5
View File
@@ -0,0 +1,5 @@
import type { Team as PrismaTeam } from "@/generated/prisma/client"
export type Team = PrismaTeam
export type TeamSummary = Pick<Team, "id" | "name">
+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)
+123
View File
@@ -0,0 +1,123 @@
import { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type {
CreateTeamFormType,
UpdateTeamFormType,
} from "@/schemas/team.schema"
import { TeamService } from "@/services/team.service"
type FieldErrors = Record<string, string[]>
type TeamUseCaseResult =
| {
success: true
}
| {
success: false
errors: FieldErrors
}
function teamError(errors: FieldErrors): TeamUseCaseResult {
return {
success: false,
errors,
}
}
function isUniqueConstraintError(error: unknown) {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
)
}
export async function listTeamsUseCase() {
return TeamService.findAll()
}
export async function createTeamUseCase(
input: CreateTeamFormType,
): Promise<TeamUseCaseResult> {
const name = input.name.trim()
try {
return await prisma.$transaction(async (tx) => {
const existingTeam = await TeamService.findByNameCaseInsensitive(name, tx)
if (existingTeam) {
return teamError({ name: ["Team already exists"] })
}
await TeamService.create({ name }, tx)
return { success: true }
})
} catch (error) {
if (isUniqueConstraintError(error)) {
return teamError({ name: ["Team already exists"] })
}
throw error
}
}
export async function updateTeamUseCase(
input: UpdateTeamFormType,
): Promise<TeamUseCaseResult> {
const { id } = input
const name = input.name.trim()
try {
return await prisma.$transaction(async (tx) => {
const existingTeam = await TeamService.findById(id, tx)
if (!existingTeam) {
return teamError({ id: ["Team not found"] })
}
if (existingTeam.name.toLowerCase() === name.toLowerCase()) {
return teamError({ name: ["Team name is the same"] })
}
const teamWithName = await TeamService.findByNameCaseInsensitive(name, tx)
if (teamWithName) {
return teamError({ name: ["Team already exists"] })
}
await TeamService.update(id, { name }, tx)
return { success: true }
})
} catch (error) {
if (isUniqueConstraintError(error)) {
return teamError({ name: ["Team already exists"] })
}
throw error
}
}
export async function deleteTeamUseCase(
id: string,
): Promise<TeamUseCaseResult> {
try {
return await prisma.$transaction(async (tx) => {
const team = await TeamService.findById(id, tx)
if (!team) {
return teamError({ id: ["Team not found"] })
}
await TeamService.delete(id, tx)
return { success: true }
})
} catch (error) {
if (isUniqueConstraintError(error)) {
return teamError({ name: ["Team already exists"] })
}
throw error
}
}
+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 -7
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,7 +79,7 @@ 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,
},
+1
View File
@@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [
"Asset",
"Item",
"Category",
"Team",
"Person",
"User",
]
@@ -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",
@@ -0,0 +1,170 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestTeam } from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
stopIntegrationTestDatabase,
} from "../helpers/test-db"
let prisma: PrismaClient
let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUseCase
let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase
let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase
let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const teamUseCases = await import("@/use-cases/team.use-cases")
prisma = prismaModule.prisma
createTeamUseCase = teamUseCases.createTeamUseCase
updateTeamUseCase = teamUseCases.updateTeamUseCase
deleteTeamUseCase = teamUseCases.deleteTeamUseCase
listTeamsUseCase = teamUseCases.listTeamsUseCase
})
beforeEach(async () => {
await resetIntegrationTestDatabase(prisma)
})
afterAll(async () => {
await prisma?.$disconnect()
await stopIntegrationTestDatabase()
})
describe("team use-cases", () => {
it("creates a team and rejects duplicate names", async () => {
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
success: true,
})
expect(
await prisma.team.findFirst({ where: { name: "Engineering" } }),
).toMatchObject({ name: "Engineering" })
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
success: false,
errors: { name: ["Team already exists"] },
})
expect(await prisma.team.count()).toBe(1)
})
it("rejects duplicate names case-insensitively and trims whitespace", async () => {
await createTestTeam(prisma, { name: "Engineering" })
expect(await createTeamUseCase({ name: "engineering" })).toEqual({
success: false,
errors: { name: ["Team already exists"] },
})
expect(await createTeamUseCase({ name: " ENGINEERING " })).toEqual({
success: false,
errors: { name: ["Team already exists"] },
})
expect(await prisma.team.count()).toBe(1)
})
it("trims the name before saving", async () => {
await createTeamUseCase({ name: " Engineering " })
const team = await prisma.team.findFirst({
where: { name: "Engineering" },
})
expect(team).not.toBeNull()
expect(team?.name).toBe("Engineering")
})
it("updates a team and rejects unchanged or duplicate names", async () => {
const team = await createTestTeam(prisma, { name: "Peripherals" })
const other = await createTestTeam(prisma, { name: "Networking" })
expect(
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
).toEqual({ success: true })
expect(
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
).toMatchObject({ name: "Accessories" })
expect(
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
).toEqual({
success: false,
errors: { name: ["Team name is the same"] },
})
expect(await updateTeamUseCase({ id: team.id, name: other.name })).toEqual({
success: false,
errors: { name: ["Team already exists"] },
})
expect(
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
).toMatchObject({ name: "Accessories" })
})
it("returns not found when updating a missing team", async () => {
expect(
await updateTeamUseCase({
id: "00000000-0000-0000-0000-000000000000",
name: "Ghost",
}),
).toEqual({
success: false,
errors: { id: ["Team not found"] },
})
})
it("hard deletes a team", async () => {
const team = await createTestTeam(prisma, { name: "Legacy" })
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull()
})
it("returns not found when deleting a missing team", async () => {
expect(
await deleteTeamUseCase("00000000-0000-0000-0000-000000000000"),
).toEqual({
success: false,
errors: { id: ["Team not found"] },
})
})
it("nulls linked Person.teamId when a team is deleted", async () => {
const team = await createTestTeam(prisma, { name: "Assigned" })
const person = await createTestPerson(prisma)
await prisma.person.update({
where: { id: person.id },
data: { teamId: team.id },
})
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
const updatedPerson = await prisma.person.findUnique({
where: { id: person.id },
})
expect(updatedPerson).not.toBeNull()
expect(updatedPerson?.teamId).toBeNull()
})
it("lists all teams ordered by name", async () => {
await createTestTeam(prisma, { name: "Beta" })
await createTestTeam(prisma, { name: "Alpha" })
await createTestTeam(prisma, { name: "Gamma" })
const teams = await listTeamsUseCase()
expect(teams).toHaveLength(3)
expect(teams.map((team) => team.name)).toEqual(["Alpha", "Beta", "Gamma"])
})
})
@@ -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,7 +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,
@@ -79,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 () => {
@@ -111,6 +123,7 @@ describe("edit person page wiring", () => {
...en.inventory.people.schema,
},
roleLabels: en.admin.users.roles,
teams,
}),
)
})
@@ -141,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,
}),
)
})
+18 -8
View File
@@ -44,6 +44,10 @@ vi.mock("@/components/common/pagination", () => ({
createElement("nav", { "aria-label": "Pagination" }, totalPages),
}))
vi.mock("@/app/(dashboard)/people/_components/teams.tab", () => ({
default: () => null,
}))
describe("person pages", () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -63,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"),
@@ -83,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")
@@ -106,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"),
@@ -133,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"),
@@ -189,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"),
@@ -217,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
@@ -235,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"),
@@ -283,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)
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest"
import {
buildCreateTeamSchema,
buildUpdateTeamSchema,
} from "@/schemas/team.schema"
const schemaCopy = {
nameRequired: "El nombre del equipo es obligatorio",
nameMaxLength: "El nombre del equipo no puede superar los 80 caracteres",
idRequired: "El ID es obligatorio",
}
describe("team schema", () => {
it("rejects blank names", () => {
const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: "" })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.name).toContain(
schemaCopy.nameRequired,
)
}
})
it("rejects whitespace-only names", () => {
const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: " " })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.name).toContain(
schemaCopy.nameRequired,
)
}
})
it("rejects names longer than 80 characters", () => {
const result = buildCreateTeamSchema(schemaCopy).safeParse({
name: "a".repeat(81),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.name).toContain(
schemaCopy.nameMaxLength,
)
}
})
it("accepts valid create input", () => {
const result = buildCreateTeamSchema(schemaCopy).safeParse({
name: "Engineering",
})
expect(result.success).toBe(true)
})
it("rejects update with empty id", () => {
const result = buildUpdateTeamSchema(schemaCopy).safeParse({
id: "",
name: "Engineering",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.id).toContain(
schemaCopy.idRequired,
)
}
})
it("accepts valid update input", () => {
const result = buildUpdateTeamSchema(schemaCopy).safeParse({
id: "some-id",
name: "Engineering",
})
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"