From 0d7326c680b582cc27e9369753c4494f199d91d8 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 01:28:32 +0200 Subject: [PATCH 01/12] feat(db): drop PersonDepartment enum and wire Person.teamId relation --- .../migration.sql | 41 +++++++++++++++++++ prisma/schema.prisma | 19 ++------- 2 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260625230055_drop_person_department_enum/migration.sql 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 2453021..99e1765 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,22 +110,10 @@ model UserInvitation { // PEOPLE // ====================================================== -enum PersonDepartment { - IT - ENGINEERING - LOGISTICS - TRAFFIC - DRIVER - ADMINISTRATION - SALES - OTHER -} - model Person { - id String @id @default(uuid(7)) @db.Uuid - firstName String - lastName String - department PersonDepartment? + id String @id @default(uuid(7)) @db.Uuid + firstName String + lastName String email String? phone String? @@ -143,7 +131,6 @@ model Person { assignments Assignment[] @@index([lastName, firstName]) - @@index([department, deletedAt]) @@index([teamId, deletedAt]) @@index([teamId]) @@index([deletedAt]) -- 2.52.0 From 2919479f45de985edb05e2f114d426d77f521cf3 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 01:28:37 +0200 Subject: [PATCH 02/12] refactor(people): replace department with teamId in schemas, services, use-cases and types --- src/actions/person.messages.ts | 1 + src/actions/user.messages.ts | 2 +- src/lib/constants.ts | 11 ---- src/schemas/person.schema.ts | 21 ++---- src/schemas/user.schema.ts | 17 +++-- src/services/person.service.ts | 6 ++ src/use-cases/person.use-cases.ts | 103 +++++++++++++++++++++++++----- 7 files changed, 112 insertions(+), 49 deletions(-) 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/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/lib/constants.ts b/src/lib/constants.ts index 097fe1a..0640ee5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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", diff --git a/src/schemas/person.schema.ts b/src/schemas/person.schema.ts index 6b7b48b..87db2f4 100644 --- a/src/schemas/person.schema.ts +++ b/src/schemas/person.schema.ts @@ -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 + .string() + .uuid({ error: copy.teamIdInvalid }) + .optional() + .nullable(), email: z.string().optional().nullable(), phone: z.string().optional().nullable(), userId: z diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index e406970..3c26c64 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -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 + .string() + .uuid({ error: copy.teamIdInvalid }) + .optional() + .nullable(), 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 + .string() + .uuid({ error: copy.teamIdInvalid }) + .optional() + .nullable(), email: z.email({ error: copy.emailInvalid }), phone: z.string().optional().nullable(), role: unifiedFormRoleSchema, diff --git a/src/services/person.service.ts b/src/services/person.service.ts index d3674b7..5a091f9 100644 --- a/src/services/person.service.ts +++ b/src/services/person.service.ts @@ -4,6 +4,12 @@ import prisma from "@/lib/prisma" const personWithUserSelect = { include: { + team: { + select: { + id: true, + name: true, + }, + }, user: { select: { id: true, diff --git a/src/use-cases/person.use-cases.ts b/src/use-cases/person.use-cases.ts index b279bcf..a7a3872 100644 --- a/src/use-cases/person.use-cases.ts +++ b/src/use-cases/person.use-cases.ts @@ -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 @@ -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 { + 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 { - 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 { - 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) -- 2.52.0 From 43d8a133ca4afa14a74d710b5a14776ff1b1ee15 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 01:28:50 +0200 Subject: [PATCH 03/12] feat(ui): add team picker to person forms, list and detail pages --- .../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/user.copy.ts | 16 ------- src/app/(dashboard)/people/new/page.tsx | 5 +- src/app/(dashboard)/people/page.tsx | 16 ++----- 8 files changed, 47 insertions(+), 119 deletions(-) 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")} /> - -