From 62dab5e025e30afd54c8b989bec6782acbee0ef0 Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 15:54:38 -0300 Subject: [PATCH 1/2] feat(cliente): ver y modificar los datos de contacto El cliente no tenia forma de corregir su email ni su telefono despues del alta. Nueva pantalla /cliente/perfil ("Mis datos", enlazada desde el dashboard) que muestra nombre, email y telefono, y permite editarlos. - GET/PATCH /api/cliente/perfil, ambos con la sesion del cliente. - El telefono usa el mismo campo con el +54 9 fijo que el registro, y se guarda normalizado en telefonoNorm para que el bot lo siga reconociendo. - aLocalArgentino() saca el prefijo para mostrar: un cliente dado de alta por WhatsApp tiene el numero guardado como 5491141984844 y en el formulario tiene que verse 1141984844. - Email y telefono se validan unicos contra los demas clientes (409), no contra uno mismo. Si cambia el email se avisa que con ese va a entrar de ahora en mas, porque es la identidad del magic link. - El middleware protege /cliente/perfil como el resto del area del cliente. Co-Authored-By: Claude Opus 5 --- src/app/api/cliente/perfil/route.ts | 84 +++++++++++++++ src/app/cliente/dashboard/page.tsx | 11 +- src/app/cliente/perfil/page.tsx | 157 ++++++++++++++++++++++++++++ src/lib/telefono.ts | 10 ++ src/middleware.ts | 1 + 5 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/app/api/cliente/perfil/route.ts create mode 100644 src/app/cliente/perfil/page.tsx diff --git a/src/app/api/cliente/perfil/route.ts b/src/app/api/cliente/perfil/route.ts new file mode 100644 index 0000000..16d6887 --- /dev/null +++ b/src/app/api/cliente/perfil/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; +import { aFormatoWhatsapp, aLocalArgentino, esLocalArgentinoValido } from "@/lib/telefono"; + +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"); + if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const cliente = await prisma.cliente.findUnique({ + where: { id: sesion.sub }, + select: { nombre: true, apellido: true, email: true, telefono: true, telefonoNorm: true }, + }); + if (!cliente) return NextResponse.json({ error: "Cliente no encontrado" }, { status: 404 }); + + return NextResponse.json({ + nombre: cliente.nombre, + apellido: cliente.apellido, + email: cliente.email ?? "", + telefono: aLocalArgentino(cliente.telefonoNorm ?? cliente.telefono ?? ""), + }); +} + +export async function PATCH(req: NextRequest) { + const sesion = await verificarSesion("cliente_session"); + if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { nombre, apellido, email, telefono } = await req.json(); + + if (!nombre?.trim() || !apellido?.trim()) + return NextResponse.json({ error: "Nombre y apellido son obligatorios" }, { status: 400 }); + + if (!email?.trim() || !EMAIL_RE.test(email.trim())) + return NextResponse.json({ error: "El email no es válido" }, { status: 400 }); + + if (!esLocalArgentinoValido(telefono ?? "")) + return NextResponse.json( + { error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" }, + { status: 400 } + ); + + const emailNuevo = email.trim().toLowerCase(); + const telefonoNorm = aFormatoWhatsapp(telefono); + + // El email es con lo que se entra al sistema y el teléfono es con lo que el bot + // reconoce al cliente: los dos tienen que seguir siendo únicos. + const emailTomado = await prisma.cliente.findFirst({ + where: { email: emailNuevo, id: { not: sesion.sub } }, + select: { id: true }, + }); + if (emailTomado) + return NextResponse.json({ error: "Ese email ya está registrado" }, { status: 409 }); + + const telefonoTomado = await prisma.cliente.findFirst({ + where: { telefonoNorm, id: { not: sesion.sub } }, + select: { id: true }, + }); + 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 }, + }); + + return NextResponse.json({ + ok: true, + nombre: cliente.nombre, + apellido: cliente.apellido, + email: cliente.email ?? "", + telefono: aLocalArgentino(cliente.telefonoNorm ?? ""), + }); +} diff --git a/src/app/cliente/dashboard/page.tsx b/src/app/cliente/dashboard/page.tsx index b0b59a4..353237d 100644 --- a/src/app/cliente/dashboard/page.tsx +++ b/src/app/cliente/dashboard/page.tsx @@ -68,9 +68,14 @@ export default function DashboardPage() {

Mis autos

- +
+ + Mis datos + + +
{loading ? ( diff --git a/src/app/cliente/perfil/page.tsx b/src/app/cliente/perfil/page.tsx new file mode 100644 index 0000000..1c12079 --- /dev/null +++ b/src/app/cliente/perfil/page.tsx @@ -0,0 +1,157 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import Button from "@/components/ui/Button"; +import Card from "@/components/ui/Card"; +import Input from "@/components/ui/Input"; +import { esLocalArgentinoValido } from "@/lib/telefono"; + +interface Perfil { + nombre: string; + apellido: string; + email: string; + telefono: string; +} + +const VACIO: Perfil = { nombre: "", apellido: "", email: "", telefono: "" }; + +export default function PerfilPage() { + const router = useRouter(); + const [perfil, setPerfil] = useState(VACIO); + const [form, setForm] = useState(VACIO); + const [loading, setLoading] = useState(true); + const [editando, setEditando] = useState(false); + const [guardando, setGuardando] = useState(false); + const [error, setError] = useState(""); + const [telefonoError, setTelefonoError] = useState(""); + const [guardado, setGuardado] = useState(false); + + useEffect(() => { + fetch("/api/cliente/perfil") + .then((r) => { + if (r.status === 401) { router.push("/cliente"); return null; } + return r.json(); + }) + .then((data) => { + if (!data) return; + setPerfil(data); + setForm(data); + setLoading(false); + }); + }, [router]); + + function set(campo: keyof Perfil) { + return (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [campo]: e.target.value })); + } + + function handleTelefono(e: React.ChangeEvent) { + const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10); + setForm((f) => ({ ...f, telefono: soloDigitos })); + if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica"); + else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica"); + else setTelefonoError(""); + } + + function cancelar() { + setForm(perfil); + setEditando(false); + setError(""); + setTelefonoError(""); + } + + async function guardar(e: React.FormEvent) { + e.preventDefault(); + if (!esLocalArgentinoValido(form.telefono)) { + setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15"); + return; + } + setGuardando(true); + setError(""); + const res = await fetch("/api/cliente/perfil", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + const data = await res.json(); + setGuardando(false); + if (!res.ok) { setError(data.error || "No se pudieron guardar los cambios"); return; } + setPerfil(data); + setForm(data); + setEditando(false); + setGuardado(true); + setTimeout(() => setGuardado(false), 4000); + } + + if (loading) return
Cargando...
; + + const emailCambio = editando && form.email.trim().toLowerCase() !== perfil.email.toLowerCase(); + + return ( +
+
+ +

Mis datos

+
+ + + {!editando ? ( +
+ + + + {guardado &&

✅ Datos actualizados

} + +
+ ) : ( +
+
+ + +
+ + + {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +} + +function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) { + return ( +
+

{etiqueta}

+

{valor}

+
+ ); +} diff --git a/src/lib/telefono.ts b/src/lib/telefono.ts index a24e94a..c046cb7 100644 --- a/src/lib/telefono.ts +++ b/src/lib/telefono.ts @@ -29,3 +29,13 @@ export function esLocalArgentinoValido(local: string): boolean { export function aFormatoWhatsapp(local: string): string { return PREFIJO_WHATSAPP_AR + normalizarTelefono(local); } + +/** + * Inverso del anterior: 5491141984844 → 1141984844. Sirve para mostrar el número + * en el formulario (donde el +54 9 es un prefijo fijo), venga de un alta web o de + * una por WhatsApp, que lo guarda con el prefijo puesto. + */ +export function aLocalArgentino(valor: string): string { + const d = normalizarTelefono(valor); + return d.startsWith(PREFIJO_WHATSAPP_AR) ? d.slice(PREFIJO_WHATSAPP_AR.length) : d; +} diff --git a/src/middleware.ts b/src/middleware.ts index 1fd0c06..4321925 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -19,6 +19,7 @@ export async function middleware(req: NextRequest) { if (pathname.startsWith("/cliente/dashboard") || pathname.startsWith("/cliente/historial") || + pathname.startsWith("/cliente/perfil") || pathname.startsWith("/cliente/turnos")) { const ok = await verifyCookie(req, "cliente_session"); if (!ok) return NextResponse.redirect(new URL("/cliente", req.url)); From ce7063514c528b408fe2921bff1df00ea720f61a Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 16:09:40 -0300 Subject: [PATCH 2/2] fix(perfil): detalles senalados en la revision - verificarSesion acepta el rol esperado. Las tres sesiones (admin, cliente, jefe) se firman con el mismo secreto, asi que un token de jefe puesto en la cookie cliente_session pasaba la verificacion de firma; ahora el rol se comprueba. Verificado: con un JWT de jefe, /api/cliente/perfil da 401. - El update traduce el P2002 de los indices unicos a un 409 legible, por si otra request toma el mismo email o telefono entre la comprobacion y el update. - La pantalla ya no queda en "Cargando..." para siempre si falla el pedido. - Si la sesion vence mientras edita, el PATCH manda a entrar de nuevo en vez de mostrar un error suelto. Queda como mejora aparte pasarle el rol esperado al resto de las rutas del area del cliente, que hoy solo validan la firma. Co-Authored-By: Claude Opus 5 --- src/app/api/cliente/perfil/route.ts | 39 +++++++++++++++++++---------- src/app/cliente/perfil/page.tsx | 9 +++++++ src/lib/auth-admin.ts | 10 ++++++-- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/app/api/cliente/perfil/route.ts b/src/app/api/cliente/perfil/route.ts index 16d6887..1972006 100644 --- a/src/app/api/cliente/perfil/route.ts +++ b/src/app/api/cliente/perfil/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; 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(); @@ -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 }, + 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, diff --git a/src/app/cliente/perfil/page.tsx b/src/app/cliente/perfil/page.tsx index 1c12079..cf82f4c 100644 --- a/src/app/cliente/perfil/page.tsx +++ b/src/app/cliente/perfil/page.tsx @@ -31,6 +31,7 @@ export default function PerfilPage() { fetch("/api/cliente/perfil") .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]); @@ -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; } diff --git a/src/lib/auth-admin.ts b/src/lib/auth-admin.ts index b632d71..7d508e3 100644 --- a/src/lib/auth-admin.ts +++ b/src/lib/auth-admin.ts @@ -28,14 +28,20 @@ export async function crearSesionJefe(jefeId: string): Promise { } 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; }