Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"postinstall": "prisma generate",
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"build": "prisma db push && next build --turbopack",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Remove prisma db push from the build script.

next build should not require live DB access or mutate schema state; run schema changes in a dedicated migration/deploy step instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 8, The build script currently couples schema mutation
with application build by running prisma db push before next build; remove
prisma db push from the build entry so the build is isolated from live database
access and schema changes. Update the package.json build script only, keeping
next build --turbopack as the build command, and move any schema update behavior
to a separate migration/deploy workflow.

"start": "next start",
"test": "vitest",
"test:ui": "vitest --ui",
Expand Down Expand Up @@ -59,4 +59,4 @@
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18"
}
}
}
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ model Usuario {
ultimoAcceso DateTime? @map("ultimo_acceso")
intentosFallidos Int @default(0) @map("intentos_fallidos")
bloqueadoHasta DateTime? @map("bloqueado_hasta")
motivoInactivo String? @map("motivo_inactivo")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
permisos Permiso[]
Expand Down
139 changes: 138 additions & 1 deletion src/app/api/usuarios/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,30 @@ export async function DELETE(
message: "Usuario eliminado permanentemente",
});
} else {
let motivo = "";
try {
const body = await request.json();
if (body.motivoInactivo !== undefined && typeof body.motivoInactivo !== "string") {
return NextResponse.json(
{ success: false, message: "motivoInactivo debe ser una cadena de texto" },
{ status: 400 }
);
}
motivo = body.motivoInactivo || "";
} catch (e) {
// Ignorar si no hay body
}

if (!motivo || motivo.trim() === '') {
return NextResponse.json(
{ success: false, message: "El motivo de inactivación es requerido" },
{ status: 400 }
);
}

await prisma.usuario.update({
where: { id },
data: { estado: 0 },
data: { estado: 0, motivoInactivo: motivo.trim() },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
return NextResponse.json({
success: true,
Expand All @@ -86,3 +107,119 @@ export async function DELETE(
);
}
}

export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const caller = getAuthenticatedUser(request);
if (!caller) {
return NextResponse.json(
{ success: false, message: "No autenticado" },
{ status: 401 }
);
}

if (caller.rol !== "admin") {
return NextResponse.json(
{ success: false, message: "No tienes permisos" },
{ status: 403 }
);
}

const { id: idParam } = await params;
const id = parseInt(idParam, 10);

if (isNaN(id)) {
return NextResponse.json(
{ success: false, message: "ID de usuario inválido" },
{ status: 400 }
);
}

const body = await request.json();
const { estado, motivoInactivo } = body;

if (estado !== undefined && typeof estado !== "number") {
return NextResponse.json(
{ success: false, message: "El estado debe ser un número (0 o 1)" },
{ status: 400 }
);
}

if (estado !== undefined && estado !== 0 && estado !== 1) {
return NextResponse.json(
{ success: false, message: "Estado no soportado" },
{ status: 400 }
);
}

if (motivoInactivo !== undefined && typeof motivoInactivo !== "string") {
return NextResponse.json(
{ success: false, message: "motivoInactivo debe ser una cadena de texto" },
{ status: 400 }
);
}

const dataToUpdate: any = {};
if (estado !== undefined) {
dataToUpdate.estado = estado;
if (estado === 0) {
if (!motivoInactivo || motivoInactivo.trim() === "") {
return NextResponse.json(
{ success: false, message: "El motivo de inactivación es requerido" },
{ status: 400 }
);
}
}
}

if (motivoInactivo !== undefined) {
dataToUpdate.motivoInactivo = motivoInactivo.trim();
}

if (estado === 1) {
dataToUpdate.motivoInactivo = null;
}

const usuario = await prisma.usuario.update({
where: { id },
data: dataToUpdate,
select: {
id: true,
nombreCompleto: true,
username: true,
email: true,
rol: true,
estado: true,
ultimoAcceso: true,
intentosFallidos: true,
bloqueadoHasta: true,
motivoInactivo: true,
createdAt: true,
updatedAt: true,
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return NextResponse.json({
success: true,
message: "Usuario actualizado correctamente",
data: usuario,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
} catch (error: any) {
console.error("Error al actualizar usuario:", error);

if (error?.code === 'P2025') {
return NextResponse.json(
{ success: false, message: "Usuario no encontrado" },
{ status: 404 }
);
}

return NextResponse.json(
{ success: false, message: "Error interno del servidor" },
{ status: 500 }
);
}
}
134 changes: 116 additions & 18 deletions src/app/gestion-usuarios/gestion-usuarios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type UsuarioRow = {
email: string;
rol: string;
estado: number;
motivoInactivo?: string | null;
};

type RolDef = { key: string; label: string; esBase: boolean };
Expand Down Expand Up @@ -201,10 +202,9 @@ export default function GestionUsuariosPage() {
denyButtonText: 'Desactivar (Soft Delete)',
cancelButtonText: 'Cancelar'
}).then(async (result) => {
if (result.isConfirmed || result.isDenied) {
const isHardDelete = result.isConfirmed;
if (result.isConfirmed) {
try {
const res = await fetch(`/api/usuarios/${id}?hardDelete=${isHardDelete}`, {
const res = await fetch(`/api/usuarios/${id}?hardDelete=true`, {
method: "DELETE"
});
const json = await res.json();
Expand All @@ -217,6 +217,70 @@ export default function GestionUsuariosPage() {
} catch (error) {
Swal.fire('Error', 'No se pudo conectar con el servidor', 'error');
}
} else if (result.isDenied) {
const { value: motivo } = await Swal.fire({
title: 'Motivo de inactivación',
input: 'text',
inputLabel: 'Ingresa el motivo (ej. Ya no pertenece a la iglesia)',
inputPlaceholder: 'Escribe el motivo aquí...',
showCancelButton: true,
inputValidator: (value) => {
if (!value || !value.trim()) {
return '¡Debes escribir un motivo!';
}
}
});

if (motivo) {
try {
const res = await fetch(`/api/usuarios/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: 0, motivoInactivo: motivo })
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const json = await res.json();
if (res.ok && json.success) {
Swal.fire('¡Desactivado!', 'El usuario ha sido desactivado correctamente.', 'success');
cargarUsuarios();
} else {
Swal.fire('Error', json.message || 'Error al desactivar el usuario', 'error');
}
} catch (error) {
Swal.fire('Error', 'No se pudo conectar con el servidor', 'error');
}
}
}
});
};

const handleReactivar = (id: number, nombre: string) => {
Swal.fire({
title: 'Reactivar Usuario',
text: `¿Estás seguro de reactivar a ${nombre}?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#16a34a',
cancelButtonColor: '#6b7280',
confirmButtonText: 'Sí, reactivar',
cancelButtonText: 'Cancelar'
}).then(async (result) => {
if (result.isConfirmed) {
try {
const res = await fetch(`/api/usuarios/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: 1 })
});
const json = await res.json();
if (res.ok && json.success) {
Swal.fire('¡Activado!', 'El usuario ha sido reactivado correctamente.', 'success');
cargarUsuarios();
} else {
Swal.fire('Error', json.message || 'Error al reactivar el usuario', 'error');
}
} catch (error) {
Swal.fire('Error', 'No se pudo conectar con el servidor', 'error');
}
}
});
};
Expand Down Expand Up @@ -439,15 +503,33 @@ export default function GestionUsuariosPage() {
<span className={`w-1.5 h-1.5 rounded-full ${u.estado === 1 ? "bg-green-500" : "bg-red-500"}`} />
{u.estado === 1 ? "Activo" : "Inactivo"}
</span>
{u.estado === 0 && u.motivoInactivo && (
<p className="text-[10px] text-red-600 mt-1 truncate max-w-[150px]" title={u.motivoInactivo}>
{u.motivoInactivo}
</p>
)}
</td>
<td className="px-6 py-4">
<button
onClick={() => handleEliminar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-red-50 text-red-600 hover:bg-red-600 hover:text-white border border-red-200 hover:border-red-600 rounded-lg transition-colors shadow-sm"
title="Eliminar usuario"
>
<FaTrash className="text-sm" />
</button>
<div className="flex items-center gap-2">
{u.estado === 0 && (
<button
onClick={() => handleReactivar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-green-50 text-green-600 hover:bg-green-600 hover:text-white border border-green-200 hover:border-green-600 rounded-lg transition-colors shadow-sm"
title="Activar usuario"
aria-label={`Activar usuario ${u.nombreCompleto}`}
>
<span className="font-bold">✓</span>
</button>
)}
<button
onClick={() => handleEliminar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-red-50 text-red-600 hover:bg-red-600 hover:text-white border border-red-200 hover:border-red-600 rounded-lg transition-colors shadow-sm"
title={u.estado === 0 ? "Eliminar permanentemente" : "Eliminar o Desactivar"}
aria-label={u.estado === 0 ? `Eliminar permanentemente usuario ${u.nombreCompleto}` : `Eliminar o desactivar usuario ${u.nombreCompleto}`}
>
<FaTrash className="text-sm" />
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</td>
</tr>
))}
Expand All @@ -468,16 +550,32 @@ export default function GestionUsuariosPage() {
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${u.estado === 1 ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{u.estado === 1 ? "Activo" : "Inactivo"}
</span>
<button
onClick={() => handleEliminar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-red-50 text-red-600 hover:bg-red-600 hover:text-white border border-red-200 hover:border-red-600 rounded-lg transition-colors shadow-sm"
title="Eliminar usuario"
>
<FaTrash className="text-sm" />
</button>
<div className="flex items-center gap-2">
{u.estado === 0 && (
<button
onClick={() => handleReactivar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-green-50 text-green-600 hover:bg-green-600 hover:text-white border border-green-200 hover:border-green-600 rounded-lg transition-colors shadow-sm"
title="Activar usuario"
aria-label={`Activar usuario ${u.nombreCompleto}`}
>
<span className="font-bold">✓</span>
</button>
)}
<button
onClick={() => handleEliminar(u.id, u.nombreCompleto)}
className="inline-flex items-center justify-center w-8 h-8 bg-red-50 text-red-600 hover:bg-red-600 hover:text-white border border-red-200 hover:border-red-600 rounded-lg transition-colors shadow-sm"
title={u.estado === 0 ? "Eliminar permanentemente" : "Eliminar o Desactivar"}
aria-label={u.estado === 0 ? `Eliminar permanentemente usuario ${u.nombreCompleto}` : `Eliminar o desactivar usuario ${u.nombreCompleto}`}
>
<FaTrash className="text-sm" />
</button>
</div>
</div>
</div>
<p className="text-xs text-gray-500 mb-2">{u.email}</p>
<p className="text-xs text-gray-500 mb-1">{u.email}</p>
{u.estado === 0 && u.motivoInactivo && (
<p className="text-[10px] text-red-600 mb-2 font-medium">Motivo: {u.motivoInactivo}</p>
)}
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getRolColor(u.rol).badge}`}>
{roles.find(r => r.key === u.rol)?.label ?? u.rol}
</span>
Expand Down
2 changes: 2 additions & 0 deletions src/dao/usuario.dao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function mapToUsuario(row: any): Usuario {
ultimoAcceso: row.ultimoAcceso,
intentosFallidos: row.intentosFallidos,
bloqueadoHasta: row.bloqueadoHasta,
motivoInactivo: row.motivoInactivo,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand All @@ -31,6 +32,7 @@ export class UsuarioDAO {
ultimoAcceso: true,
intentosFallidos: true,
bloqueadoHasta: true,
motivoInactivo: true,
createdAt: true,
updatedAt: true,
},
Expand Down
1 change: 1 addition & 0 deletions src/models/Usuario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface Usuario {
ultimoAcceso: Date | null;
intentosFallidos: number;
bloqueadoHasta: Date | null;
motivoInactivo?: string | null;
createdAt: Date;
updatedAt: Date;
}
Expand Down