diff --git a/database-novo/migrations/20260613000001_f1b_keep_anon_tables_public.sql b/database-novo/migrations/20260613000001_f1b_keep_anon_tables_public.sql new file mode 100644 index 0000000..70507cf --- /dev/null +++ b/database-novo/migrations/20260613000001_f1b_keep_anon_tables_public.sql @@ -0,0 +1,49 @@ +-- ============================================================================= +-- F1b — Decisão de roteamento anon: 6 tabelas anon-facing FICAM em public +-- +-- Fluxos anônimos identificam o tenant por TOKEN/SLUG (não por login), então +-- não conseguem resolver o schema físico. Decisão (2026-06-13, opção C): +-- manter essas tabelas em public com tenant_id + RLS por token, como hoje. +-- +-- patient_intake_requests — intake de paciente por convite (token) +-- patient_invites — tokens de convite +-- patient_invite_attempts — rate-limit anon dos convites +-- document_share_links — assinatura pública de documento (token) +-- agendador_configuracoes — agendador público (link_slug) +-- agendador_solicitacoes — solicitação criada por visitante anon +-- +-- Logo, REMOVE essas 6 do _tenant_template (não viram schema do tenant). +-- O clone_tenant_template itera as tabelas do template dinamicamente, então +-- novos clones nascem sem elas automaticamente. Classificação final: +-- 78 tenant-scoped + 59 globais (era 84 + 53). +-- +-- Nota F6: public.document_share_links.documento_id tem FK -> documents, que +-- VAI pro schema do tenant. No drop de public.documents (F6), essa FK precisa +-- virar coluna solta (uuid sem constraint) — o RPC valida via token. Idem +-- qualquer FK public->tenant dessas 6 (registrar no lote de FKs da F6). +-- ============================================================================= + +BEGIN; + +DO $$ +DECLARE + anon_tabs text[] := ARRAY[ + 'patient_intake_requests','patient_invites','patient_invite_attempts', + 'document_share_links','agendador_configuracoes','agendador_solicitacoes' + ]; + t text; +BEGIN + FOREACH t IN ARRAY anon_tabs LOOP + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = '_tenant_template' AND table_name = t) THEN + EXECUTE format('DROP TABLE _tenant_template.%I CASCADE', t); + RAISE NOTICE 'F1b: _tenant_template.% removida (fica em public)', t; + END IF; + -- defensivo: tira do registro de realtime do template, se estiver lá + DELETE FROM _tenant_template._realtime_tables WHERE table_name = t; + END LOOP; +END $$; + +UPDATE _tenant_template._meta SET value = '2'::jsonb WHERE key = 'template_version'; + +COMMIT; diff --git a/docs/F0_categorizacao.md b/docs/F0_categorizacao.md index c82eb5a..89d26e5 100644 --- a/docs/F0_categorizacao.md +++ b/docs/F0_categorizacao.md @@ -9,8 +9,10 @@ | Item | Quantidade | |---|---| | Tabelas em `public` (BASE TABLE) | 137 | -| **Tenant-scoped** (vão pra `tenant_`) — decidido Q3 | **84** | -| **Globais** (ficam em `public`) | **53** | +| **Tenant-scoped** (vão pra `tenant_`) — Q3 + Q5 | **78** | +| **Globais** (ficam em `public`) | **59** | + +> **Atualização 2026-06-13 (Q5 — roteamento anon):** 6 tabelas anon-facing voltaram pra `public` (decisão opção C): `patient_intake_requests`, `patient_invites`, `patient_invite_attempts`, `document_share_links`, `agendador_configuracoes`, `agendador_solicitacoes`. Fluxos anônimos identificam o tenant por token/slug e não resolvem o schema físico — mantê-las em public com RLS por token evita um índice global de tokens. Removidas do `_tenant_template` na migration `20260613000001_f1b` (template v2, 78 tabelas). FK a observar na F6: `public.document_share_links.documento_id → documents` (tenant) vira coluna solta no drop. | Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) | | Views que referenciam tabelas-tenant | 6 | | FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) | diff --git a/src/components/ui/PatientCreatePopover.vue b/src/components/ui/PatientCreatePopover.vue index 24eb112..7e1e2d1 100644 --- a/src/components/ui/PatientCreatePopover.vue +++ b/src/components/ui/PatientCreatePopover.vue @@ -32,7 +32,6 @@ import Popover from 'primevue/popover'; import { useToast } from 'primevue/usetoast'; import { supabase } from '@/lib/supabase/client'; -import { tenantDb } from '@/lib/supabase/tenantClient'; const emit = defineEmits(['quick-create', 'go-complete', 'show', 'hide']); const toast = useToast(); @@ -53,7 +52,7 @@ async function loadToken() { const { data: authData } = await supabase.auth.getUser(); const uid = authData?.user?.id; if (!uid) return; - const { data } = await tenantDb().from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1); + const { data } = await supabase.from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1); if (data?.[0]?.token) { inviteToken.value = data[0].token; tokenLoaded = true; diff --git a/src/composables/useMenuBadges.js b/src/composables/useMenuBadges.js index 6782bb1..a98fcce 100644 --- a/src/composables/useMenuBadges.js +++ b/src/composables/useMenuBadges.js @@ -59,14 +59,15 @@ async function _refresh() { // 2. Cadastros recebidos (status = 'new') — RLS filtra pelo owner { - const { count } = await tenantDb().from('patient_intake_requests').select('id', { count: 'exact', head: true }).eq('status', 'new'); + const { count } = await supabase.from('patient_intake_requests').select('id', { count: 'exact', head: true }).eq('status', 'new'); cadastrosRecebidos.value = count || 0; } // 3. Agendamentos recebidos (status = 'pendente') { - let q = tenantDb().from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'pendente'); - if (!(isClinic && tenantId)) q = q.eq('owner_id', ownerId); + let q = supabase.from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'pendente'); + if (isClinic && tenantId) q = q.eq('tenant_id', tenantId); + else q = q.eq('owner_id', ownerId); const { count } = await q; agendamentosRecebidos.value = count || 0; } diff --git a/src/features/agenda/composables/useAgendaEventLifecycle.js b/src/features/agenda/composables/useAgendaEventLifecycle.js index 708c794..f9b7946 100644 --- a/src/features/agenda/composables/useAgendaEventLifecycle.js +++ b/src/features/agenda/composables/useAgendaEventLifecycle.js @@ -595,7 +595,7 @@ export function useAgendaEventLifecycle({ const d = new Date(composer.form.value.dia); const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - const { data } = await tenantDb().from('agendador_solicitacoes') + const { data } = await supabase.from('agendador_solicitacoes') .select('id, paciente_nome, paciente_sobrenome, paciente_email') .eq('owner_id', props.ownerId) .eq('status', 'pendente') diff --git a/src/features/agenda/pages/AgendaTerapeutaPage.vue b/src/features/agenda/pages/AgendaTerapeutaPage.vue index eaac808..0a6e557 100644 --- a/src/features/agenda/pages/AgendaTerapeutaPage.vue +++ b/src/features/agenda/pages/AgendaTerapeutaPage.vue @@ -981,7 +981,7 @@ async function loadAgendadorSlug() { const uid = ownerId.value; if (!uid) return; try { - const { data } = await tenantDb().from('agendador_configuracoes').select('link_slug').eq('owner_id', uid).eq('ativo', true).maybeSingle(); + const { data } = await supabase.from('agendador_configuracoes').select('link_slug').eq('owner_id', uid).eq('ativo', true).maybeSingle(); agendadorSlug.value = data?.link_slug || ''; } catch { agendadorSlug.value = ''; @@ -993,7 +993,7 @@ async function loadCadastroToken() { const { data: authData } = await supabase.auth.getUser(); const uid = authData?.user?.id; if (!uid) return; - const { data } = await tenantDb().from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1); + const { data } = await supabase.from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1); cadastroToken.value = data?.[0]?.token || ''; } catch { cadastroToken.value = ''; diff --git a/src/features/agenda/pages/AgendamentosRecebidosPage.vue b/src/features/agenda/pages/AgendamentosRecebidosPage.vue index 98c4ba8..0612dc7 100644 --- a/src/features/agenda/pages/AgendamentosRecebidosPage.vue +++ b/src/features/agenda/pages/AgendamentosRecebidosPage.vue @@ -64,12 +64,14 @@ async function load() { if (!ownerId.value) return; loading.value = true; try { - let q = tenantDb().from('agendador_solicitacoes') - .select('id, owner_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf, tipo, modalidade, data_solicitada, hora_solicitada, reservado_ate, motivo, como_conheceu, status, created_at') + let q = supabase + .from('agendador_solicitacoes') + .select('id, owner_id, tenant_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf, tipo, modalidade, data_solicitada, hora_solicitada, reservado_ate, motivo, como_conheceu, status, created_at') .order('data_solicitada', { ascending: false }) .order('hora_solicitada', { ascending: true }); - if (!isClinic.value) q = q.eq('owner_id', ownerId.value); + if (isClinic.value) q = q.eq('tenant_id', tenantId.value); + else q = q.eq('owner_id', ownerId.value); if (filtroStatus.value) q = q.eq('status', filtroStatus.value); @@ -78,8 +80,9 @@ async function load() { solicitacoes.value = data || []; if (filtroStatus.value !== 'pendente') { - let qp = tenantDb().from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'pendente'); - if (!isClinic.value) qp = qp.eq('owner_id', ownerId.value); + let qp = supabase.from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'pendente'); + if (isClinic.value) qp = qp.eq('tenant_id', tenantId.value); + else qp = qp.eq('owner_id', ownerId.value); const { count } = await qp; totalPendentes.value = count || 0; } else { @@ -88,8 +91,9 @@ async function load() { // Conta autorizados (sempre, independente do filtro ativo) if (filtroStatus.value !== 'autorizado') { - let qa = tenantDb().from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'autorizado'); - if (!isClinic.value) qa = qa.eq('owner_id', ownerId.value); + let qa = supabase.from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'autorizado'); + if (isClinic.value) qa = qa.eq('tenant_id', tenantId.value); + else qa = qa.eq('owner_id', ownerId.value); const { count: ca } = await qa; totalAutorizados.value = ca || 0; } else { @@ -155,7 +159,7 @@ const aprovando = ref(null); async function aprovar(s) { aprovando.value = s.id; try { - const { error } = await tenantDb().from('agendador_solicitacoes').update({ status: 'autorizado', autorizado_em: new Date().toISOString() }).eq('id', s.id); + const { error } = await supabase.from('agendador_solicitacoes').update({ status: 'autorizado', autorizado_em: new Date().toISOString() }).eq('id', s.id); if (error) throw error; toast.add({ severity: 'success', summary: 'Autorizado', detail: `Solicitação de ${nomeCompleto(s)} autorizada.`, life: 3000 }); await load(); @@ -184,7 +188,8 @@ async function confirmarRecusa() { if (!s) return; recusandoId.value = s.id; try { - const { error } = await tenantDb().from('agendador_solicitacoes') + const { error } = await supabase + .from('agendador_solicitacoes') .update({ status: 'recusado', recusado_motivo: recusaMotivo.value || null }) .eq('id', s.id); if (error) throw error; @@ -293,7 +298,7 @@ async function onEventSaved(arg) { if (normalized[k] !== undefined) dbPayload[k] = normalized[k]; } await createEvento(dbPayload); - const { error } = await tenantDb().from('agendador_solicitacoes').update({ status: 'convertido' }).eq('id', target.id); + const { error } = await supabase.from('agendador_solicitacoes').update({ status: 'convertido' }).eq('id', target.id); if (error) throw error; toast.add({ severity: 'success', summary: 'Convertido!', detail: `Sessão criada para ${nomeCompleto(target)}.`, life: 4000 }); await load(); diff --git a/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue b/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue index fcffcc3..e9af0b4 100644 --- a/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue +++ b/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue @@ -16,7 +16,6 @@ -->