274 lines
7.8 KiB
Markdown
274 lines
7.8 KiB
Markdown
# 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 <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
|
|
|
|
```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 <access_token del platform_admin>
|
|
{
|
|
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
|