Multi-número
Workspace compartido: invitar admins y usuarios
Cómo un admin invita a más gente y por qué la cuenta nueva puede usar toda la app sin configurar nada: sin conectar WhatsApp, sin pegar tokens, sin crear pipelines ni etiquetas.
Cubre las migraciones 009, 010, 023 y 024.
1. La idea en una línea
profiles.organization_id es el user_id del dueño del workspace, y
todas las filas compartidas se archivan bajo ese id.
Eso es todo el diseño. La 009 sembró organization_id = user_id para el
primer usuario, así que "la organización" y "el dueño" son literalmente el
mismo UUID. Un invitado hereda ese organization_id, y con eso:
- lee lo mismo que el dueño, porque las RLS dicen
user_id IN (SELECT org_user_ids()); - escribe bajo el mismo id, así que lo que crea lo ve el resto del equipo.
No hay tabla organizations, y no hace falta.
2. Invitar
Configuración → Team. Solo un role = 'admin' ve el formulario.
POST /api/team con { email, full_name, role }, donde role es 'admin'
o 'user':
- Crea el usuario en Supabase Auth con
email_confirm: trueyuser_metadata = { organization_id, role, invite_status: 'pending' }. - El trigger
handle_new_user(migración 009) lee esos metadatos y crea elprofilescon la organización y el rol correctos. - Devuelve una contraseña temporal si no le pasaste una. Es la única vez que se ve: no se guarda en claro en ningún sitio.
Si el email ya tiene cuenta, no se duplica: se le mueve de organización y se
le vuelve a poner invite_status: 'pending' para que confirme.
PATCH /api/team cambia el rol de un miembro; DELETE /api/team?id= lo
elimina. Ambos rechazan operar sobre uno mismo — un admin no puede
degradarse ni borrarse y dejar el workspace sin dueño.
La pantalla de invitación
Al entrar por primera vez, el invitado ve una pantalla: aceptar o
rechazar (dashboard-shell.tsx). Aceptar deja invite_status = 'accepted' y
entra al workspace del equipo. Rechazar le devuelve a un workspace propio
(organization_id = su user_id, rol admin), no le deja fuera de la app.
Esa pantalla es consentimiento, no configuración: es un clic, y detrás no hay nada que rellenar.
3. Qué ve un rol user
Las RLS de la migración 010 acotan contactos y conversaciones por asignación:
user_id IN (SELECT org_user_ids())
AND (is_admin() OR assigned_to = auth.uid() OR assigned_to IS NULL)
Un admin ve todo. Un user ve lo suyo y lo que no está asignado a nadie. Es
deliberado: un agente no debería leer la cartera de otro agente.
Todo lo demás —números conectados, plantillas, pipelines, etiquetas, respuestas rápidas, automatizaciones— es del workspace y lo comparten los dos roles.
4. Lo que faltaba, y las dos migraciones que lo arreglan
La 009 amplió las RLS de las tablas principales, pero quedaban tres agujeros por los que un invitado abría la app y la veía vacía.
4.1 Tablas que se quedaron en auth.uid() = user_id
quick_replies, contact_groups, bot_sessions y channel_configs nunca
se ampliaron. La 023 les pone una política de organización.
4.2 El código filtraba por el usuario logueado
Las RLS ya permitían leer las filas del equipo, pero las rutas del servidor
añadían .eq('user_id', user.id) encima. El resultado: la base tenía los
datos, la política los dejaba pasar, y la consulta los descartaba.
La corrección tiene dos mitades:
- Servidor:
getWorkspace()(src/lib/workspace.ts) devuelveownerId, y cada ruta filtra por él. Para un usuario que está solo en su workspace,ownerId === userIdy el comportamiento es exactamente el de antes. - Cliente: se borró el filtro. Las RLS ya acotan la lectura a la
organización, así que el
.eq('user_id', …)del navegador solo servía para esconder los datos del equipo. Menos código y correcto.
4.3 Los INSERT seguían archivando bajo el invitado
Quedaban unos quince sitios que escriben user_id: user.id — formularios de
contacto, importador CSV, pipelines, etiquetas, difusiones. Parchear los
quince es la versión larga, y deja fuera el siguiente que alguien escriba.
La 024 lo arregla donde pasan todos: un trigger BEFORE INSERT en cada
tabla compartida que reescribe user_id al dueño del workspace.
SELECT p.organization_id INTO _owner FROM profiles p WHERE p.user_id = NEW.user_id;
IF _owner IS NOT NULL AND _owner <> NEW.user_id THEN
NEW.user_id := _owner;
END IF;
Al dueño no le cambia nada. A un usuario sin perfil todavía (el trigger de auth aún no corrió) tampoco.
4.4 Lo que ya estaba mal archivado
La 023 reasigna las filas que un invitado creó bajo su propio id, antes de que existiera el trigger. Suelta los índices únicos de la 021 y la 022 durante el movimiento y los recrea después: un UPDATE que junta dos miembros en la misma cuenta puede chocar a mitad de sentencia —dos predeterminados, dos plantillas con el mismo nombre— y Postgres no difiere esa comprobación. Los duplicados se resuelven entre medias.
5. Orden de despliegue
Aplica 021 → 022 → 023 → 024 y despliega después. La 023 depende de los índices que crean la 021 y la 022, y la 024 debe existir antes de que entre tráfico nuevo o volverás a acumular filas mal archivadas.
6. Comprobación
Con dos cuentas:
- Como admin, invita a
agente@ejemplo.comcon rol User. Guarda la contraseña temporal que devuelve el diálogo. - Entra con esa cuenta en otro navegador. Acepta la invitación.
- Sin tocar Configuración, la bandeja debe mostrar las conversaciones del equipo y el banner de WhatsApp debe decir conectado.
- Crea un contacto desde esa cuenta. Vuelve al admin: el contacto está ahí.
- Sube el rol a Admin desde Configuración → Team. Ahora ve también los contactos asignados a otros agentes.
- Un admin no puede cambiarse el rol ni borrarse: ambas acciones responden
7. Diagnóstico rápido
| Síntoma | Causa probable |
|---|---|
| El invitado entra y lo ve todo vacío | Falta la 023, o una ruta todavía filtra por user.id en vez de ownerId |
| El invitado crea algo y el admin no lo ve | Falta la 024 (el trigger), o la fila es anterior a ella — reejecuta la 023 |
| "WhatsApp no está conectado" en la cuenta invitada | El componente sigue filtrando por user_id en el navegador; debe apoyarse en RLS |
Un user no ve ningún contacto |
Están todos asignados a otro agente — es la RLS de la 010, no un fallo |
| El invitado se queda en la pantalla de invitación | invite_status quedó en pending; aceptar o rechazar lo resuelve, ambas cosas le dejan usar la app |
| Dos predeterminados de WhatsApp tras fusionar | La 023 lo corrige antes de recrear el índice parcial; reejecútala |
Para la parte de multinúmero y coexistencia, ver coexistencia-y-bandejas.md.