diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index c2d0687..4f183c1 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -33,7 +33,7 @@ export async function POST(request: Request) { ) } - const clienteRole = await prisma.role.findUnique({ where: { nombre: "Cliente" } }) + const clienteRole = await prisma.role.findUnique({ where: { name: "Cliente" } }) const hashed = await bcrypt.hash(password, 12) diff --git a/app/api/roles/[id]/route.ts b/app/api/roles/[id]/route.ts index 204a775..919be41 100644 --- a/app/api/roles/[id]/route.ts +++ b/app/api/roles/[id]/route.ts @@ -28,8 +28,8 @@ export async function PUT(request: Request, { params }: RouteContext) { const rolActualizado = await prisma.role.update({ where: { id: parsedId.data }, data: { - nombre: parsed.data.nombre, - descripcion: parsed.data.descripcion, + name: parsed.data.name, + description: parsed.data.description, }, }); diff --git a/app/api/roles/route.ts b/app/api/roles/route.ts index f088181..15d980e 100644 --- a/app/api/roles/route.ts +++ b/app/api/roles/route.ts @@ -20,8 +20,8 @@ export async function GET(request: Request) { const where: Prisma.RoleWhereInput = {}; if (search !== '') { where.OR = [ - { nombre: { contains: search } }, - { descripcion: { contains: search } }, + { name: { contains: search } }, + { description: { contains: search } }, ]; } @@ -59,8 +59,8 @@ export async function POST(request: Request) { const nuevoRol = await prisma.role.create({ data: { - nombre: parsed.data.nombre, - descripcion: parsed.data.descripcion, + name: parsed.data.name, + description: parsed.data.description, }, }); diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 73d0ca2..0148691 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import prisma from '../../../../lib/prisma'; export const runtime = 'nodejs' -import { updateUserSchema, idParamSchema } from '../../../../lib/validations'; +import { updateUserSchema, userIdParamSchema } from '../../../../lib/validations'; import { requireAdmin } from '../../../../lib/api-auth'; type RouteContext = { params: Promise<{ id: string }> }; @@ -14,7 +14,7 @@ export async function PUT(request: Request, { params }: RouteContext) { try { const { id } = await params; - const parsedId = idParamSchema.safeParse(id); + const parsedId = userIdParamSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json({ error: "ID inválido" }, { status: 400 }); } @@ -45,7 +45,7 @@ export async function DELETE(request: Request, { params }: RouteContext) { try { const { id } = await params; - const parsedId = idParamSchema.safeParse(id); + const parsedId = userIdParamSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json({ error: "ID inválido" }, { status: 400 }); } @@ -68,7 +68,7 @@ export async function PATCH(request: Request, { params }: RouteContext) { try { const { id } = await params; - const parsedId = idParamSchema.safeParse(id); + const parsedId = userIdParamSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json({ error: "ID inválido" }, { status: 400 }); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b2f668b..c73d59c 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -78,14 +78,14 @@ export default async function DashboardPage() { const [totalUsuarios, totalRoles, usuariosActivos, usuariosPendientes, nuevosClientes, recientes] = await Promise.all([ - prisma.user.count({ where: { role: { nombre: "Cliente" }, deletedAt: null } }), + prisma.user.count({ where: { role: { name: "Cliente" }, deletedAt: null } }), prisma.role.count(), prisma.user.count({ where: { emailVerified: true, deletedAt: null } }), prisma.user.count({ where: { emailVerified: false, deletedAt: null } }), prisma.user.count({ where: { createdAt: { gte: sieteDiasAtras }, - role: { nombre: "Cliente" }, + role: { name: "Cliente" }, deletedAt: null, }, }), @@ -196,7 +196,7 @@ export default async function DashboardPage() { {usuario.email} - {usuario.role?.nombre ?? "Sin rol"} + {usuario.role?.name ?? "Sin rol"} {dateFormatter.format(usuario.createdAt)} diff --git a/app/dashboard/roles/page.tsx b/app/dashboard/roles/page.tsx index abbae80..8ca2149 100644 --- a/app/dashboard/roles/page.tsx +++ b/app/dashboard/roles/page.tsx @@ -33,10 +33,10 @@ import { } from "@/components/ui/pagination"; import { toast } from "sonner"; -type Role = { id: number; nombre: string; descripcion: string | null; creadoEn: string }; -type RoleForm = { nombre: string; descripcion: string }; -type EditingRole = { id: number; nombre: string; descripcion: string } | null; -type ConfirmDelete = { id: number; nombre: string } | null; +type Role = { id: number; name: string; description: string | null; createdAt: string }; +type RoleForm = { name: string; description: string }; +type EditingRole = { id: number; name: string; description: string } | null; +type ConfirmDelete = { id: number; name: string } | null; const LIMIT = 5; @@ -50,7 +50,7 @@ const getPageNumbers = (current: number, total: number): (number | "ellipsis")[] return pages; }; -const emptyForm: RoleForm = { nombre: "", descripcion: "" }; +const emptyForm: RoleForm = { name: "", description: "" }; export default function RolesPage() { const [roles, setRoles] = useState([]); @@ -115,7 +115,7 @@ export default function RolesPage() { const res = await fetch(`/api/roles/${editingRole.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nombre: editingRole.nombre, descripcion: editingRole.descripcion }), + body: JSON.stringify({ name: editingRole.name, description: editingRole.description }), }); if (!res.ok) { const data = await res.json(); @@ -181,10 +181,10 @@ export default function RolesPage() { roles.map((role) => ( {role.id} - {role.nombre} - {role.descripcion || "—"} + {role.name} + {role.description || "—"} - {new Date(role.creadoEn).toLocaleDateString("es-MX")} + {new Date(role.createdAt).toLocaleDateString("es-MX")} @@ -195,8 +195,8 @@ export default function RolesPage() { onClick={() => setEditingRole({ id: role.id, - nombre: role.nombre, - descripcion: role.descripcion ?? "", + name: role.name, + description: role.description ?? "", }) } > @@ -207,7 +207,7 @@ export default function RolesPage() { size="icon-sm" aria-label="Eliminar" className="hover:text-destructive" - onClick={() => setConfirmDelete({ id: role.id, nombre: role.nombre })} + onClick={() => setConfirmDelete({ id: role.id, name: role.name })} > @@ -274,8 +274,8 @@ export default function RolesPage() { Nombre setCreateForm((f) => ({ ...f, nombre: e.target.value }))} + value={createForm.name} + onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} required /> @@ -283,8 +283,8 @@ export default function RolesPage() { Descripción setCreateForm((f) => ({ ...f, descripcion: e.target.value }))} + value={createForm.description} + onChange={(e) => setCreateForm((f) => ({ ...f, description: e.target.value }))} /> @@ -308,9 +308,9 @@ export default function RolesPage() { Nombre - setEditingRole((r) => (r ? { ...r, nombre: e.target.value } : null)) + setEditingRole((r) => (r ? { ...r, name: e.target.value } : null)) } /> @@ -318,9 +318,9 @@ export default function RolesPage() { Descripción - setEditingRole((r) => (r ? { ...r, descripcion: e.target.value } : null)) + setEditingRole((r) => (r ? { ...r, description: e.target.value } : null)) } /> @@ -340,7 +340,7 @@ export default function RolesPage() { ¿Eliminar rol? - Se eliminará el rol "{confirmDelete?.nombre}" de forma permanente. + Se eliminará el rol "{confirmDelete?.name}" de forma permanente. diff --git a/app/dashboard/usuarios/page.tsx b/app/dashboard/usuarios/page.tsx index b873075..c7eb9e1 100644 --- a/app/dashboard/usuarios/page.tsx +++ b/app/dashboard/usuarios/page.tsx @@ -30,8 +30,8 @@ import { import { toast } from "sonner"; import { cn } from "@/lib/utils"; -type EditingUser = { id: number; name: string; email: string }; -type ConfirmDelete = { id: number; name: string } | null; +type EditingUser = { id: string; name: string; email: string }; +type ConfirmDelete = { id: string; name: string } | null; const LIMIT = 5; @@ -118,13 +118,13 @@ export default function UsuariosPage() { } }; - const handleDelete = async (id: number) => { + const handleDelete = async (id: string) => { await fetch(`/api/users/${id}`, { method: "DELETE" }); fetchUsuarios(currentPage); toast.success("Usuario movido a la papelera"); }; - const handleRestore = async (id: number) => { + const handleRestore = async (id: string) => { await fetch(`/api/users/${id}`, { method: "PATCH" }); fetchUsuarios(currentPage); toast.success("Usuario restaurado"); diff --git a/lib/auth.config.ts b/lib/auth.config.ts index 1cc61e2..e33e9ce 100644 --- a/lib/auth.config.ts +++ b/lib/auth.config.ts @@ -14,14 +14,14 @@ export const authConfig = { const existingUser = await prisma.user.findUnique({ where: { email } }) if (!existingUser) { - const clienteRole = await prisma.role.findUnique({ where: { nombre: "Cliente" } }) + const clienteRole = await prisma.role.findUnique({ where: { name: "Cliente" } }) await prisma.user.create({ data: { name: user.name ?? profile?.name ?? email, email, password: null, emailVerified: true, - roleId: clienteRole?.id ?? null, + roleId: clienteRole?.id, }, }) } else if (!existingUser.emailVerified) { @@ -40,8 +40,8 @@ export const authConfig = { where: { email: token.email }, include: { role: true }, }) - if (dbUser) token.id = String(dbUser.id) - token.role = dbUser?.role?.nombre ?? null + if (dbUser) token.id = dbUser.id + token.role = dbUser?.role?.name ?? null } return token }, diff --git a/lib/auth.ts b/lib/auth.ts index c092047..312b7a4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -38,7 +38,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (!user.emailVerified) throw new EmailNotVerifiedError() - return { id: String(user.id), email: user.email, name: user.name } + return { id: user.id, email: user.email, name: user.name } }, }), Google, diff --git a/lib/validations.ts b/lib/validations.ts index ed9357b..9379d08 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -22,9 +22,12 @@ export const resetPasswordSchema = z.object({ password: passwordSchema, }) -/** Valida params dinámicos [id]: deben ser enteros positivos. */ +/** Valida params dinámicos [id] de Role: deben ser enteros positivos. */ export const idParamSchema = z.coerce.number().int().positive() +/** Valida params dinámicos [id] de User: deben ser UUID. */ +export const userIdParamSchema = z.uuid() + export const createUserSchema = z.object({ name: z.string().min(2, "Mínimo 2 caracteres").max(100), email: z.string().email("Email inválido"), @@ -33,8 +36,8 @@ export const createUserSchema = z.object({ export const updateUserSchema = createUserSchema.partial() export const createRoleSchema = z.object({ - nombre: z.string().min(2, "Mínimo 2 caracteres").max(100), - descripcion: z.string().max(255).optional(), + name: z.string().min(2, "Mínimo 2 caracteres").max(100), + description: z.string().max(255).optional(), }) export const updateRoleSchema = createRoleSchema.partial() diff --git a/prisma.config.ts b/prisma.config.ts index cfb31f4..9eb86c6 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -4,9 +4,9 @@ import { defineConfig } from "@prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { - seed: "npx tsx prisma/seed.ts", + seed: "npx tsx prisma/seed-roles.ts && npx tsx prisma/seed.ts", }, datasource: { - url: process.env.DATABASE_URL!, + url: process.env.DIRECT_URL ?? process.env.DATABASE_URL!, }, }); \ No newline at end of file diff --git a/prisma/migrations/20260612020953_uuid_pk_and_role_i18n/migration.sql b/prisma/migrations/20260612020953_uuid_pk_and_role_i18n/migration.sql new file mode 100644 index 0000000..1c5dde1 --- /dev/null +++ b/prisma/migrations/20260612020953_uuid_pk_and_role_i18n/migration.sql @@ -0,0 +1,30 @@ +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_roleId_fkey"; + +-- DropIndex +DROP INDEX "Role_nombre_key"; + +-- AlterTable +ALTER TABLE "Role" DROP COLUMN "creadoEn", +DROP COLUMN "descripcion", +DROP COLUMN "nombre", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "description" TEXT, +ADD COLUMN "name" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "User" DROP CONSTRAINT "User_pkey", +DROP COLUMN "id", +ADD COLUMN "id" UUID NOT NULL, +ALTER COLUMN "roleId" SET NOT NULL, +ALTER COLUMN "roleId" SET DEFAULT 2, +ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); + +-- CreateIndex +CREATE INDEX "User_roleId_idx" ON "User"("roleId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d2adbc..733c4e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,7 +7,7 @@ datasource db { } model User { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) @db.Uuid name String email String @unique password String? @@ -16,16 +16,18 @@ model User { verifyTokenExp DateTime? resetToken String? @unique resetTokenExp DateTime? - roleId Int? - role Role? @relation(fields: [roleId], references: [id]) + roleId Int @default(2) + role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) createdAt DateTime @default(now()) deletedAt DateTime? + + @@index([roleId]) } model Role { id Int @id @default(autoincrement()) - nombre String @unique - descripcion String? - creadoEn DateTime @default(now()) + name String @unique + description String? + createdAt DateTime @default(now()) users User[] } \ No newline at end of file diff --git a/prisma/seed-roles.ts b/prisma/seed-roles.ts index 053cd25..2c4ccb3 100644 --- a/prisma/seed-roles.ts +++ b/prisma/seed-roles.ts @@ -7,15 +7,15 @@ const prisma = new PrismaClient({ adapter }) async function main() { await prisma.role.upsert({ - where: { nombre: "Admin" }, + where: { name: "Admin" }, update: {}, - create: { nombre: "Admin", descripcion: "Acceso completo al sistema" }, + create: { name: "Admin", description: "Acceso completo al sistema" }, }) await prisma.role.upsert({ - where: { nombre: "Cliente" }, + where: { name: "Cliente" }, update: {}, - create: { nombre: "Cliente", descripcion: "Usuario cliente del estudio" }, + create: { name: "Cliente", description: "Usuario cliente del estudio" }, }) console.log("✓ Roles base creados: Admin, Cliente") diff --git a/prisma/seed.ts b/prisma/seed.ts index 0f1ba67..f42ba70 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -7,6 +7,7 @@ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) const prisma = new PrismaClient({ adapter }) async function main() { + const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: "Admin" } }) const hashed = await bcrypt.hash("admin123", 10) await prisma.user.upsert({ @@ -17,6 +18,7 @@ async function main() { name: "Admin", password: hashed, emailVerified: true, + roleId: adminRole.id, }, })