Files
inq-roi-simulador-web/sprints/sprint-7/ETAPA_2_PROMPT.md
Marcos Benítez bc9f70419c
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Resumen de lo entregado
2026-07-07 09:47:31 -03:00

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.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

// 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_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

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_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

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

/**
 * 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