10 Commits

22 changed files with 1214 additions and 1 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;
+14
View File
@@ -130,6 +130,9 @@ model Person {
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)
@@ -141,9 +144,20 @@ model Person {
@@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
// ======================================================
+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)),
]),
)
}
@@ -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>
)
}
+46 -1
View File
@@ -13,21 +13,36 @@ import {
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,6 +50,7 @@ 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
@@ -42,7 +58,7 @@ export default async function PeoplePage(props: {
const departmentCopy = copy.departments as PersonDepartmentCopy
const personFallbackCopy = copy.fallback as PersonFallbackCopy
return (
const peopleList = (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
@@ -160,4 +176,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>
)
}
+38
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",
+39
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",
+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>
+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
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">
+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
}
}
+13
View File
@@ -77,6 +77,19 @@ export async function createTestPerson(
})
}
export async function createTestTeam(
prisma: PrismaClient,
overrides: Partial<{ name: string }> = {},
) {
const suffix = nextSuffix()
return prisma.team.create({
data: {
name: overrides.name ?? `Test Team ${suffix}`,
},
})
}
export async function createTestItem(
prisma: PrismaClient,
overrides: Partial<{
+1
View File
@@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [
"Asset",
"Item",
"Category",
"Team",
"Person",
"User",
]
@@ -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"])
})
})
@@ -55,6 +55,7 @@ const basePerson: PersonWithUser = {
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
teamId: null,
email: "ada@example.test",
phone: "1234",
userId: null,
@@ -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()
+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)
})
})