Resumen de lo entregado
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-07 09:47:31 -03:00
parent 2922d4c2f3
commit bc9f70419c
26 changed files with 4102 additions and 23 deletions

View File

@@ -0,0 +1 @@
v2.109.0

View File

@@ -0,0 +1 @@
{"ref":"xgqbkxcliyjzisoccsur","name":"inq-roi-desa","organization_id":"ekmgshaupdqaebwypwwj","organization_slug":"ekmgshaupdqaebwypwwj"}

424
supabase/docker-compose.yml Normal file
View File

@@ -0,0 +1,424 @@
# Usage
# Start: docker compose up -d
# Stop: docker compose down
# Reset everything: sh reset.sh
#
# Variables de entorno específicas de InQ ROI (agregar a .env además de las de InQ Sizing):
# APP_CODE_PATH — path absoluto al directorio code/ del app en Dokploy.
# Se obtiene después de crear la aplicación InQ ROI en Dokploy.
# Ejemplo: /etc/dokploy/applications/inq-roi-app-abc123/code
# GOTRUE_URI_ALLOW_LIST — URLs permitidas para redirect OAuth.
# Ejemplo: https://inq-roi.inqualityhq.com/**,http://localhost:5173/**
name: supabase
services:
studio:
image: supabase/studio:2026.06.03-sha-0bca601
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
ENABLED_FEATURES_LOGS_ALL: "false"
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
volumes:
- ./volumes/snippets:/app/snippets:z
# [InQ ROI] path al repo del app en Dokploy — configurar APP_CODE_PATH en .env
- ${APP_CODE_PATH}/supabase/functions:/app/edge-functions:ro,z
kong:
image: kong/kong:3.9.1
restart: unless-stopped
networks:
default:
aliases:
- api-gw
dokploy-network: {}
healthcheck:
test: ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
depends_on:
studio:
condition: service_healthy
ports:
- ${KONG_HTTP_PORT}:8000/tcp
- ${KONG_HTTPS_PORT}:8443/tcp
volumes:
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
- ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: /home/kong/kong-entrypoint.sh
auth:
image: supabase/gotrue:v2.189.0
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
# [InQ ROI] configurar en .env — ej: https://inq-roi.inqualityhq.com/**,http://localhost:5173/**
GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
# Google OAuth — credenciales en .env (misma Google Cloud Console app o una nueva)
GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true"
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}"
GOTRUE_EXTERNAL_GOOGLE_SECRET: "${GOTRUE_EXTERNAL_GOOGLE_SECRET}"
# [InQ ROI] redirect URI usa API_EXTERNAL_URL — solo cambiar ese valor en .env
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "${API_EXTERNAL_URL}/auth/v1/callback"
rest:
image: postgrest/postgrest:v14.12
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command: ["postgrest"]
realtime:
image: supabase/realtime:v2.102.3
restart: unless-stopped
depends_on:
db:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
]
timeout: 5s
interval: 30s
retries: 3
start_period: 10s
environment:
PORT: 4000
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
METRICS_JWT_SECRET: ${JWT_SECRET}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
DISABLE_HEALTHCHECK_LOGGING: "true"
storage:
image: supabase/storage-api:v1.60.4
restart: unless-stopped
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://storage:5000/status"
]
timeout: 5s
interval: 5s
retries: 3
start_period: 10s
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
AUTH_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
REGION: ${REGION}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
volumes:
- ./volumes/storage:/var/lib/storage:z
imgproxy:
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./volumes/storage:/var/lib/storage:z
healthcheck:
test: ["CMD", "imgproxy", "health"]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
functions:
image: supabase/edge-runtime:v1.74.0
restart: unless-stopped
volumes:
# [InQ ROI] path al repo del app en Dokploy — configurar APP_CODE_PATH en .env
- ${APP_CODE_PATH}/supabase/functions:/home/deno/functions:z
- deno-cache:/root/.cache/deno
depends_on:
kong:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/9000'"]
interval: 5s
timeout: 5s
retries: 3
environment:
JWT_SECRET: ${JWT_SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# [InQ ROI] SITE_URL se inyecta en las Edge Functions como variable de entorno
SITE_URL: ${SITE_URL}
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
command:
[
"start",
"--main-service",
"/home/deno/functions/main"
]
db:
image: supabase/postgres:15.8.1.085
restart: unless-stopped
volumes:
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
- db-data:/var/lib/postgresql/data:Z
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
- db-config:/etc/postgresql-custom
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${POSTGRES_PORT}
POSTGRES_PORT: ${POSTGRES_PORT}
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
command:
[
"postgres",
"-c", "config_file=/etc/postgresql/postgresql.conf",
"-c", "log_min_messages=fatal"
]
supavisor:
image: supabase/supavisor:2.9.5
restart: unless-stopped
ports:
- ${POSTGRES_PORT}:5432
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
volumes:
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
healthcheck:
test:
[
"CMD", "curl", "-sSfL", "--head", "-o", "/dev/null",
"http://127.0.0.1:4000/api/health"
]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
depends_on:
db:
condition: service_healthy
environment:
PORT: 4000
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
CLUSTER_POSTGRES: "true"
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
API_JWT_SECRET: ${JWT_SECRET}
METRICS_JWT_SECRET: ${JWT_SECRET}
REGION: local
ERL_AFLAGS: -proto_dist inet_tcp
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
POOLER_POOL_MODE: transaction
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
command:
[
"/bin/sh", "-c",
"/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"
]
volumes:
db-config:
deno-cache:
db-data:
networks:
default: {}
dokploy-network:
external: true

View File

@@ -0,0 +1,195 @@
/**
* 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': Deno.env.get('SITE_URL') ?? '*',
'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 },
)
})

View File

@@ -0,0 +1,37 @@
/**
* Edge Function: main (relay)
* Enruta todas las requests al worker de la función correspondiente.
* Requerido por supabase/edge-runtime en self-hosted (--main-service).
* No expuesto directamente — solo opera como router interno.
*/
const FUNCTIONS = ['invite-user', 'revoke-user']
Deno.serve(async (req: Request) => {
const url = new URL(req.url)
// Extraer nombre de función del path: /functions/v1/invite-user → invite-user
const segments = url.pathname.split('/').filter(Boolean)
const functionName = segments[segments.length - 1]
if (!functionName || !FUNCTIONS.includes(functionName)) {
return new Response(
JSON.stringify({ error: `Función no encontrada: ${functionName}` }),
{ status: 404, headers: { 'Content-Type': 'application/json' } },
)
}
const servicePath = `/home/deno/functions/${functionName}`
try {
// EdgeRuntime.userWorkers es el API del supabase/edge-runtime para crear workers por función
const worker = await EdgeRuntime.userWorkers.create({ servicePath })
return await worker.fetch(req)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
)
}
})

View File

@@ -0,0 +1,118 @@
/**
* Edge Function: revoke-user
* Propósito: revocar acceso de un usuario (suspensión suave — cambia platform_role a 'guest')
* Solo accesible por platform_admin. No elimina datos ni el usuario de auth.users.
* 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 revoke-user
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
interface RevokeUserRequest {
userId: string
}
Deno.serve(async (req: Request) => {
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 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 revocar accesos' }),
{ status: 403, headers },
)
}
// ── 2. Parsear y validar el body ──────────────────────────────────────────
let body: RevokeUserRequest
try {
body = await req.json() as RevokeUserRequest
} catch {
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
}
const { userId } = body
if (!userId?.trim()) {
return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
}
// ── 3. Prevenir auto-revocación ───────────────────────────────────────────
if (userId === caller.id) {
return new Response(
JSON.stringify({ error: 'No puedes revocar tu propio acceso' }),
{ status: 400, headers },
)
}
// ── 4. Cambiar platform_role a 'guest' ────────────────────────────────────
// Suspensión suave: el usuario queda en auth.users pero RLS bloquea acceso a datos.
const { error: updateError } = await adminClient
.from('users')
.update({ platform_role: 'guest' })
.eq('id', userId)
if (updateError) {
return new Response(
JSON.stringify({ error: `Error al revocar acceso: ${updateError.message}` }),
{ status: 500, headers },
)
}
// ── 5. Revocar sesiones activas (opcional) ────────────────────────────────
// Intenta cerrar las sesiones activas del usuario. Si el método no está disponible
// en esta versión del cliente, el rol 'guest' ya impide el acceso via RLS.
try {
await adminClient.auth.admin.signOut(userId)
} catch {
// No crítico: el rol ya fue cambiado. Las sesiones expirarán naturalmente.
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers },
)
})

View File

@@ -0,0 +1,58 @@
-- Tabla de organizaciones clientes
-- is_provider = true identifica a InQuality (org administradora de la plataforma)
CREATE TABLE IF NOT EXISTS public.organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
is_provider boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
-- Constraint único en name — requerido para ON CONFLICT (name) DO NOTHING en los INSERTs
-- siguientes. Debe estar ANTES de cualquier INSERT (orden crítico).
ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name);
-- Insertar la organización de InQuality como proveedor
-- ON CONFLICT DO NOTHING: idempotente si ya existe
INSERT INTO public.organizations (name, is_provider)
VALUES ('InQuality', true)
ON CONFLICT DO NOTHING;
-- Agregar org_id a users (nullable — usuarios InQuality existentes no tienen org de cliente)
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Agregar org_id a processes (nullable — procesos existentes se vinculan por migración automática)
ALTER TABLE public.processes
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Ampliar check constraint de platform_role para incluir roles de cliente
-- Primero eliminar la restricción existente, luego recrear con los valores nuevos
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check;
ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check
CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'));
-- ── Migración automática: crear orgs desde valores únicos de processes.client ──
-- Crea una organización por cada client name único y no vacío.
-- ON CONFLICT (name) DO NOTHING requiere el constraint organizations_name_unique definido arriba.
INSERT INTO public.organizations (name, is_provider)
SELECT DISTINCT trim(client), false
FROM public.processes
WHERE client IS NOT NULL AND trim(client) != ''
ON CONFLICT (name) DO NOTHING;
-- Vincular procesos a sus orgs recién creadas
UPDATE public.processes p
SET org_id = o.id
FROM public.organizations o
WHERE trim(o.name) = trim(p.client)
AND o.is_provider = false
AND p.org_id IS NULL;
-- Asignar org_id de InQuality a usuarios InQuality existentes
-- (los que tienen platform_role = 'platform_admin' o 'member')
UPDATE public.users u
SET org_id = (SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1)
WHERE u.org_id IS NULL
AND u.platform_role IN ('platform_admin', 'member');

View File

@@ -0,0 +1,293 @@
-- ── SECURITY DEFINER helpers ──────────────────────────────────────────────────
-- Retorna el org_id del usuario actual (null para usuarios sin org asignada)
CREATE OR REPLACE FUNCTION public.current_org()
RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT org_id FROM public.users WHERE id = auth.uid()
$$;
-- Retorna true si el usuario pertenece a la organización proveedora (InQuality)
CREATE OR REPLACE FUNCTION public.is_inquality()
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT EXISTS (
SELECT 1 FROM public.users u
JOIN public.organizations o ON o.id = u.org_id
WHERE u.id = auth.uid() AND o.is_provider = true
)
$$;
-- Retorna el platform_role del usuario actual
CREATE OR REPLACE FUNCTION public.current_platform_role()
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT platform_role FROM public.users WHERE id = auth.uid()
$$;
-- ── Trigger handle_new_user ───────────────────────────────────────────────────
-- Auto-crea fila en public.users para usuarios @inquality.com.py que se loguean
-- con Google OAuth. Los usuarios clientes se crean via Edge Function (invite),
-- no por este trigger.
-- ON CONFLICT (id) DO NOTHING: no sobrescribir si la Edge Function ya creó la fila.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
provider_org_id uuid;
BEGIN
IF new.email LIKE '%@inquality.com.py' THEN
SELECT id INTO provider_org_id
FROM public.organizations
WHERE is_provider = true
LIMIT 1;
INSERT INTO public.users (id, name, avatar_url, platform_role, org_id)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'full_name', new.email),
new.raw_user_meta_data->>'avatar_url',
'member',
provider_org_id
)
ON CONFLICT (id) DO NOTHING;
END IF;
RETURN new;
END;
$$;
-- Crear trigger (DROP IF EXISTS para idempotencia)
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
-- ── RLS: organizations ────────────────────────────────────────────────────────
CREATE POLICY "orgs_select" ON public.organizations
FOR SELECT USING (
public.is_inquality()
OR id = public.current_org()
);
CREATE POLICY "orgs_insert" ON public.organizations
FOR INSERT WITH CHECK (public.is_platform_admin());
CREATE POLICY "orgs_update" ON public.organizations
FOR UPDATE USING (public.is_platform_admin());
CREATE POLICY "orgs_delete" ON public.organizations
FOR DELETE USING (public.is_platform_admin());
-- ── RLS: users ───────────────────────────────────────────────────────────────
-- Reemplazar la política permisiva "read_users" (migration 001) con políticas granulares
DROP POLICY IF EXISTS "read_users" ON public.users;
CREATE POLICY "users_select_own" ON public.users
FOR SELECT USING (id = auth.uid());
CREATE POLICY "users_select_admin" ON public.users
FOR SELECT USING (public.is_platform_admin());
-- ── RLS: processes ────────────────────────────────────────────────────────────
-- Reemplazar las tres políticas de migration 012 con versiones que incluyen acceso por org
DROP POLICY IF EXISTS "select_processes" ON public.processes;
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (
public.current_org() IS NOT NULL
AND org_id = public.current_org()
)
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
-- client_editor y client_viewer NO pueden crear procesos nuevos
);
DROP POLICY IF EXISTS "update_processes" ON public.processes;
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
-- ── RLS: activities ──────────────────────────────────────────────────────────
-- Reemplazar "all_activities" (migration 006) con políticas granulares por rol
DROP POLICY IF EXISTS "all_activities" ON public.activities;
-- SELECT: acceso transitivo — si el usuario puede ver el proceso padre (via processes SELECT policy),
-- puede ver sus actividades. El EXISTS aplica RLS de processes automáticamente.
CREATE POLICY "activities_select" ON public.activities
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
);
-- INSERT / UPDATE: InQuality + client_editor pueden escribir; client_viewer y guest no
CREATE POLICY "activities_insert" ON public.activities
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "activities_update" ON public.activities
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
-- DELETE: solo InQuality (platform_admin o member)
CREATE POLICY "activities_delete" ON public.activities
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: resources ────────────────────────────────────────────────────────────
-- Reemplazar "all_resources" (migration 005) con políticas granulares por rol.
-- NOTA: la tabla resources no tiene columna process_id en este schema — los recursos
-- son catálogo global. La policy SELECT mantiene acceso a todos los usuarios autenticados
-- (equivalente a la política original) y se alinea con supabaseResourceRepo.getAll().
DROP POLICY IF EXISTS "all_resources" ON public.resources;
CREATE POLICY "resources_select" ON public.resources
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "resources_insert" ON public.resources
FOR INSERT WITH CHECK (
auth.uid() IS NOT NULL
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "resources_update" ON public.resources
FOR UPDATE USING (
auth.uid() IS NOT NULL
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "resources_delete" ON public.resources
FOR DELETE USING (
public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: simulations ─────────────────────────────────────────────────────────
-- Reemplazar "all_simulations" (migration 009) con políticas granulares por rol
DROP POLICY IF EXISTS "all_simulations" ON public.simulations;
CREATE POLICY "simulations_select" ON public.simulations
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
);
CREATE POLICY "simulations_insert" ON public.simulations
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "simulations_delete" ON public.simulations
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
AND public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: activity_resource_assignments ───────────────────────────────────────
-- Reemplazar "all_assignments" (migration 007 — nombre real, no "all_activity_resource_assignments")
-- con políticas granulares. Heredan acceso del proceso via actividad padre.
DROP POLICY IF EXISTS "all_assignments" ON public.activity_resource_assignments;
CREATE POLICY "ara_select" ON public.activity_resource_assignments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
);
CREATE POLICY "ara_write" ON public.activity_resource_assignments
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "ara_update" ON public.activity_resource_assignments
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "ara_delete" ON public.activity_resource_assignments
FOR DELETE USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
-- ── RLS: gateways ────────────────────────────────────────────────────────────
-- Reemplazar "all_gateways" (migration 008) con políticas granulares por rol
DROP POLICY IF EXISTS "all_gateways" ON public.gateways;
CREATE POLICY "gateways_select" ON public.gateways
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
);
CREATE POLICY "gateways_write" ON public.gateways
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "gateways_update" ON public.gateways
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);