M5: tenantship + admin members + accept_invite RPC
Modulo 5 da Fase 1 + quick wins fechados. features/tenantship/ com 2 services + 2 composables (members + invites). MembersPage.vue nova em views/pages/admin/ + rota /admin/members em routes.clinic. Migration 20260520000005 cria RPC accept_tenant_invite (SECURITY DEFINER + lock FOR UPDATE) — tenantInvitesRepository.acceptInvite agora chama RPC real (nao mais stub). SaasTenantFeaturesPage refatorada pra usar novo tenantFeatureAdminService. SetupWizardPage 2648 linhas deferido pra sessao dedicada. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/services/tenantFeatureAdminService.js
|
||||
|
|
||||
| Service de admin SaaS pra gestão de features por tenant. Substitui supabase
|
||||
| direto que estava em SaasTenantFeaturesPage.vue (audit alta — 4 queries
|
||||
| inline + 1 RPC).
|
||||
|
|
||||
| Diferente de `tenantFeaturesStore` (que gerencia features do user/tenant ATIVO),
|
||||
| este service opera em QUALQUER tenant selecionado pelo SaaS admin.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// SELECTs canônicos
|
||||
const TENANT_LIST_SELECT = 'id, name';
|
||||
const FEATURE_CATALOG_SELECT = 'id, key, name, descricao';
|
||||
const ENTITLEMENT_SELECT = 'feature_key';
|
||||
const TENANT_FEATURE_SELECT = 'feature_key, enabled';
|
||||
const SUBSCRIPTION_SELECT = 'plan_key';
|
||||
const EXCEPTIONS_LOG_SELECT = 'feature_key, enabled, reason, created_by, created_at';
|
||||
|
||||
/**
|
||||
* Lista todos os tenants (apenas SaaS admin tem acesso via RLS).
|
||||
*/
|
||||
export async function listTenants() {
|
||||
const { data, error } = await supabase.from('tenants').select(TENANT_LIST_SELECT).order('name', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista o catálogo completo de features do sistema.
|
||||
*/
|
||||
export async function listFeatureCatalog() {
|
||||
const { data, error } = await supabase.from('features').select(FEATURE_CATALOG_SELECT).order('key', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega o estado completo de features de um tenant: entitlements via plano,
|
||||
* overrides (exceções), plano ativo, e log das últimas 50 exceções.
|
||||
*
|
||||
* @param {string} tenantId
|
||||
* @returns {Promise<{planAllowed: Set, planKey: string|null, overrides: Object, exceptionsLog: Array}>}
|
||||
*/
|
||||
export async function loadTenantFeatureState(tenantId) {
|
||||
if (!tenantId) {
|
||||
return { planAllowed: new Set(), planKey: null, overrides: {}, exceptionsLog: [] };
|
||||
}
|
||||
|
||||
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
|
||||
supabase.from('v_tenant_entitlements').select(ENTITLEMENT_SELECT).eq('tenant_id', tenantId),
|
||||
supabase.from('tenant_features').select(TENANT_FEATURE_SELECT).eq('tenant_id', tenantId),
|
||||
supabase.from('v_tenant_active_subscription').select(SUBSCRIPTION_SELECT).eq('tenant_id', tenantId).maybeSingle(),
|
||||
supabase.from('tenant_feature_exceptions_log').select(EXCEPTIONS_LOG_SELECT).eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
|
||||
]);
|
||||
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
if (e3) throw e3;
|
||||
if (e4) throw e4;
|
||||
|
||||
const planAllowed = new Set();
|
||||
for (const r of ent || []) planAllowed.add(r.feature_key);
|
||||
|
||||
const overrides = {};
|
||||
for (const r of ovr || []) overrides[r.feature_key] = !!r.enabled;
|
||||
|
||||
return {
|
||||
planAllowed,
|
||||
planKey: sub?.plan_key || null,
|
||||
overrides,
|
||||
exceptionsLog: log || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica exceção comercial (force enable/disable) numa feature de um tenant.
|
||||
* RPC `set_tenant_feature_exception` faz UPSERT em tenant_features +
|
||||
* INSERT em tenant_feature_exceptions_log.
|
||||
*/
|
||||
export async function setFeatureException(tenantId, featureKey, enabled, reason = null) {
|
||||
if (!tenantId) throw new Error('tenantId obrigatório.');
|
||||
if (!featureKey) throw new Error('featureKey obrigatória.');
|
||||
|
||||
const { error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: tenantId,
|
||||
p_feature_key: featureKey,
|
||||
p_enabled: enabled,
|
||||
p_reason: reason ? String(reason).trim() || null : null
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
Reference in New Issue
Block a user