propuesta · v1.0 · jul 2026

Cliente en línea

El producto se crea escribiéndolo. El cliente no.

En el asistente, un producto que no existe se crea al escribirlo y la cotización sigue. Un cliente que no existe es un callejón sin salida: hay que abandonar la cotización, ir a otra sección —a la que Andy además no tiene acceso— y volver a empezar. Esta propuesta le da al cliente la misma experiencia que ya tiene el producto, y no es una analogía suelta: el producto ya trae la implementación de referencia.

El modelo
0 tablas · 0 columnas
Endpoint nuevo
1 · con la forma del producto
Campos
4, no 10
Riesgo
Medio · abre una puerta de escritura
Todo lo de aquí está verificado contra el código, con archivo y línea. Los nombres de las maquetas son inventados — esta página es pública.
01La referencia ya está escrita
verificado en el código

El producto funciona así hoy: POST /sourced-products, y su propia documentación lo dice mejor que nosotros — «Dedupe-safe on the normalized name. The inline row picker POSTs { name, category } on the fly.» Dos campos. Un AGENT puede. Si el producto ya existía, lo resuelve en vez de duplicarlo.

Y trae la forma exacta que necesitamos copiar, no inventar:

@Post()
@Roles(RoleEnum.ADMIN, RoleEnum.MANAGER, RoleEnum.AGENT)
@CanCreate('SUPPLIERS')

Un piso de rol grueso más un permiso fino. Es el idioma de la casa, y es el que usaremos.

02El cliente tiene tres llaves, no una
schema.prisma · model User

Aquí está la diferencia que decide el diseño. El producto deduplica por una llave: el nombre normalizado. El cliente tiene tres campos @uniqueemail, dni y phoneNumber— y cada choque significa algo distinto. Tratarlos igual es lo que convierte un atajo en un problema de datos.

Choca el correo resolver
Choca el teléfono, con otro correo parar
No choca nada crear
ya es cliente · lo selecciona y sigue dos identidades para una persona · muestra la existente nuevo

El caso del medio es el importante y es el que nadie diseña: si el teléfono ya existe pero con otro correo, no estamos ante un cliente nuevo, estamos ante un duplicado a punto de nacer. El flujo no crea nada y ofrece la ficha existente.

03Dos obstáculos, los dos comprobados
probado con su token

Andy no puede crear clientes

clients.controller.ts lleva @Roles(ADMIN, MANAGER) a nivel de clase, así que aplica a todos sus endpoints. Probado: GET /clients le devuelve 403.

Y el permiso solo no alcanza: los guards corren Roles → Permissions, así que RolesGuard rechaza antes de que la matriz fina se consulte. Hace falta abrir el rol en el método — que sí sobreescribe, porque el guard usa getAllAndOverride.

El create actual manda una clave por correo

clients.service.ts genera una contraseña temporal y la envía. Para un cliente creado a media cotización eso está mal: recibiría credenciales que nadie pidió.

Y contradice lo ya decidido — registro por WhatsApp con enlace mágico, sin clave. El cliente creado así queda sin credenciales hasta que se registre él.

04Dónde vive: en la fila que ya está ahí
sin modal sobre modal

No hay pantalla nueva. Dentro del mismo UserSearchSelect, cuando la búsqueda no encuentra nada, la última fila deja de ser «sin resultados» y pasa a ser la acción.

Cliente
Marta Bellorín
Sin resultados para «Marta Bellorín»
+Crear «Marta Bellorín»

Al pulsarla se despliega en el sitio, cuatro campos, y al guardar el cliente queda seleccionado en el formulario. La cotización nunca se abandona.

Crear cliente
Crear y seleccionar

El teléfono es obligatorio aquí aunque el modelo lo permita nulo, y esa es una decisión, no un descuido: WhatsApp es el canal principal de Mogos, y un cliente sin teléfono es un cliente al que no se le puede escribir. El DTO y el zod lo tienen optional() — el formulario en línea, no.

05Qué se valida, y con qué
nada de reglas a mano

Cuatro campos es poco, así que los cuatro tienen que ser buenos. Y ninguna de las tres validaciones se escribe a mano: las tres ya existen en el repo o en una librería que el repo ya usa.

El teléfono · «tamaño WhatsApp» tiene una definición exacta

No es una regla de longitud. phone.util.ts ya trae normalizeToE164 con libphonenumber-js, que conoce el plan de numeración de 240+ países: sabe que Venezuela es +58 más diez dígitos y que China es +86 más once.

La prueba de que la longitud no basta: +58 111 1111111 tiene el largo correcto y no es un teléfono. isValid() lo rechaza; un length === 10 lo acepta.

Y el resultado no es solo una validación: el E.164 que devuelve ES el wa_id de WhatsApp (los mismos dígitos, sin el «+»). Validar y normalizar son el mismo paso.

El correo · el campo que más pesa

z.string().email() comprueba la forma, no que exista. Aquí eso importa más que en cualquier otro formulario, porque el correo hace dos trabajos a la vez: es la llave de deduplicación y es a donde llega el enlace mágico.

Un correo con un dedazo no crea un dato feo: crea un cliente que nunca podrá entrar, y además bloquea el correo verdadero cuando alguien lo intente después, porque el campo es @unique.

Mitigación, porque verificarlo en línea no se puede: se normaliza (recorte y minúsculas) y se muestra normalizado antes de crear. Que la persona vea lo que va a guardarse.

El selector de país reutiliza el que ya existePHONE_COUNTRY_CODES en client-schema.ts, hoy VE · US · CO · MX · ES con sus prefijos. Pero le falta uno, y salta a la vista en cuanto se mira quién va a usar esto:

No hay China en la lista. El equipo que más va a crear clientes desde este formulario trabaja en Guangzhou. Hay que añadir CN: '+86' — una línea, y sin ella el selector no puede representar al proveedor ni al contacto local.

Un detalle de empaque que conviene decir en voz alta: libphonenumber-js es hoy dependencia solo del API. Para validar en el navegador hay que declararla en apps/admin — es isomorfa, así que la regla es la misma en las dos orillas y no se duplica el criterio. Conviene el paquete de metadatos min, no el max.

06Qué desaparece del trabajo
y el conteo de clics

Desaparece el callejón sin salida. No es que el camino actual sea largo: es que para Andy no existe — la sección de clientes le responde 403.

Hoy · camino de administración imposible
Propuesto · en la misma fila 4 clics
3 navegaciones + 10 campos + un selector de agente · y con 403 al final abrir la fila · 4 campos con tabulador · crear
07Qué NO hacemos, y por qué
decisiones, no omisiones
×

Reutilizar el formulario completo de cliente en un panel

Son diez campos y un selector de agente. Ese es el trabajo de administrar clientes, no el de cotizarle a uno. Meterlo en un panel lateral haría el atajo tan pesado como el camino largo.

×

Enviar clave temporal

Es lo que hace el create actual, y aquí sería un correo con credenciales que el cliente nunca pidió — probablemente en español, desde una entidad en China. El acceso se resuelve por el registro mágico de WhatsApp, que ya está decidido.

×

Abrir el rol a nivel de clase

Cambiar el @Roles del controlador le daría a todo AGENT el módulo completo: listar, editar, desactivar y reasignar agente. El permiso se abre en un método, no en la clase.

×

Pedir cédula, género o fecha de nacimiento

Son opcionales en el modelo y ninguno hace falta para cotizar. Si algún día se necesitan, se piden donde se usan — no en el momento en que alguien está tratando de cotizar.

08Qué se puede romper, y con qué arnés
el riesgo real

El riesgo no es el formulario: es que el endpoint nuevo se convierta en una puerta lateral al módulo de clientes. Abrimos escritura a un rol que hoy no tiene ninguna.

El arnés

  • @Roles en el método, jamás en la clase.
  • Un test que verifique que un AGENT sigue recibiendo 403 en GET /clients, PATCH /clients/:id y assign-agent. Si ese test se pone verde, la puerta se abrió.
  • El endpoint acepta cuatro campos y nada más: un agentId que llegue en el cuerpo se rechaza, no se ignora.

El supuesto que hay que confirmar

El cliente nace sin agente. User.agentId es la cartera y tiene consecuencia comercial; Andy cotiza desde China y no lleva cartera. Que nazca sin dueño y se asigne después, donde ya se asigna, es la opción que no inventa una regla comercial.

Si existe una regla que lo decida, esto cambia — y es lo único de esta propuesta que cambia.

La misma experiencia que el producto, porque el producto ya la resolvió.

No hay tabla nueva ni columna nueva. Hay un endpoint con la forma exacta del que ya existe para productos, cuatro campos en vez de diez, y una fila que deja de decir «sin resultados» para ofrecer la única cosa que en ese momento sirve. Lo único verdaderamente nuevo es el cuidado con las tres llaves únicas — porque el cliente, a diferencia del producto, puede duplicarse de tres maneras distintas.

mogos · propuesta de cliente en línea · v1.0 Verificado contra el código · julio de 2026