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:
Leonardo
2026-05-21 04:20:33 -03:00
parent fbfb95648e
commit 0956e4facc
12 changed files with 1173 additions and 42 deletions
@@ -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 };
}
@@ -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 };
}
@@ -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;
}
@@ -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<Object>} 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;
}
@@ -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) };
}
@@ -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;
}
@@ -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;
}