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':

  1. Crea el usuario en Supabase Auth con email_confirm: true y user_metadata = { organization_id, role, invite_status: 'pending' }.
  2. El trigger handle_new_user (migración 009) lee esos metadatos y crea el profiles con la organización y el rol correctos.
  3. 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) devuelve ownerId, y cada ruta filtra por él. Para un usuario que está solo en su workspace, ownerId === userId y 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:

  1. Como admin, invita a agente@ejemplo.com con rol User. Guarda la contraseña temporal que devuelve el diálogo.
  2. Entra con esa cuenta en otro navegador. Acepta la invitación.
  3. Sin tocar Configuración, la bandeja debe mostrar las conversaciones del equipo y el banner de WhatsApp debe decir conectado.
  4. Crea un contacto desde esa cuenta. Vuelve al admin: el contacto está ahí.
  5. Sube el rol a Admin desde Configuración → Team. Ahora ve también los contactos asignados a otros agentes.
  6. 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.