7.8 KiB
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.organizationsexiste con InQuality (is_provider=true)public.users.org_id+public.users.platform_rolecon 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:
- Crea el usuario en
auth.usersde Supabase (vía Admin API) - Crea o reutiliza la organización cliente
- Inserta la fila en
public.userscon el rol correcto y org_id - 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
// POST /functions/v1/invite-user
// Authorization: Bearer <access_token del platform_admin>
{
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_adminpuede llamar esta función. Verificar conadminClient.auth.getUser(token)+ consulta apublic.userspara confirmarplatform_role = 'platform_admin'. emailrequerido, formato válido.platform_roledebe ser uno de los tres valores permitidos.org_namerequerido siplatform_roleesclient_editoroclient_viewer.- No invitar emails que ya existen en
auth.users(verificar antes).
Lógica
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:
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):
if (req.method === 'OPTIONS') {
return new Response('ok', { headers })
}
Edge Function: revoke-user
Request
// POST /functions/v1/revoke-user
// Authorization: Bearer <access_token del platform_admin>
{
userId: string // UUID del usuario a revocar
}
Lógica
// 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_URLSUPABASE_SERVICE_ROLE_KEYSUPABASE_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
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.tsexiste y compila (Deno/TypeScript)supabase/functions/revoke-user/index.tsexiste 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 buildlimpio
Documentar en comentario al tope de cada función
/**
* 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_accessy sus policies