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.
Aquí está la diferencia que decide el diseño. El producto deduplica por una llave: el nombre normalizado. El cliente tiene tres campos @unique — email, dni y phoneNumber— y cada choque significa algo distinto. Tratarlos igual es lo que convierte un atajo en un problema de datos.
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.
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.
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.
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.
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.
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 existe — PHONE_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:
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.
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.
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.
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.