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,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;