From efda051aa3d02c17aed88eba67303907c751f3e8 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:32:37 +0000 Subject: [PATCH] feat(teams): add Team entity and replace PersonDepartment with teamId (#6) Co-authored-by: Asis Ferrer Co-committed-by: Asis Ferrer --- .../migration.sql | 24 +++ .../migration.sql | 41 +++++ prisma/schema.prisma | 33 ++-- src/actions/assignment.actions.ts | 20 ++- src/actions/import.actions.ts | 1 - src/actions/person.messages.ts | 1 + src/actions/team.actions.ts | 143 +++++++++++++++ src/actions/team.messages.ts | 38 ++++ src/actions/user.messages.ts | 2 +- .../inventory/assets/[assetId]/page.tsx | 23 ++- .../people/[personId]/edit/page.tsx | 6 +- .../(dashboard)/people/[personId]/page.tsx | 13 +- .../people/_components/edit.person.form.tsx | 47 ++--- .../people/_components/new.person.form.tsx | 47 ++--- .../people/_components/person.copy.ts | 16 -- .../people/_components/team.create.form.tsx | 94 ++++++++++ .../people/_components/team.edit.form.tsx | 141 +++++++++++++++ .../people/_components/team.list.table.tsx | 113 ++++++++++++ .../people/_components/teams.tab.tsx | 34 ++++ .../people/_components/user.copy.ts | 16 -- src/app/(dashboard)/people/new/page.tsx | 5 +- src/app/(dashboard)/people/page.tsx | 63 +++++-- src/i18n/dictionaries/en.ts | 65 +++++-- src/i18n/dictionaries/es.ts | 66 +++++-- src/lib/auth.ts | 4 +- src/lib/constants.ts | 11 -- src/schemas/item.schema.ts | 28 +-- src/schemas/person.schema.ts | 21 +-- src/schemas/team.schema.ts | 35 ++++ src/schemas/user.schema.ts | 17 +- src/services/movement.service.ts | 6 +- src/services/person.service.ts | 6 + src/services/team.service.ts | 57 ++++++ src/types/asset.ts | 2 +- src/types/index.ts | 1 + src/types/team.ts | 5 + src/use-cases/item.use-cases.ts | 7 +- src/use-cases/person.use-cases.ts | 103 +++++++++-- src/use-cases/team.use-cases.ts | 123 +++++++++++++ tests/e2e/assignments.spec.ts | 2 +- tests/e2e/people.spec.ts | 142 +++++++++++++++ tests/integration/helpers/factories.ts | 23 ++- tests/integration/helpers/test-db.ts | 1 + .../use-cases/person-user.use-cases.test.ts | 70 ++++++-- .../use-cases/person.use-cases.test.ts | 71 ++++++-- .../use-cases/team.use-cases.test.ts | 170 ++++++++++++++++++ .../update-person-user.use-cases.test.ts | 44 +++-- .../use-cases/user.use-cases.test.ts | 20 ++- tests/unit/actions/person.actions.test.ts | 13 +- tests/unit/actions/person.messages.test.ts | 14 ++ .../actions/update-person-user.action.test.ts | 39 +++- .../inventory/assets/[assetId]/page.test.ts | 6 +- tests/unit/app/inventory/assets/page.test.ts | 2 +- .../people/edit-person-form-wiring.test.ts | 20 ++- .../unit/app/people/person-form-pages.test.ts | 15 +- tests/unit/app/people/person-pages.test.ts | 26 ++- .../unit/app/users/unified-form-pages.test.ts | 25 ++- tests/unit/app/users/user.copy.test.ts | 50 +----- .../unit/i18n/admin-users-dictionary.test.ts | 8 +- tests/unit/i18n/dictionaries.test.ts | 46 ++--- .../unit/i18n/unified-form-dictionary.test.ts | 8 +- tests/unit/schemas/core-schemas.test.ts | 6 +- tests/unit/schemas/person.schema.test.ts | 40 +++-- tests/unit/schemas/team.schema.test.ts | 80 +++++++++ .../schemas/unified-create.schema.test.ts | 57 +++--- .../schemas/unified-update.schema.test.ts | 35 +++- tests/unit/services/user.service.test.ts | 2 +- 67 files changed, 2037 insertions(+), 476 deletions(-) create mode 100644 prisma/migrations/20260625215731_add_team_model/migration.sql create mode 100644 prisma/migrations/20260625230055_drop_person_department_enum/migration.sql create mode 100644 src/actions/team.actions.ts create mode 100644 src/actions/team.messages.ts create mode 100644 src/app/(dashboard)/people/_components/team.create.form.tsx create mode 100644 src/app/(dashboard)/people/_components/team.edit.form.tsx create mode 100644 src/app/(dashboard)/people/_components/team.list.table.tsx create mode 100644 src/app/(dashboard)/people/_components/teams.tab.tsx create mode 100644 src/schemas/team.schema.ts create mode 100644 src/services/team.service.ts create mode 100644 src/types/team.ts create mode 100644 src/use-cases/team.use-cases.ts create mode 100644 tests/e2e/people.spec.ts create mode 100644 tests/integration/use-cases/team.use-cases.test.ts create mode 100644 tests/unit/schemas/team.schema.test.ts diff --git a/prisma/migrations/20260625215731_add_team_model/migration.sql b/prisma/migrations/20260625215731_add_team_model/migration.sql new file mode 100644 index 0000000..bc07a8a --- /dev/null +++ b/prisma/migrations/20260625215731_add_team_model/migration.sql @@ -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; diff --git a/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql b/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql new file mode 100644 index 0000000..01f2c9c --- /dev/null +++ b/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70d0038..99e1765 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 // ====================================================== diff --git a/src/actions/assignment.actions.ts b/src/actions/assignment.actions.ts index 4c50ba4..54f3aae 100644 --- a/src/actions/assignment.actions.ts +++ b/src/actions/assignment.actions.ts @@ -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, diff --git a/src/actions/import.actions.ts b/src/actions/import.actions.ts index e0f1ca0..b1b4b21 100644 --- a/src/actions/import.actions.ts +++ b/src/actions/import.actions.ts @@ -303,7 +303,6 @@ export async function importItems(formData: ImportFormType) { lastName, email: undefined, phone: "", - department: "OTHER", }) } else { newPerson = existingPerson.data[0] diff --git a/src/actions/person.messages.ts b/src/actions/person.messages.ts index fc60ab6..d42adc8 100644 --- a/src/actions/person.messages.ts +++ b/src/actions/person.messages.ts @@ -6,6 +6,7 @@ type FieldErrors = Record const personErrorMessageKeys = { "Email already exists": "duplicateEmail", + "Team not found": "teamNotFound", } as const satisfies Record function isPersonErrorMessage( diff --git a/src/actions/team.actions.ts b/src/actions/team.actions.ts new file mode 100644 index 0000000..cb049f1 --- /dev/null +++ b/src/actions/team.actions.ts @@ -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() +} diff --git a/src/actions/team.messages.ts b/src/actions/team.messages.ts new file mode 100644 index 0000000..a533117 --- /dev/null +++ b/src/actions/team.messages.ts @@ -0,0 +1,38 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"] + +type FieldErrors = Record + +const teamErrorMessageKeys = { + "Team already exists": "duplicateName", + "Team name is the same": "unchangedName", + "Team name unchanged": "unchangedName", + "Team not found": "notFound", +} as const satisfies Record + +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)), + ]), + ) +} diff --git a/src/actions/user.messages.ts b/src/actions/user.messages.ts index 9d39261..f43f466 100644 --- a/src/actions/user.messages.ts +++ b/src/actions/user.messages.ts @@ -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 diff --git a/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx b/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx index 1bbb435..97fb846 100644 --- a/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx +++ b/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx @@ -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({
{asset.serialNumber}
-
{copy.labels.assetTag}
+
+ {copy.labels.assetTag} +
{asset.assetTag ?? missingValue}
@@ -119,11 +124,19 @@ export default async function AssetDetailPage({
{asset.notes ?? missingValue}
-
{copy.labels.status}
-
{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}
+
+ {copy.labels.status} +
+
+ {formatAssetStatus(asset.status, statusCopy, { + unknownStatus: missingValue, + })} +
-
{copy.labels.person}
+
+ {copy.labels.person} +
{formatPersonName(asset.assignment?.person, missingValue)}
diff --git a/src/app/(dashboard)/people/[personId]/edit/page.tsx b/src/app/(dashboard)/people/[personId]/edit/page.tsx index 99c94f6..a49690a 100644 --- a/src/app/(dashboard)/people/[personId]/edit/page.tsx +++ b/src/app/(dashboard)/people/[personId]/edit/page.tsx @@ -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
{personCopy.edit.notFound}
@@ -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} /> ) diff --git a/src/app/(dashboard)/people/[personId]/page.tsx b/src/app/(dashboard)/people/[personId]/page.tsx index fd462d3..9434eba 100644 --- a/src/app/(dashboard)/people/[personId]/page.tsx +++ b/src/app/(dashboard)/people/[personId]/page.tsx @@ -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({ {person.phone}
- - {copy.detail.labels.department} - - - {formatPersonDepartment( - person.department, - copy.departments, - copy.fallback, - )} - + {copy.detail.labels.team} + {person.team?.name ?? copy.fallback.noTeam}
{person.user ? ( <> diff --git a/src/app/(dashboard)/people/_components/edit.person.form.tsx b/src/app/(dashboard)/people/_components/edit.person.form.tsx index d7594da..e6953fe 100644 --- a/src/app/(dashboard)/people/_components/edit.person.form.tsx +++ b/src/app/(dashboard)/people/_components/edit.person.form.tsx @@ -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")} /> - -