Siga os passos abaixo para criar a tabela profiles no Supabase.
Depois disso, você verá todos os usuários cadastrados direto no Painel Admin com controle total de planos.
Clique no botão abaixo para abrir o SQL Editor diretamente no seu projeto ObraAnalytics:
Abrir SQL Editor no Supabase-- ============================================================
-- ObraAnalytics — SETUP COMPLETO (execute uma única vez)
-- ============================================================
-- 1. TABELA PROFILES (cria se não existir)
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
nome TEXT,
email TEXT UNIQUE,
telefone TEXT DEFAULT '',
plano TEXT DEFAULT 'trial',
trial_expires TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '24 hours'),
avatar_url TEXT DEFAULT '',
empresa TEXT DEFAULT '',
observacoes TEXT DEFAULT '',
criado_em TIMESTAMPTZ DEFAULT NOW(),
atualizado_em TIMESTAMPTZ DEFAULT NOW()
);
-- Adiciona colunas que podem estar faltando (seguro rodar várias vezes)
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS telefone TEXT DEFAULT '';
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS empresa TEXT DEFAULT '';
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS observacoes TEXT DEFAULT '';
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS avatar_url TEXT DEFAULT '';
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS atualizado_em TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS criado_em TIMESTAMPTZ DEFAULT NOW();
-- ✅ NOVA COLUNA: data de expiração do plano pago
-- NULL = plano ativo sem expiração manual
-- Preencha esta coluna quando o usuário cancelar ou não renovar
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS plan_expires TIMESTAMPTZ DEFAULT NULL;
-- Atualiza constraint do plano (remove a antiga se existir e recria)
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_plano_check;
ALTER TABLE public.profiles ADD CONSTRAINT profiles_plano_check
CHECK (plano IN ('trial','basico','pro','mensal','anual','enterprise','equipe','inativo'));
-- 2. RLS (segurança por linha)
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Remove policies antigas se existirem (evita erro de duplicata)
DROP POLICY IF EXISTS "Usuário lê próprio perfil" ON public.profiles;
DROP POLICY IF EXISTS "Usuário edita próprio perfil" ON public.profiles;
DROP POLICY IF EXISTS "Service role acesso total" ON public.profiles;
CREATE POLICY "Usuário lê próprio perfil"
ON public.profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Usuário edita próprio perfil"
ON public.profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "Service role acesso total"
ON public.profiles FOR ALL USING (auth.role() = 'service_role');
-- 3. TRIGGER: cria perfil automaticamente no cadastro
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (id, nome, email, telefone, plano, trial_expires)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'nome', split_part(NEW.email, '@', 1)),
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'telefone', ''),
'trial',
NOW() + INTERVAL '24 hours'
) ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END; $$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- 4. TRIGGER: atualiza timestamp
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.atualizado_em = NOW(); RETURN NEW; END; $$;
DROP TRIGGER IF EXISTS set_profiles_updated_at ON public.profiles;
CREATE TRIGGER set_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- 5. ÍNDICES
CREATE INDEX IF NOT EXISTS idx_profiles_email ON public.profiles(email);
CREATE INDEX IF NOT EXISTS idx_profiles_plano ON public.profiles(plano);
CREATE INDEX IF NOT EXISTS idx_profiles_criado ON public.profiles(criado_em DESC);
-- 6. FUNÇÃO ADMIN: listar usuários (bypassa RLS)
-- DROP obrigatório: não é possível alterar tipo de retorno com CREATE OR REPLACE
-- O Postgres registra INT como "integer" internamente
DROP FUNCTION IF EXISTS public.admin_listar_usuarios(integer,integer,text,text);
DROP FUNCTION IF EXISTS public.admin_listar_usuarios(int4,int4,text,text);
DROP FUNCTION IF EXISTS public.admin_listar_usuarios(int,int,text,text);
CREATE OR REPLACE FUNCTION public.admin_listar_usuarios(
p_page INT DEFAULT 1,
p_limit INT DEFAULT 50,
p_search TEXT DEFAULT '',
p_plano TEXT DEFAULT ''
)
RETURNS TABLE (
id UUID, nome TEXT, email TEXT, telefone TEXT, plano TEXT,
trial_expires TIMESTAMPTZ, plan_expires TIMESTAMPTZ, empresa TEXT,
observacoes TEXT, criado_em TIMESTAMPTZ, atualizado_em TIMESTAMPTZ, total_count BIGINT
)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
RETURN QUERY
SELECT p.id, p.nome, p.email, p.telefone, p.plano,
p.trial_expires, p.plan_expires, p.empresa,
p.observacoes, p.criado_em, p.atualizado_em,
COUNT(*) OVER() AS total_count
FROM public.profiles p
WHERE
(p_search = '' OR p.email ILIKE '%'||p_search||'%'
OR p.nome ILIKE '%'||p_search||'%' OR p.empresa ILIKE '%'||p_search||'%')
AND (p_plano = '' OR p.plano = p_plano)
ORDER BY p.criado_em DESC
LIMIT p_limit OFFSET (p_page - 1) * p_limit;
END; $$;
GRANT EXECUTE ON FUNCTION public.admin_listar_usuarios TO anon, authenticated;
-- 7. FUNÇÃO ADMIN: atualizar plano (com suporte a plan_expires)
DROP FUNCTION IF EXISTS public.admin_atualizar_plano(uuid,text,text,timestamptz);
DROP FUNCTION IF EXISTS public.admin_atualizar_plano(uuid,text,text);
CREATE OR REPLACE FUNCTION public.admin_atualizar_plano(
p_user_id UUID,
p_plano TEXT,
p_obs TEXT DEFAULT '',
p_plan_expires TIMESTAMPTZ DEFAULT NULL
)
RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
UPDATE public.profiles
SET plano = p_plano,
plan_expires = p_plan_expires,
observacoes = CASE WHEN p_obs = '' THEN observacoes ELSE p_obs END,
atualizado_em = NOW()
WHERE id = p_user_id;
RETURN FOUND;
END; $$;
GRANT EXECUTE ON FUNCTION public.admin_atualizar_plano TO anon, authenticated;
-- 7b. FUNÇÃO: bloquear usuário (define plano=inativo + plan_expires=NOW)
DROP FUNCTION IF EXISTS public.admin_bloquear_usuario(uuid,text);
DROP FUNCTION IF EXISTS public.admin_bloquear_usuario(uuid);
CREATE OR REPLACE FUNCTION public.admin_bloquear_usuario(p_user_id UUID, p_motivo TEXT DEFAULT '')
RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
UPDATE public.profiles
SET plano = 'inativo',
plan_expires = NOW(),
observacoes = CASE WHEN p_motivo = '' THEN observacoes
ELSE 'BLOQUEADO: ' || p_motivo || ' | ' || observacoes END,
atualizado_em = NOW()
WHERE id = p_user_id;
RETURN FOUND;
END; $$;
GRANT EXECUTE ON FUNCTION public.admin_bloquear_usuario TO anon, authenticated;
-- 8. FUNÇÃO ADMIN: estatísticas gerais
DROP FUNCTION IF EXISTS public.admin_stats();
CREATE OR REPLACE FUNCTION public.admin_stats()
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE result JSON;
BEGIN
SELECT json_build_object(
'total_usuarios', (SELECT COUNT(*) FROM public.profiles),
'trial', (SELECT COUNT(*) FROM public.profiles WHERE plano='trial'),
'mensal', (SELECT COUNT(*) FROM public.profiles WHERE plano='mensal'),
'anual', (SELECT COUNT(*) FROM public.profiles WHERE plano='anual'),
'enterprise', (SELECT COUNT(*) FROM public.profiles WHERE plano='enterprise'),
'inativo', (SELECT COUNT(*) FROM public.profiles WHERE plano='inativo'),
'trial_expirado', (SELECT COUNT(*) FROM public.profiles WHERE plano='trial' AND trial_expires < NOW()),
'novos_7dias', (SELECT COUNT(*) FROM public.profiles WHERE criado_em >= NOW()-INTERVAL '7 days'),
'novos_30dias', (SELECT COUNT(*) FROM public.profiles WHERE criado_em >= NOW()-INTERVAL '30 days')
) INTO result;
RETURN result;
END; $$;
GRANT EXECUTE ON FUNCTION public.admin_stats TO anon, authenticated;
-- 9. PERMISSÕES
GRANT SELECT, INSERT, UPDATE ON public.profiles TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.profiles TO service_role;
-- 10. MIGRAR USUÁRIOS EXISTENTES (cria perfil para quem já se cadastrou)
INSERT INTO public.profiles (id, nome, email, plano, trial_expires, criado_em)
SELECT u.id,
COALESCE(u.raw_user_meta_data->>'nome', split_part(u.email,'@',1)),
u.email, 'trial',
COALESCE(u.created_at + INTERVAL '24 hours', NOW() + INTERVAL '24 hours'),
COALESCE(u.created_at, NOW())
FROM auth.users u
WHERE NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = u.id);
-- VERIFICAÇÃO FINAL
SELECT 'profiles criados: ' || COUNT(*)::TEXT AS resultado FROM public.profiles;
Após colar o SQL, clique em Run ▶ no canto superior direito do SQL Editor.
Volte ao Painel Admin e clique na aba 👥 Usuários. Você deverá ver: