# Etapa 2 — Edge Function: invitación de usuarios cliente **Sprint:** 7 **Fecha:** 2026-07-06 **Alcance:** Edge Function de Supabase + endpoint de revocación. Cero cambios en frontend. Cero cambios en migraciones existentes (001-015 son historia inmutable). --- ## Contexto Las migraciones 014-015 ya están aplicadas en producción: - `public.organizations` existe con InQuality (is_provider=true) - `public.users.org_id` + `public.users.platform_role` con 4 roles: platform_admin, member, client_editor, client_viewer - SECURITY DEFINER helpers: `current_org()`, `is_inquality()`, `current_platform_role()` - RLS activo en todas las tablas Esta etapa crea el flujo de invitación: un `platform_admin` llama a una Edge Function que: 1. Crea el usuario en `auth.users` de Supabase (vía Admin API) 2. Crea o reutiliza la organización cliente 3. Inserta la fila en `public.users` con el rol correcto y org_id 4. Envía el email de bienvenida vía Supabase Auth inviteUserByEmail El patrón es el mismo que InQ Sizing (`autenticacion-y-usuarios.md`), adaptado al schema de InQ ROI. --- ## Estructura de archivos a crear ``` supabase/ └── functions/ ├── invite-user/ │ └── index.ts ← Edge Function principal └── revoke-user/ └── index.ts ← Edge Function de revocación/suspensión ``` --- ## Edge Function: `invite-user` ### Request ```typescript // POST /functions/v1/invite-user // Authorization: Bearer { email: string, // email del usuario a invitar name: string, // nombre completo platform_role: 'client_editor' | 'client_viewer' | 'member', org_name: string, // nombre de la organización (se crea si no existe) // Si platform_role = 'member', org_name se ignora → org será InQuality } ``` ### Validaciones - Solo `platform_admin` puede llamar esta función. Verificar con `adminClient.auth.getUser(token)` + consulta a `public.users` para confirmar `platform_role = 'platform_admin'`. - `email` requerido, formato válido. - `platform_role` debe ser uno de los tres valores permitidos. - `org_name` requerido si `platform_role` es `client_editor` o `client_viewer`. - No invitar emails que ya existen en `auth.users` (verificar antes). ### Lógica ```typescript import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const adminClient = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) // 1. Autenticar al llamador const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '') const { data: { user: caller } } = await adminClient.auth.getUser(callerToken) const { data: callerProfile } = await adminClient .from('users') .select('platform_role') .eq('id', caller.id) .single() if (callerProfile?.platform_role !== 'platform_admin') { return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) } // 2. Resolver org_id let orgId: string if (platform_role === 'member') { // Nuevo miembro InQuality → org es InQuality const { data: inqOrg } = await adminClient .from('organizations') .select('id') .eq('is_provider', true) .single() orgId = inqOrg.id } else { // Usuario cliente → buscar o crear org por nombre const { data: existingOrg } = await adminClient .from('organizations') .select('id') .eq('name', org_name.trim()) .single() if (existingOrg) { orgId = existingOrg.id } else { const { data: newOrg } = await adminClient .from('organizations') .insert({ name: org_name.trim(), is_provider: false }) .select('id') .single() orgId = newOrg.id } } // 3. Invitar usuario vía Auth Admin API (envía email automáticamente) const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail( email, { data: { full_name: name }, // raw_user_meta_data redirectTo: `${Deno.env.get('SITE_URL')}/auth/callback` } ) if (inviteError) { return new Response(JSON.stringify({ error: inviteError.message }), { status: 400 }) } // 4. Crear fila en public.users // UPSERT (no INSERT) para manejar race condition: si el trigger handle_new_user // ya corrió (emails @inquality.com.py), no duplicar. const { error: upsertError } = await adminClient .from('users') .upsert({ id: inviteData.user.id, name, platform_role, org_id: orgId, }, { onConflict: 'id', ignoreDuplicates: false }) if (upsertError) { return new Response(JSON.stringify({ error: upsertError.message }), { status: 500 }) } return new Response(JSON.stringify({ success: true, userId: inviteData.user.id }), { status: 200 }) ``` ### Headers de respuesta Siempre incluir: ```typescript const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } ``` Manejar OPTIONS (CORS preflight): ```typescript if (req.method === 'OPTIONS') { return new Response('ok', { headers }) } ``` --- ## Edge Function: `revoke-user` ### Request ```typescript // POST /functions/v1/revoke-user // Authorization: Bearer { userId: string // UUID del usuario a revocar } ``` ### Lógica ```typescript // 1. Verificar que el llamador es platform_admin (igual que invite-user) // 2. No permitir auto-revocación if (userId === caller.id) { return new Response(JSON.stringify({ error: 'No puedes revocar tu propio acceso' }), { status: 400 }) } // 3. Cambiar platform_role a 'guest' (suspensión suave, no elimina datos) const { error } = await adminClient .from('users') .update({ platform_role: 'guest' }) .eq('id', userId) if (error) { return new Response(JSON.stringify({ error: error.message }), { status: 500 }) } // 4. Opcionalmente: revocar sesiones activas await adminClient.auth.admin.signOut(userId) return new Response(JSON.stringify({ success: true }), { status: 200 }) ``` --- ## Variables de entorno requeridas Las Edge Functions de Supabase tienen acceso automático a: - `SUPABASE_URL` - `SUPABASE_SERVICE_ROLE_KEY` - `SUPABASE_ANON_KEY` Agregar manualmente en Supabase Dashboard → Edge Functions → Secrets: - `SITE_URL` = `https://inq-roi.inqualityhq.com` Documentar esto en un comentario al tope de cada función. --- ## TypeScript — tipado estricto ```typescript interface InviteUserRequest { email: string name: string platform_role: 'client_editor' | 'client_viewer' | 'member' org_name?: string } interface RevokeUserRequest { userId: string } ``` Validar con guardas de tipo antes de proceder. Retornar errores descriptivos en español para consumo interno. --- ## Criterios de validación - [ ] `supabase/functions/invite-user/index.ts` existe y compila (Deno/TypeScript) - [ ] `supabase/functions/revoke-user/index.ts` existe y compila - [ ] Llamada sin Authorization header → 401 - [ ] Llamada con token de usuario no-admin → 403 - [ ] Llamada con email ya existente → error descriptivo (no crash) - [ ] Llamada con platform_role inválido → error descriptivo - [ ] `npm run test` → 552+ tests verdes (ningún test nuevo requerido en esta etapa — el E2E de invite requiere despliegue real) - [ ] `npm run build` limpio ## Documentar en comentario al tope de cada función ```typescript /** * Edge Function: invite-user * Propósito: invitar usuario externo (client_editor/client_viewer) o interno (member) * Solo accesible por platform_admin. * Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL * Deploy: supabase functions deploy invite-user */ ``` --- ## NO modificar - Ningún archivo de migración (001-015 son historia inmutable) - Ningún archivo TypeScript/React del frontend - Ningún test existente - Las tablas `user_groups`, `group_memberships`, `process_access` y sus policies