/** * 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 * - SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY: disponibles automáticamente en Edge Functions * - SITE_URL: agregar manualmente en Supabase Dashboard → Edge Functions → Secrets * Valor producción: https://inq-roi.inqualityhq.com * Deploy: supabase functions deploy invite-user */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' interface InviteUserRequest { email: string name: string platform_role: 'client_editor' | 'client_viewer' | 'member' org_name?: string } const ALLOWED_ROLES = ['client_editor', 'client_viewer', 'member'] as const const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ Deno.serve(async (req: Request) => { const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } // Manejar CORS preflight if (req.method === 'OPTIONS') { return new Response('ok', { headers }) } if (req.method !== 'POST') { return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers }) } 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 ', '') if (!callerToken) { return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers }) } const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken) if (authError || !caller) { return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers }) } // Verificar platform_role desde DB — el JWT no es fuente confiable para el rol const { data: callerProfile, error: profileError } = await adminClient .from('users') .select('platform_role') .eq('id', caller.id) .single() if (profileError || callerProfile?.platform_role !== 'platform_admin') { return new Response(JSON.stringify({ error: 'Forbidden: solo platform_admin puede invitar usuarios' }), { status: 403, headers }) } // ── 2. Parsear y validar el body ────────────────────────────────────────── let body: InviteUserRequest try { body = await req.json() as InviteUserRequest } catch { return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers }) } const { email, name, platform_role, org_name } = body if (!email || !EMAIL_REGEX.test(email)) { return new Response(JSON.stringify({ error: 'Email inválido o no proporcionado' }), { status: 400, headers }) } if (!name?.trim()) { return new Response(JSON.stringify({ error: 'El campo name es requerido' }), { status: 400, headers }) } if (!platform_role || !ALLOWED_ROLES.includes(platform_role)) { return new Response( JSON.stringify({ error: `platform_role debe ser uno de: ${ALLOWED_ROLES.join(', ')}` }), { status: 400, headers }, ) } if ((platform_role === 'client_editor' || platform_role === 'client_viewer') && !org_name?.trim()) { return new Response( JSON.stringify({ error: 'org_name es requerido para roles client_editor y client_viewer' }), { status: 400, headers }, ) } // ── 3. Resolver org_id ──────────────────────────────────────────────────── let orgId: string if (platform_role === 'member') { // Nuevo miembro InQuality → la org es siempre InQuality (is_provider = true) const { data: inqOrg, error: inqOrgError } = await adminClient .from('organizations') .select('id') .eq('is_provider', true) .single() if (inqOrgError || !inqOrg) { return new Response( JSON.stringify({ error: 'No se encontró la organización InQuality en la base de datos' }), { status: 500, headers }, ) } orgId = inqOrg.id } else { // Usuario cliente → buscar org por nombre exacto (trim), crear si no existe const trimmedOrgName = org_name!.trim() const { data: existingOrg } = await adminClient .from('organizations') .select('id') .eq('name', trimmedOrgName) .single() if (existingOrg) { orgId = existingOrg.id } else { const { data: newOrg, error: createOrgError } = await adminClient .from('organizations') .insert({ name: trimmedOrgName, is_provider: false }) .select('id') .single() if (createOrgError || !newOrg) { return new Response( JSON.stringify({ error: `Error al crear organización: ${createOrgError?.message ?? 'desconocido'}` }), { status: 500, headers }, ) } orgId = newOrg.id } } // ── 4. Invitar usuario vía Auth Admin API ───────────────────────────────── // inviteUserByEmail crea el usuario en auth.users y envía el email de bienvenida. // Si el email ya existe en auth.users, retorna un error descriptivo. const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail( email, { data: { full_name: name.trim() }, // se almacena en raw_user_meta_data redirectTo: `${Deno.env.get('SITE_URL') ?? ''}/auth/callback`, }, ) if (inviteError) { return new Response( JSON.stringify({ error: inviteError.message }), { status: 400, headers }, ) } // ── 5. Crear fila en public.users ───────────────────────────────────────── // UPSERT con ignoreDuplicates: false para manejar la race condition con handle_new_user: // si el trigger ya creó la fila (emails @inquality.com.py), actualizar platform_role y org_id. const { error: upsertError } = await adminClient .from('users') .upsert( { id: inviteData.user.id, name: name.trim(), platform_role, org_id: orgId, }, { onConflict: 'id', ignoreDuplicates: false }, ) if (upsertError) { return new Response( JSON.stringify({ error: `Error al crear perfil de usuario: ${upsertError.message}` }), { status: 500, headers }, ) } return new Response( JSON.stringify({ success: true, userId: inviteData.user.id }), { status: 200, headers }, ) })