diff --git a/database-novo/migrations/20260520000005_accept_tenant_invite_rpc.sql b/database-novo/migrations/20260520000005_accept_tenant_invite_rpc.sql new file mode 100644 index 0000000..cabc038 --- /dev/null +++ b/database-novo/migrations/20260520000005_accept_tenant_invite_rpc.sql @@ -0,0 +1,95 @@ +-- ============================================================================ +-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite +-- ---------------------------------------------------------------------------- +-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER): +-- 1. Lê invite ATIVO (não accepted, não revoked, não expired) +-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid() +-- 3. UPDATE invite com accepted_at + accepted_by +-- +-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR. +-- +-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite(). +-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona. +-- ============================================================================ + +BEGIN; + +CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid; + v_invite record; + v_existing_member record; +BEGIN + -- Quem está aceitando — auth.uid() pega do JWT + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Sessão inválida (sem user autenticado).'; + END IF; + + -- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race. + SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at + INTO v_invite + FROM public.tenant_invites + WHERE token = p_token + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Convite não encontrado. Verifique o link.'; + END IF; + + IF v_invite.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'Convite revogado pelo administrador.'; + END IF; + + IF v_invite.accepted_at IS NOT NULL THEN + RAISE EXCEPTION 'Convite já foi aceito anteriormente.'; + END IF; + + IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN + RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.'; + END IF; + + -- 2. Idempotência: se já é membro do tenant, só marca invite aceito. + SELECT id, role, status + INTO v_existing_member + FROM public.tenant_members + WHERE tenant_id = v_invite.tenant_id + AND user_id = v_uid + LIMIT 1; + + IF v_existing_member.id IS NULL THEN + INSERT INTO public.tenant_members (tenant_id, user_id, role, status) + VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active'); + ELSIF v_existing_member.status <> 'active' THEN + UPDATE public.tenant_members + SET status = 'active', role = v_invite.role + WHERE id = v_existing_member.id; + END IF; + -- (se já está ativo, deixa como tá — convite aceito não rebaixa) + + -- 3. Marca invite como aceito + UPDATE public.tenant_invites + SET accepted_at = now(), accepted_by = v_uid + WHERE id = v_invite.id; + + RETURN jsonb_build_object( + 'ok', true, + 'tenant_id', v_invite.tenant_id, + 'role', v_invite.role + ); +END; +$$; + +COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS + 'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.'; + +-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar). +REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated; + +COMMIT; diff --git a/src/features/tenantship/composables/useTenantInvites.js b/src/features/tenantship/composables/useTenantInvites.js new file mode 100644 index 0000000..fd711a9 --- /dev/null +++ b/src/features/tenantship/composables/useTenantInvites.js @@ -0,0 +1,101 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/composables/useTenantInvites.js +| +| Thin wrapper sobre tenantInvitesRepository. Segue +| blueprints/composable-blueprint.md (Tipo A — thin wrapper default). +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { listForTenant, getByToken, sendInvite, revokeInvite, acceptInvite } from '@/features/tenantship/services/tenantInvitesRepository'; + +export function useTenantInvites() { + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + + async function loadForTenant({ tenantId, includeInactive } = {}) { + loading.value = true; + error.value = ''; + try { + rows.value = await listForTenant({ tenantId, includeInactive }); + } catch (e) { + error.value = e?.message || 'Falha ao carregar convites.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function send(payload) { + loading.value = true; + error.value = ''; + try { + const created = await sendInvite(payload); + // Inserir/replace na lista local sem re-fetch + const idx = rows.value.findIndex((r) => r.id === created.id); + if (idx >= 0) rows.value[idx] = created; + else rows.value = [created, ...rows.value]; + return created; + } catch (e) { + error.value = e?.message || 'Falha ao enviar convite.'; + throw e; + } finally { + loading.value = false; + } + } + + async function revoke(inviteId, opts) { + loading.value = true; + error.value = ''; + try { + const updated = await revokeInvite(inviteId, opts); + const idx = rows.value.findIndex((r) => r.id === updated.id); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao revogar convite.'; + throw e; + } finally { + loading.value = false; + } + } + + /** + * STUB — depende de RPC ainda não criada. Joga erro PT-BR explicando. + * Ver tenantInvitesRepository.acceptInvite. + */ + async function accept(token) { + loading.value = true; + error.value = ''; + try { + return await acceptInvite(token); + } catch (e) { + error.value = e?.message || 'Falha ao aceitar convite.'; + throw e; + } finally { + loading.value = false; + } + } + + /** + * Read público pelo token (anonymous). Não atualiza `rows` — + * usado no fluxo de aceitar (link externo). + */ + async function fetchByToken(token) { + loading.value = true; + error.value = ''; + try { + return await getByToken(token); + } catch (e) { + error.value = e?.message || 'Falha ao carregar convite.'; + return null; + } finally { + loading.value = false; + } + } + + return { rows, loading, error, loadForTenant, send, revoke, accept, fetchByToken }; +} diff --git a/src/features/tenantship/composables/useTenantMembers.js b/src/features/tenantship/composables/useTenantMembers.js new file mode 100644 index 0000000..8ad38ad --- /dev/null +++ b/src/features/tenantship/composables/useTenantMembers.js @@ -0,0 +1,93 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/composables/useTenantMembers.js +| +| Thin wrapper sobre tenantMembersRepository. Segue +| blueprints/composable-blueprint.md (Tipo A — thin wrapper default). +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { listForTenant, getById, updateMemberRole, updateMemberStatus, removeMember } from '@/features/tenantship/services/tenantMembersRepository'; + +export function useTenantMembers() { + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + + async function loadForTenant({ tenantId, status } = {}) { + loading.value = true; + error.value = ''; + try { + rows.value = await listForTenant({ tenantId, status }); + } catch (e) { + error.value = e?.message || 'Falha ao carregar membros.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function fetchById(memberId, opts) { + loading.value = true; + error.value = ''; + try { + return await getById(memberId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar membro.'; + return null; + } finally { + loading.value = false; + } + } + + async function updateRole(memberId, role, opts) { + loading.value = true; + error.value = ''; + try { + const updated = await updateMemberRole(memberId, role, opts); + const idx = rows.value.findIndex((r) => r.id === updated.id); + if (idx >= 0) rows.value[idx] = { ...rows.value[idx], role: updated.role }; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao atualizar papel.'; + throw e; + } finally { + loading.value = false; + } + } + + async function updateStatus(memberId, status, opts) { + loading.value = true; + error.value = ''; + try { + const updated = await updateMemberStatus(memberId, status, opts); + const idx = rows.value.findIndex((r) => r.id === updated.id); + if (idx >= 0) rows.value[idx] = { ...rows.value[idx], status: updated.status }; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao atualizar status.'; + throw e; + } finally { + loading.value = false; + } + } + + async function remove(memberId, opts) { + loading.value = true; + error.value = ''; + try { + await removeMember(memberId, opts); + rows.value = rows.value.filter((r) => r.id !== memberId); + return true; + } catch (e) { + error.value = e?.message || 'Falha ao remover membro.'; + throw e; + } finally { + loading.value = false; + } + } + + return { rows, loading, error, loadForTenant, fetchById, updateRole, updateStatus, remove }; +} diff --git a/src/features/tenantship/services/_tenantGuards.js b/src/features/tenantship/services/_tenantGuards.js new file mode 100644 index 0000000..a7c690c --- /dev/null +++ b/src/features/tenantship/services/_tenantGuards.js @@ -0,0 +1,44 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/services/_tenantGuards.js +| +| Guards compartilhados entre repositories do feature tenantship. +| Cópia canônica do pattern extraído de features/agenda/services/_tenantGuards.js +| (ver blueprints/repository-blueprint.md seção 3). +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; + +export function assertTenantId(tenantId) { + if (!tenantId || tenantId === 'null' || tenantId === 'undefined') { + throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.'); + } +} + +export async function getUid() { + const { data, error } = await supabase.auth.getUser(); + if (error) throw error; + const uid = data?.user?.id; + if (!uid) throw new Error('Usuário não autenticado.'); + return uid; +} + +export function assertEmail(email) { + if (!email || typeof email !== 'string') { + throw new Error('E-mail inválido.'); + } + const trimmed = email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { + throw new Error('E-mail em formato inválido.'); + } + return trimmed; +} + +export function assertRole(role) { + if (!['therapist', 'secretary'].includes(role)) { + throw new Error("Role inválida. Aceitos: 'therapist' ou 'secretary'."); + } + return role; +} diff --git a/src/features/tenantship/services/tenantInvitesRepository.js b/src/features/tenantship/services/tenantInvitesRepository.js new file mode 100644 index 0000000..2068733 --- /dev/null +++ b/src/features/tenantship/services/tenantInvitesRepository.js @@ -0,0 +1,148 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/services/tenantInvitesRepository.js +| +| Repository de tenant_invites. Pure functions seguindo +| blueprints/repository-blueprint.md. +| +| A tabela tenant_invites já existe no schema (tenants_multi_tenant.sql:100) +| com role CHECK ['therapist','secretary'], token uuid auto, expires_at default +| now()+7d, accepted_at/by, revoked_at/by. +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid, assertEmail, assertRole } from './_tenantGuards'; +import { TENANT_INVITE_SELECT, flattenInviteRow } from './tenantInvitesSelects'; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lista convites de um tenant. Ordem: pending primeiro (mais recentes), depois resto. + * + * @param {Object} [opts] + * @param {string} [opts.tenantId] + * @param {boolean} [opts.includeInactive=false] - se true, inclui revoked/accepted/expired + */ +export async function listForTenant({ tenantId, includeInactive = false } = {}) { + const tid = resolveTenantId(tenantId); + + let q = supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('tenant_id', tid); + + if (!includeInactive) { + q = q.is('accepted_at', null).is('revoked_at', null).gt('expires_at', new Date().toISOString()); + } + + q = q.order('created_at', { ascending: false }); + + const { data, error } = await q; + if (error) throw error; + return (data || []).map(flattenInviteRow); +} + +/** + * Busca convite por token. Usado no fluxo de aceitar (read público). + * NOTA: política RLS deve permitir SELECT por token sem auth — a ser configurada. + * + * @param {string} token - uuid do convite + */ +export async function getByToken(token) { + if (!token) throw new Error('Token inválido.'); + const { data, error } = await supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('token', token).maybeSingle(); + if (error) throw error; + return data ? flattenInviteRow(data) : null; +} + +/** + * Envia novo convite (cria row). Idempotente por (tenant_id, email) ativo — + * se já existe convite pending pro mesmo email, retorna o existente. + * + * TODO (Módulo 6 — Notificações): após criar a row, disparar email/WhatsApp + * com o link `/aceitar-convite?token=${row.token}`. Hoje só insere. + * + * @param {Object} payload + * @param {string} payload.email + * @param {'therapist'|'secretary'} payload.role + * @param {string} [payload.tenantId] + * @returns {Promise} row do convite (novo ou existente) + */ +export async function sendInvite({ email, role, tenantId } = {}) { + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + const safeEmail = assertEmail(email); + const safeRole = assertRole(role); + + // Idempotência: se já existe pending pro mesmo (tenant, email), retorna existente + const { data: existing } = await supabase + .from('tenant_invites') + .select(TENANT_INVITE_SELECT) + .eq('tenant_id', tid) + .eq('email', safeEmail) + .is('accepted_at', null) + .is('revoked_at', null) + .gt('expires_at', new Date().toISOString()) + .maybeSingle(); + + if (existing) return flattenInviteRow(existing); + + const { data, error } = await supabase + .from('tenant_invites') + .insert([{ tenant_id: tid, email: safeEmail, role: safeRole, invited_by: uid }]) + .select(TENANT_INVITE_SELECT) + .single(); + + if (error) throw error; + return flattenInviteRow(data); +} + +/** + * Revoga convite (soft — registra revoked_at + revoked_by, não deleta a row). + * + * @param {string} inviteId + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function revokeInvite(inviteId, { tenantId } = {}) { + if (!inviteId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + const { data, error } = await supabase + .from('tenant_invites') + .update({ revoked_at: new Date().toISOString(), revoked_by: uid }) + .eq('id', inviteId) + .eq('tenant_id', tid) + .select(TENANT_INVITE_SELECT) + .single(); + + if (error) throw error; + return flattenInviteRow(data); +} + +/** + * Aceita convite — cria tenant_members + marca accepted_at no invite (atomicamente via RPC). + * + * RPC `accept_tenant_invite(p_token uuid)` (migration 20260520000005): + * - SECURITY DEFINER (auth.uid() do caller é o aceitador) + * - Lock FOR UPDATE no invite (anti-race) + * - Idempotente: re-aceitar não cria duplicata + * - Retorna jsonb { ok, tenant_id, role } em sucesso + * - Throw com mensagem PT-BR em erros (revogado, expirado, já aceito, sem sessão) + * + * @param {string} token - uuid do invite + * @returns {Promise<{ok: boolean, tenant_id: string, role: string}>} + */ +export async function acceptInvite(token) { + if (!token) throw new Error('Token inválido.'); + + const { data, error } = await supabase.rpc('accept_tenant_invite', { p_token: token }); + if (error) throw error; + return data; +} diff --git a/src/features/tenantship/services/tenantInvitesSelects.js b/src/features/tenantship/services/tenantInvitesSelects.js new file mode 100644 index 0000000..2021670 --- /dev/null +++ b/src/features/tenantship/services/tenantInvitesSelects.js @@ -0,0 +1,38 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/services/tenantInvitesSelects.js +| +| Fonte única do SELECT de tenant_invites. +|-------------------------------------------------------------------------- +*/ + +export const TENANT_INVITE_SELECT = ` + id, tenant_id, email, role, token, + invited_by, created_at, expires_at, + accepted_at, accepted_by, + revoked_at, revoked_by +`.trim(); + +/** + * Computa status derivado do invite (sem campo no banco — calculado em runtime). + * + * @param {Object} row + * @returns {'pending'|'expired'|'accepted'|'revoked'} + */ +export function deriveInviteStatus(row) { + if (!row) return 'pending'; + if (row.revoked_at) return 'revoked'; + if (row.accepted_at) return 'accepted'; + if (row.expires_at && new Date(row.expires_at) < new Date()) return 'expired'; + return 'pending'; +} + +/** + * Achata a row adicionando o status derivado. + */ +export function flattenInviteRow(r) { + if (!r) return r; + return { ...r, status: deriveInviteStatus(r) }; +} diff --git a/src/features/tenantship/services/tenantMembersRepository.js b/src/features/tenantship/services/tenantMembersRepository.js new file mode 100644 index 0000000..781792d --- /dev/null +++ b/src/features/tenantship/services/tenantMembersRepository.js @@ -0,0 +1,152 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/services/tenantMembersRepository.js +| +| Repository de tenant_members. Pure functions seguindo +| blueprints/repository-blueprint.md. +| +| Tabela: tenant_members (id, tenant_id, user_id, role, status='active', created_at) +| View enriched: v_tenant_members_with_profiles (inclui full_name e email) +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { TENANT_MEMBER_PROFILE_SELECT, TENANT_MEMBER_RAW_SELECT, flattenMemberRow } from './tenantMembersSelects'; + +const VALID_ROLES = ['tenant_admin', 'therapist', 'secretary']; +const VALID_STATUSES = ['active', 'inactive', 'suspended']; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lista membros de um tenant com profile (full_name, email). + * + * @param {Object} [opts] + * @param {string} [opts.tenantId] + * @param {string} [opts.status] - filtra por status (active/inactive/suspended) + */ +export async function listForTenant({ tenantId, status } = {}) { + const tid = resolveTenantId(tenantId); + + let q = supabase.from('v_tenant_members_with_profiles').select(TENANT_MEMBER_PROFILE_SELECT).eq('tenant_id', tid).order('created_at', { ascending: false }); + + if (status) q = q.eq('status', status); + + const { data, error } = await q; + if (error) throw error; + return (data || []).map(flattenMemberRow); +} + +/** + * Lê o tenant_member ativo do usuário LOGADO no tenant ativo (ou no tenantId passado). + * Retorna `{ id, tenant_id, role, status }` ou null. + * + * Útil pra criar entidades que precisam de `responsible_member_id` (ex: patients). + * + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function getMyActiveMember({ tenantId } = {}) { + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('user_id', uid).eq('tenant_id', tid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Lê um member pela id (raw, sem profile join — útil pra verificações rápidas). + * + * @param {string} memberId + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function getById(memberId, { tenantId } = {}) { + if (!memberId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('id', memberId).eq('tenant_id', tid).maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Atualiza role de um member. Tenant_admin não pode rebaixar a si mesmo — + * a UI deve bloquear, mas RLS no banco também deve garantir. + * + * @param {string} memberId + * @param {'tenant_admin'|'therapist'|'secretary'} role + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function updateMemberRole(memberId, role, { tenantId } = {}) { + if (!memberId) throw new Error('ID inválido.'); + if (!VALID_ROLES.includes(role)) { + throw new Error(`Role inválida. Aceitos: ${VALID_ROLES.join(', ')}.`); + } + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('tenant_members').update({ role }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Atualiza status (ativar/inativar/suspender) sem remover. + * + * @param {string} memberId + * @param {'active'|'inactive'|'suspended'} status + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function updateMemberStatus(memberId, status, { tenantId } = {}) { + if (!memberId) throw new Error('ID inválido.'); + if (!VALID_STATUSES.includes(status)) { + throw new Error(`Status inválido. Aceitos: ${VALID_STATUSES.join(', ')}.`); + } + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('tenant_members').update({ status }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Remove member (hard delete da tabela tenant_members). + * Os dados do user em outras tabelas (pacientes, agenda, etc) PERMANECEM — + * a remoção é apenas do vínculo membership. + * + * @param {string} memberId + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function removeMember(memberId, { tenantId } = {}) { + if (!memberId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + + // Defesa: nunca permitir o próprio user se remover via essa função + // (causa lockout). UI deve bloquear; aqui só sanity-check. + const uid = await getUid(); + const target = await getById(memberId, { tenantId: tid }); + if (target && target.user_id === uid) { + throw new Error('Não é permitido remover a si mesmo. Peça a outro admin do tenant.'); + } + + const { error } = await supabase.from('tenant_members').delete().eq('id', memberId).eq('tenant_id', tid); + + if (error) throw error; + return true; +} diff --git a/src/features/tenantship/services/tenantMembersSelects.js b/src/features/tenantship/services/tenantMembersSelects.js new file mode 100644 index 0000000..66d024c --- /dev/null +++ b/src/features/tenantship/services/tenantMembersSelects.js @@ -0,0 +1,37 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/tenantship/services/tenantMembersSelects.js +| +| SELECTs canônicos pra tenant_members + view v_tenant_members_with_profiles. +|-------------------------------------------------------------------------- +*/ + +/** + * SELECT direto da tabela tenant_members (sem join). + * Use quando precisa só dos campos crus. + */ +export const TENANT_MEMBER_RAW_SELECT = ` + id, tenant_id, user_id, role, status, created_at +`.trim(); + +/** + * SELECT enriched via view v_tenant_members_with_profiles. + * Inclui full_name e email (joins com profiles e auth.users). + * Use pra listagens da UI. + */ +export const TENANT_MEMBER_PROFILE_SELECT = ` + tenant_member_id, tenant_id, user_id, role, status, created_at, + full_name, email +`.trim(); + +/** + * Normaliza row da view pra usar `id` no lugar de `tenant_member_id`. + */ +export function flattenMemberRow(r) { + if (!r) return r; + const out = { ...r }; + if (r.tenant_member_id && !r.id) out.id = r.tenant_member_id; + return out; +} diff --git a/src/router/routes.clinic.js b/src/router/routes.clinic.js index e0683eb..0c87c50 100644 --- a/src/router/routes.clinic.js +++ b/src/router/routes.clinic.js @@ -177,6 +177,16 @@ export default { component: () => import('@/views/pages/auth/SecurityPage.vue') }, + // ====================================================== + // 👥 MEMBROS & CONVITES + // ====================================================== + { + path: 'members', + name: 'admin-members', + component: () => import('@/views/pages/admin/MembersPage.vue'), + meta: { roles: ['tenant_admin', 'clinic_admin'] } + }, + // ====================================================== // 🔒 MÓDULO PRO — Online Scheduling // ====================================================== diff --git a/src/services/tenantFeatureAdminService.js b/src/services/tenantFeatureAdminService.js new file mode 100644 index 0000000..32adcb4 --- /dev/null +++ b/src/services/tenantFeatureAdminService.js @@ -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; +} diff --git a/src/views/pages/admin/MembersPage.vue b/src/views/pages/admin/MembersPage.vue new file mode 100644 index 0000000..8dcb02b --- /dev/null +++ b/src/views/pages/admin/MembersPage.vue @@ -0,0 +1,336 @@ + + + + diff --git a/src/views/pages/saas/SaasTenantFeaturesPage.vue b/src/views/pages/saas/SaasTenantFeaturesPage.vue index f6926d1..50c02d2 100644 --- a/src/views/pages/saas/SaasTenantFeaturesPage.vue +++ b/src/views/pages/saas/SaasTenantFeaturesPage.vue @@ -16,7 +16,13 @@ -->