This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
+151 -58
View File
@@ -2,64 +2,105 @@
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
function normalizeKey(k) {
function normalizeKey (k) {
return String(k || '').trim()
}
function uniqKeys (rows, field) {
const list = []
const seen = new Set()
for (const r of (rows || [])) {
const key = normalizeKey(r?.[field])
if (!key) continue
if (seen.has(key)) continue
seen.add(key)
list.push(key)
}
return list
}
export const useEntitlementsStore = defineStore('entitlements', {
state: () => ({
loading: false,
// =========================
// Tenant entitlements (B)
// =========================
tenantLoading: false,
loadedForTenant: null,
features: [], // array reativo de feature_key liberadas
raw: [],
error: null,
loadedAt: null
tenantFeatures: [],
tenantRaw: [],
tenantError: null,
tenantLoadedAt: null,
// =========================
// User entitlements (A)
// =========================
userLoading: false,
loadedForUser: null,
userFeatures: [],
userRaw: [],
userError: null,
userLoadedAt: null
}),
getters: {
can: (state) => (featureKey) => state.features.includes(featureKey),
has: (state) => (featureKey) => state.features.includes(featureKey)
/**
* ✅ Sem scope: união de tenant + user entitlements.
* Um terapeuta com plano pessoal (therapist_pro) tem features em userFeatures,
* não em tenantFeatures — ambos devem ser verificados.
*/
has: (state) => (featureKey, scope = null) => {
const key = normalizeKey(featureKey)
if (!key) return false
if (scope === 'tenant') return state.tenantFeatures.includes(key)
if (scope === 'user') return state.userFeatures.includes(key)
// sem scope: true se qualquer uma das origens tiver a feature
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key)
},
can: (state) => (featureKey, scope = null) => {
const key = normalizeKey(featureKey)
if (!key) return false
if (scope === 'tenant') return state.tenantFeatures.includes(key)
if (scope === 'user') return state.userFeatures.includes(key)
return state.tenantFeatures.includes(key) || state.userFeatures.includes(key)
}
},
actions: {
async fetch(tenantId, opts = {}) {
// =========================
// Compat: fetch() continua existindo
// =========================
async fetch (tenantId, opts = {}) {
return this.loadForTenant(tenantId, opts)
},
clear() {
clear () {
return this.invalidate()
},
/**
* Carrega entitlements do tenant.
* Importante: quando o plano muda, tenantId é o mesmo,
* então você DEVE chamar com { force: true }.
*
* opts:
* - force: ignora cache do tenant
* - maxAgeMs: se definido, recarrega quando loadedAt estiver velho
*/
async loadForTenant(tenantId, { force = false, maxAgeMs = 0 } = {}) {
// =========================
// Tenant (clinic) — view v_tenant_entitlements
// =========================
async loadForTenant (tenantId, { force = false, maxAgeMs = 0 } = {}) {
if (!tenantId) {
this.invalidate()
this.invalidateTenant()
return
}
const sameTenant = this.loadedForTenant === tenantId
const hasLoadedAt = typeof this.loadedAt === 'number'
const isFresh =
sameTenant &&
hasLoadedAt &&
maxAgeMs > 0 &&
Date.now() - this.loadedAt < maxAgeMs
const same = this.loadedForTenant === tenantId
const hasLoadedAt = typeof this.tenantLoadedAt === 'number'
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && (Date.now() - this.tenantLoadedAt < maxAgeMs)
if (!force && sameTenant && (maxAgeMs === 0 || isFresh)) return
if (!force && same && (maxAgeMs === 0 || isFresh)) return
this.loading = true
this.error = null
this.tenantLoading = true
this.tenantError = null
try {
// ✅ Modelo B: entitlements por tenant (view)
const { data, error } = await supabase
.from('v_tenant_entitlements')
.select('feature_key')
@@ -68,40 +109,92 @@ export const useEntitlementsStore = defineStore('entitlements', {
if (error) throw error
const rows = data ?? []
this.tenantRaw = rows
this.tenantFeatures = uniqKeys(rows, 'feature_key')
// normaliza, remove vazios e duplicados
const list = []
const seen = new Set()
for (const r of rows) {
const key = normalizeKey(r?.feature_key)
if (!key) continue
if (seen.has(key)) continue
seen.add(key)
list.push(key)
}
this.raw = rows
this.features = list
this.loadedForTenant = tenantId
this.loadedAt = Date.now()
this.tenantLoadedAt = Date.now()
} catch (e) {
this.error = e
this.raw = []
this.features = []
this.tenantError = e
this.tenantRaw = []
this.tenantFeatures = []
this.loadedForTenant = tenantId
this.loadedAt = Date.now()
this.tenantLoadedAt = Date.now()
} finally {
this.loading = false
this.tenantLoading = false
}
},
invalidate() {
// =========================
// User (therapist personal) — view v_user_entitlements
// =========================
async loadForUser (userId, { force = false, maxAgeMs = 0 } = {}) {
if (!userId) {
this.invalidateUser()
return
}
const same = this.loadedForUser === userId
const hasLoadedAt = typeof this.userLoadedAt === 'number'
const isFresh = same && hasLoadedAt && maxAgeMs > 0 && (Date.now() - this.userLoadedAt < maxAgeMs)
if (!force && same && (maxAgeMs === 0 || isFresh)) return
this.userLoading = true
this.userError = null
try {
const { data, error } = await supabase
.from('v_user_entitlements')
.select('feature_key')
.eq('user_id', userId)
if (error) throw error
const rows = data ?? []
this.userRaw = rows
this.userFeatures = uniqKeys(rows, 'feature_key')
this.loadedForUser = userId
this.userLoadedAt = Date.now()
} catch (e) {
this.userError = e
this.userRaw = []
this.userFeatures = []
this.loadedForUser = userId
this.userLoadedAt = Date.now()
} finally {
this.userLoading = false
}
},
// =========================
// Invalidate granular
// =========================
invalidateTenant () {
this.tenantLoading = false
this.loadedForTenant = null
this.features = []
this.raw = []
this.error = null
this.loadedAt = null
this.loading = false
this.tenantFeatures = []
this.tenantRaw = []
this.tenantError = null
this.tenantLoadedAt = null
},
invalidateUser () {
this.userLoading = false
this.loadedForUser = null
this.userFeatures = []
this.userRaw = []
this.userError = null
this.userLoadedAt = null
},
// =========================
// Invalidate geral (compat)
// =========================
invalidate () {
this.invalidateTenant()
this.invalidateUser()
}
}
})
})
+23
View File
@@ -0,0 +1,23 @@
// src/stores/menuStore.js
import { defineStore } from 'pinia'
export const useMenuStore = defineStore('menuStore', {
state: () => ({
model: [],
key: null, // assinatura do contexto (uid+tenant+role)
ready: false
}),
actions: {
setMenu (key, model) {
this.key = key || null
this.model = Array.isArray(model) ? model : []
this.ready = true
},
reset () {
this.model = []
this.key = null
this.ready = false
}
}
})
+80 -21
View File
@@ -1,26 +1,60 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
const loading = ref(false)
const lastError = ref(null)
const lastFetchedAt = ref(null)
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
const featuresByTenant = ref({})
// Marca o último tenant buscado (útil pra debug)
const loadedForTenantId = ref(null)
const features = ref({}) // { patients: true/false, ... }
function isEnabled(key) {
return !!features.value?.[key]
function getTenantMap (tenantId) {
if (!tenantId) return {}
return featuresByTenant.value?.[tenantId] || {}
}
function invalidate() {
loadedForTenantId.value = null
features.value = {}
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
// Só retorna false quando explicitamente desabilitada no banco.
function isEnabled (key, tenantId = null) {
const tid = tenantId || loadedForTenantId.value
if (!tid) return false
const map = getTenantMap(tid)
if (!(key in map)) return true // não configurada = habilitada por padrão
return !!map[key]
}
async function fetchForTenant(tenantId, { force = false } = {}) {
function invalidate (tenantId = null) {
lastError.value = null
if (!tenantId) {
loadedForTenantId.value = null
featuresByTenant.value = {}
return
}
// invalida apenas um tenant
const copy = { ...featuresByTenant.value }
delete copy[tenantId]
featuresByTenant.value = copy
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null
}
async function fetchForTenant (tenantId, { force = false } = {}) {
if (!tenantId) return
if (!force && loadedForTenantId.value === tenantId) return
// se já tem cache e não é force, não busca de novo
if (!force && featuresByTenant.value?.[tenantId]) {
loadedForTenantId.value = tenantId
return
}
loading.value = true
lastError.value = null
try {
const { data, error } = await supabase
.from('tenant_features')
@@ -32,35 +66,60 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
const map = {}
for (const row of data || []) map[row.feature_key] = !!row.enabled
features.value = map
featuresByTenant.value = {
...featuresByTenant.value,
[tenantId]: map
}
loadedForTenantId.value = tenantId
lastFetchedAt.value = new Date().toISOString()
} catch (e) {
lastError.value = e
// importante: se falhar, mantém cache anterior (se existir)
// e relança para a página poder mostrar toast se quiser
throw e
} finally {
loading.value = false
}
}
async function setForTenant(tenantId, key, enabled) {
async function setForTenant (tenantId, key, enabled) {
if (!tenantId) throw new Error('tenantId missing')
lastError.value = null
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled }
const { error } = await supabase
.from('tenant_features')
.upsert(
{ tenant_id: tenantId, feature_key: key, enabled: !!enabled },
{ onConflict: 'tenant_id,feature_key' }
)
.upsert(payload, { onConflict: 'tenant_id,feature_key' })
if (error) throw error
// atualiza cache local
if (loadedForTenantId.value === tenantId) {
features.value = { ...features.value, [key]: !!enabled }
if (error) {
lastError.value = error
throw error
}
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
const current = getTenantMap(tenantId)
featuresByTenant.value = {
...featuresByTenant.value,
[tenantId]: { ...current, [key]: !!enabled }
}
loadedForTenantId.value = tenantId
}
// (opcional) útil pra debug rápido na tela
const currentFeatures = computed(() => getTenantMap(loadedForTenantId.value))
return {
loading,
features,
lastError,
lastFetchedAt,
loadedForTenantId,
featuresByTenant,
currentFeatures,
isEnabled,
invalidate,
fetchForTenant,
+82 -51
View File
@@ -2,70 +2,119 @@
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
// ✅ normaliza roles vindas do backend (tenant_members / RPC my_tenants)
// - seu projeto quer usar clinic_admin como nome canônico
function normalizeTenantRole (role) {
/**
* Normaliza o role de tenant levando em conta o kind do tenant.
*
* Regras:
* tenant_admin / admin + kind = 'therapist' → 'therapist'
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (padrão legado)
* qualquer outro role → pass-through
*/
function normalizeTenantRole (role, kind) {
const r = String(role || '').trim()
if (!r) return null
// ✅ legado: alguns bancos / RPCs retornam tenant_admin
if (r === 'tenant_admin') return 'clinic_admin'
const isAdmin = (r === 'tenant_admin' || r === 'admin')
// (opcional) se em algum lugar vier 'admin' (profiles), também normaliza:
if (r === 'admin') return 'clinic_admin'
if (isAdmin) {
const k = String(kind || '').trim()
if (k === 'therapist' || k === 'saas') return 'therapist'
if (k === 'supervisor') return 'supervisor'
return 'clinic_admin'
}
return r
}
function readSavedTenant () {
const id = localStorage.getItem('tenant_id')
if (!id) return null
try {
const raw = localStorage.getItem('tenant')
const obj = raw ? JSON.parse(raw) : null
return { id, role: obj?.role ?? null }
} catch {
return { id, role: null }
}
}
function persistTenant (tenantId, role) {
if (!tenantId) return clearPersistedTenant()
localStorage.setItem('tenant_id', tenantId)
localStorage.setItem('tenant', JSON.stringify({ id: tenantId, role }))
}
function clearPersistedTenant () {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
}
export const useTenantStore = defineStore('tenant', {
state: () => ({
loading: false,
loaded: false,
user: null, // auth user
memberships: [], // [{ tenant_id, role, status }]
user: null,
memberships: [],
activeTenantId: null,
activeRole: null,
needsTenantLink: false,
error: null
}),
getters: {
tenantId: (s) => s.activeTenantId,
currentTenantId: (s) => s.activeTenantId,
role: (s) => s.activeRole,
tenant: (s) => (s.activeTenantId ? { id: s.activeTenantId, role: s.activeRole } : null),
hasActiveTenant: (s) => !!s.activeTenantId
},
actions: {
async ensureLoaded () {
if (this.loaded) return
if (this.loading) {
await new Promise((resolve) => {
const t = setInterval(() => {
if (!this.loading) { clearInterval(t); resolve() }
}, 50)
})
return
}
await this.loadSessionAndTenant()
},
async loadSessionAndTenant () {
if (this.loading) return
this.loading = true
this.error = null
try {
// 1) auth user (estável)
const { data, error } = await supabase.auth.getSession()
if (error) throw error
this.user = data?.session?.user ?? null
// sem sessão -> limpa estado e storage
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
this.loaded = true
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
return
}
// 2) memberships via RPC
const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
if (mErr) throw mErr
this.memberships = Array.isArray(mem) ? mem : []
// 3) tenta restaurar tenant salvo
const savedTenantId = localStorage.getItem('tenant_id')
// ✅ FIX: só restaura o tenant salvo se pertence ao usuário atual.
// Sem isso, usuário B herdava o tenant_id do usuário A (mesma máquina),
// carregava com role errado e o menu ficava incorreto.
const saved = readSavedTenant()
const savedTenantId = saved?.id || null
let activeMembership = null
@@ -73,6 +122,10 @@ export const useTenantStore = defineStore('tenant', {
activeMembership = this.memberships.find(
x => x.tenant_id === savedTenantId && x.status === 'active'
)
if (!activeMembership) {
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.')
clearPersistedTenant()
}
}
// fallback: primeiro active
@@ -81,37 +134,26 @@ export const useTenantStore = defineStore('tenant', {
}
this.activeTenantId = activeMembership?.tenant_id ?? null
this.activeRole = normalizeTenantRole(activeMembership?.role, activeMembership?.kind)
// ✅ normaliza role aqui (tenant_admin -> clinic_admin)
this.activeRole = normalizeTenantRole(activeMembership?.role)
// persiste tenant se existir
if (this.activeTenantId) {
localStorage.setItem('tenant_id', this.activeTenantId)
localStorage.setItem('tenant', JSON.stringify({
id: this.activeTenantId,
role: this.activeRole
}))
persistTenant(this.activeTenantId, this.activeRole)
} else {
clearPersistedTenant()
}
// se logou mas não tem vínculo ativo
this.needsTenantLink = !this.activeTenantId
this.loaded = true
} catch (e) {
console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
this.error = e
// ⚠️ NÃO zera tudo agressivamente por erro transitório.
// Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
if (!this.user) {
this.memberships = []
this.activeTenantId = null
this.activeRole = null
this.needsTenantLink = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
this.loaded = true
@@ -126,25 +168,16 @@ export const useTenantStore = defineStore('tenant', {
)
this.activeTenantId = found?.tenant_id ?? null
// ✅ normaliza role também ao trocar tenant
this.activeRole = normalizeTenantRole(found?.role)
this.activeRole = normalizeTenantRole(found?.role, found?.kind)
this.needsTenantLink = !this.activeTenantId
if (this.activeTenantId) {
localStorage.setItem('tenant_id', this.activeTenantId)
localStorage.setItem('tenant', JSON.stringify({
id: this.activeTenantId,
role: this.activeRole
}))
persistTenant(this.activeTenantId, this.activeRole)
} else {
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
},
// opcional mas recomendado
reset () {
this.user = null
this.memberships = []
@@ -153,9 +186,7 @@ export const useTenantStore = defineStore('tenant', {
this.needsTenantLink = false
this.error = null
this.loaded = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant')
clearPersistedTenant()
}
}
})