feat: el cliente puede ver y modificar sus datos de contacto #6

Merged
jpbeldomenico merged 2 commits from feat/datos-de-contacto into main 2026-08-05 16:09:46 -03:00
3 changed files with 43 additions and 15 deletions
Showing only changes of commit ce7063514c - Show all commits
+26 -13
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
Review

Importás verificarSesion desde auth-admin.ts para validar la cookie cliente_session. Verificá que esa función valide correctamente el rol de cliente y no solo la existencia de la sesión de admin. Si reutiliza lógica de admin, podría haber escalada de privilegios.

#ai-review-inline

Importás `verificarSesion` desde `auth-admin.ts` para validar la cookie `cliente_session`. Verificá que esa función valide correctamente el rol de cliente y no solo la existencia de la sesión de admin. Si reutiliza lógica de admin, podría haber escalada de privilegios. #ai-review-inline
import { verificarSesion } from "@/lib/auth-admin";
import { aFormatoWhatsapp, aLocalArgentino, esLocalArgentinoValido } from "@/lib/telefono";
@@ -8,7 +9,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Datos de contacto del cliente logueado. El teléfono se devuelve en formato local
// (sin el +54 9) porque el formulario lo muestra como prefijo fijo.
export async function GET() {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const cliente = await prisma.cliente.findUnique({
@@ -26,7 +27,7 @@ export async function GET() {
}
export async function PATCH(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, apellido, email, telefono } = await req.json();
1
@@ -62,17 +63,29 @@ export async function PATCH(req: NextRequest) {
if (telefonoTomado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
const cliente = await prisma.cliente.update({
where: { id: sesion.sub },
data: {
nombre: nombre.trim(),
apellido: apellido.trim(),
email: emailNuevo,
telefono: aLocalArgentino(telefonoNorm),
telefonoNorm,
},
select: { nombre: true, apellido: true, email: true, telefonoNorm: true },
});
// Entre la comprobación de arriba y este update otra request puede tomar el mismo
// email o teléfono; los índices únicos lo cortan y acá se traduce a un 409 legible.
let cliente;
try {
cliente = await prisma.cliente.update({
where: { id: sesion.sub },
Outdated
Review

Guardás el teléfono en formato local en el campo telefono y en formato WhatsApp en telefonoNorm. El GET lee telefonoNorm primero y hace fallback a telefono. Si telefonoNorm existe, telefono es redundante. Confirmá que el schema tenga @@unique([telefonoNorm]) y @@unique([email]) para que la BD sea la fuente de verdad.

#ai-review-inline

Guardás el teléfono en formato local en el campo `telefono` y en formato WhatsApp en `telefonoNorm`. El GET lee `telefonoNorm` primero y hace fallback a `telefono`. Si `telefonoNorm` existe, `telefono` es redundante. Confirmá que el schema tenga `@@unique([telefonoNorm])` y `@@unique([email])` para que la BD sea la fuente de verdad. #ai-review-inline
data: {
nombre: nombre.trim(),
apellido: apellido.trim(),
email: emailNuevo,
telefono: aLocalArgentino(telefonoNorm),
telefonoNorm,
},
select: { nombre: true, apellido: true, email: true, telefonoNorm: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002")
return NextResponse.json(
{ error: "Ese email o teléfono ya está registrado" },
{ status: 409 }
);
throw e;
}
return NextResponse.json({
ok: true,
+9
View File
@@ -31,6 +31,7 @@ export default function PerfilPage() {
fetch("/api/cliente/perfil")
Review

El fetch inicial no maneja errores de red: si falla la request (p.ej. sin conexión), el .then no se ejecuta y loading queda en true para siempre. Agregá .catch(() => { setLoading(false); setError('Error de conexión'); }) o usá try/catch con async/await.

  useEffect(() => {
    async function cargar() {
      try {
        const r = await fetch('/api/cliente/perfil');
        if (r.status === 401) { router.push('/cliente'); return; }
        const data = await r.json();
        setPerfil(data);
        setForm(data);
      } catch {
        setError('Error de conexión');
      } finally {
        setLoading(false);
      }
    }
    cargar();
  }, [router]);

#ai-review-inline

El fetch inicial no maneja errores de red: si falla la request (p.ej. sin conexión), el `.then` no se ejecuta y `loading` queda en `true` para siempre. Agregá `.catch(() => { setLoading(false); setError('Error de conexión'); })` o usá try/catch con async/await. ```suggestion useEffect(() => { async function cargar() { try { const r = await fetch('/api/cliente/perfil'); if (r.status === 401) { router.push('/cliente'); return; } const data = await r.json(); setPerfil(data); setForm(data); } catch { setError('Error de conexión'); } finally { setLoading(false); } } cargar(); }, [router]); ``` #ai-review-inline
.then((r) => {
if (r.status === 401) { router.push("/cliente"); return null; }
if (!r.ok) throw new Error("respuesta con error");
return r.json();
})
.then((data) => {
@@ -38,6 +39,11 @@ export default function PerfilPage() {
setPerfil(data);
setForm(data);
setLoading(false);
})
// Sin esto, si el pedido falla la pantalla se queda en "Cargando..." para siempre.
.catch(() => {
setError("No pudimos cargar tus datos. Probá recargar la página.");
setLoading(false);
});
}, [router]);
1
@@ -74,6 +80,9 @@ export default function PerfilPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
// Si la sesión venció mientras editaba, mandarlo a entrar de nuevo en vez de
// mostrarle un error suelto que no explica nada.
if (res.status === 401) { router.push("/cliente"); return; }
const data = await res.json();
setGuardando(false);
if (!res.ok) { setError(data.error || "No se pudieron guardar los cambios"); return; }
+8 -2
View File
@@ -28,14 +28,20 @@ export async function crearSesionJefe(jefeId: string): Promise<string> {
}
export async function verificarSesion(
cookieName: string
cookieName: string,
rolEsperado?: "admin" | "cliente" | "jefe"
): Promise<{ sub: string; role: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as { sub: string; role: string };
const sesion = payload as { sub: string; role: string };
// Los tres tipos de sesión se firman con el mismo secreto, así que un token de
// admin o de jefe puesto en la cookie del cliente pasa la verificación de firma.
// Cuando el rol importa, se comprueba acá.
if (rolEsperado && sesion.role !== rolEsperado) return null;
return sesion;
} catch {
return null;
}