Files
agenciapsilmno/supabase/functions/_shared/tenant.ts
T
Leonardo 9b21642e15 F4 schema-per-tenant: edge functions roteiam pro schema do tenant
- _shared/tenant.ts: helper (adminClient, tenantDbForId, schemaForTenant,
  listTenantSchemas, resolveTenantByChannel, tenantSchemaName)
- _shared/whatsapp-hooks.ts: hooks de tabela tenant recebem tdb; RPCs de
  credito (deduct/add_whatsapp_credits) e tenant_members seguem em supa+p_tenant_id
- inbound (twilio/evolution): tenant_id da URL -> tdb pra conversation_messages
  e notification_channels
- crons de fila (process-notification/email/sms/whatsapp-queue): varrem
  listTenantSchemas e drenam a fila de cada schema (Q3: filas sao per-tenant);
  modo single-tenant se body.tenant_id vier
- crons reminders/checks (send-session-reminders, conversation-sla-check,
  whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates):
  loop por tenant
- routing por tenant_id (send-whatsapp-message, send-session-reminder-manual,
  twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId;
  channel-actions sem tenant_id varrem schemas por channel_id
- asaas-*: tenant_id do body -> tdb; asaas-webhook fica global (whatsapp_credit_purchases)
- notification-webhook (Meta): resolve tenant via channel_routing por phone_number_id,
  fan-out por message_id quando nao resolve
- caller send-session-reminder-manual passa tenant_id (evento vive no schema)

Pendente: save-intake-progress e fluxos anon por token (decisao de roteamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 08:44:09 -03:00

102 lines
4.6 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Functions: helper schema-per-tenant
|--------------------------------------------------------------------------
| As tabelas tenant-scoped vivem em schemas físicos `tenant_<slug>` SEM a
| coluna tenant_id (docs/F0_categorizacao.md). Edge functions resolvem o
| schema a partir do tenant_id (que já chega via URL/body/linha) e usam
| `tdb.from(...)` para tabelas tenant. Tabelas GLOBAIS (tenants,
| tenant_members, profiles, subscriptions, addon_*, whatsapp_credit_*,
| channel_routing, audit_logs...) e RPCs continuam via o client público.
|
| Como edge functions usam service_role, `.schema(x)` exige que o schema
| esteja exposto no PostgREST (config.toml, F5). Schemas tenant entram lá
| na criação do tenant.
|--------------------------------------------------------------------------
*/
import { createClient, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
/** Espelha public.tenant_schema_name(slug). */
export function tenantSchemaName(slug: string | null | undefined): string | null {
if (typeof slug !== 'string') return null
if (!/^[a-z][a-z0-9_]{1,47}$/.test(slug)) return null
return `tenant_${slug}`
}
/** Client service_role no schema public (tabelas globais + RPCs). */
export function adminClient(): SupabaseClient {
return createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
}
/** tenant_id -> nome do schema (via tenants.slug). null se não existir. */
export async function schemaForTenant(admin: SupabaseClient, tenantId: string): Promise<string | null> {
if (!tenantId) return null
const { data, error } = await admin.from('tenants').select('slug').eq('id', tenantId).maybeSingle()
if (error) {
console.error('[tenant] schemaForTenant erro:', error.message)
return null
}
return tenantSchemaName(data?.slug ?? null)
}
/**
* tenant_id -> client ligado ao schema do tenant (para tabelas tenant).
* Lança se o tenant não existir/sem slug — chamar tabela tenant sem schema é bug.
*/
export async function tenantDbForId(admin: SupabaseClient, tenantId: string): Promise<SupabaseClient> {
const schema = await schemaForTenant(admin, tenantId)
if (!schema) throw new Error(`[tenant] schema indisponível para tenant ${tenantId}`)
return admin.schema(schema)
}
export type TenantRef = { tenantId: string; slug: string; schema: string }
/** Lista tenants ativos com schema provisionado — base dos crons que varrem todos. */
export async function listTenantSchemas(admin: SupabaseClient): Promise<TenantRef[]> {
// tenant_schemas é populada por clone_tenant_template (F1/F2); join garante slug atual
const { data, error } = await admin
.from('tenant_schemas')
.select('tenant_id, schema_name, tenants!inner(slug)')
if (error) {
console.error('[tenant] listTenantSchemas erro:', error.message)
return []
}
return (data ?? [])
.map((r: Record<string, unknown>) => {
const slug = (r.tenants as { slug?: string } | null)?.slug ?? null
const schema = tenantSchemaName(slug)
return schema ? { tenantId: r.tenant_id as string, slug: slug as string, schema } : null
})
.filter((x): x is TenantRef => x !== null)
}
/**
* Roteia um webhook inbound -> tenant, via public.channel_routing.
* Usado quando a function NÃO recebe tenant_id na URL (ex.: Meta Cloud API,
* que identifica o canal por phone_number_id). Os webhooks Twilio/Evolution
* deste projeto recebem tenant_id na própria URL e NÃO precisam disto.
*/
export async function resolveTenantByChannel(
admin: SupabaseClient,
keys: { senderAddress?: string | null; twilioPhone?: string | null; twilioSid?: string | null },
): Promise<(TenantRef & { channelId: string }) | null> {
let q = admin.from('channel_routing').select('channel_id, tenant_id, tenants!inner(slug)').limit(1)
if (keys.twilioSid) q = q.eq('twilio_subaccount_sid', keys.twilioSid)
else if (keys.twilioPhone) q = q.eq('twilio_phone_number', keys.twilioPhone)
else if (keys.senderAddress) q = q.eq('sender_address', keys.senderAddress)
else return null
const { data, error } = await q.maybeSingle()
if (error || !data) {
if (error) console.error('[tenant] resolveTenantByChannel erro:', error.message)
return null
}
const slug = (data.tenants as { slug?: string } | null)?.slug ?? null
const schema = tenantSchemaName(slug)
if (!schema) return null
return { tenantId: data.tenant_id as string, slug: slug as string, schema, channelId: data.channel_id as string }
}