F1b: 6 tabelas anon-facing ficam em public (decisao roteamento anon)

Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico.
Decisao (opcao C): manter em public com RLS por token. Volta a global:
patient_intake_requests, patient_invites, patient_invite_attempts,
document_share_links, agendador_configuracoes, agendador_solicitacoes.

- migration 20260613000001_f1b: remove as 6 do _tenant_template (template v2,
  78 tabelas). Smoke: clone gera 78, zero tabelas anon no schema, drop limpo
- frontend: 38 cadeias em 14 arquivos revertidas tenantDb().from() ->
  supabase.from() com tenant_id/owner_id restaurado (via comparacao com main)
- edge: convert-abandoned-intakes restaurada do main (SELECT global)
- save-intake-progress: ja usava public, sem mudanca
- doc F0 atualizado: 78 tenant + 59 global

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 09:09:46 -03:00
parent 9b21642e15
commit f17e9ee786
17 changed files with 164 additions and 98 deletions
@@ -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;
+4 -2
View File
@@ -9,8 +9,10 @@
| Item | Quantidade |
|---|---|
| Tabelas em `public` (BASE TABLE) | 137 |
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** |
| **Globais** (ficam em `public`) | **53** |
| **Tenant-scoped** (vão pra `tenant_<x>`) — 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`) |
+1 -2
View File
@@ -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;
+4 -3
View File
@@ -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;
}
@@ -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')
@@ -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 = '';
@@ -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();
@@ -16,7 +16,6 @@
-->
<script setup>
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — INSERT/UPDATE
// extraídos pro repository pra remover duplicação.
@@ -276,7 +275,7 @@ const intakeSections = computed(() => {
async function fetchIntakes() {
loading.value = true;
try {
const { data, error } = await tenantDb().from('patient_intake_requests').select('*').order('created_at', { ascending: false });
const { data, error } = await supabase.from('patient_intake_requests').select('*').order('created_at', { ascending: false });
if (error) throw error;
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9);
rows.value = (data || []).slice().sort((a, b) => {
@@ -323,7 +322,7 @@ async function markRejected() {
dlg.value.saving = true;
try {
const reason = String(dlg.value.reject_note || '').trim() || null;
const { error } = await tenantDb().from('patient_intake_requests').update({ status: 'rejected', rejected_reason: reason, updated_at: new Date().toISOString() }).eq('id', item.id);
const { error } = await supabase.from('patient_intake_requests').update({ status: 'rejected', rejected_reason: reason, updated_at: new Date().toISOString() }).eq('id', item.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Rejeitado', detail: 'Solicitação rejeitada.', life: 2500 });
await fetchIntakes();
@@ -483,7 +483,8 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}
// tenant_id no patient_intake_requests pode ser nullable (intake público sem tenant)
// — só filtramos se passado explícito.
let q = tenantDb().from('patient_intake_requests')
let q = supabase
.from('patient_intake_requests')
.update({
status: 'converted',
converted_patient_id: patientId,
@@ -491,6 +492,11 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}
})
.eq('id', intakeId);
if (tenantId) {
const tid = resolveTenantId(tenantId);
q = q.eq('tenant_id', tid);
}
const { error } = await q;
if (error) throw error;
}
@@ -79,7 +79,7 @@ async function onFileSelected(event, field) {
// Persiste imediatamente no banco sem fechar o accordion
const uid = ownerId.value;
const tenantId = await getActiveTenantId(uid);
await tenantDb().from('agendador_configuracoes').upsert({ owner_id: uid, ...buildPayload('identidade'), updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ...buildPayload('identidade'), updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
toast.add({ severity: 'success', summary: 'Imagem salva', life: 2000 });
}
} catch (e) {
@@ -361,7 +361,7 @@ async function load() {
const uid = await getOwnerId();
ownerId.value = uid;
const [{ data, error }] = await Promise.all([tenantDb().from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(), loadPaymentSettings(uid)]);
const [{ data, error }] = await Promise.all([supabase.from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(), loadPaymentSettings(uid)]);
if (error) throw error;
@@ -400,7 +400,7 @@ async function toggleAtivo() {
try {
const tenantId = await getActiveTenantId(uid);
await tenantDb().from('agendador_configuracoes').upsert({ owner_id: uid, ativo: novoAtivo, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ativo: novoAtivo, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
toast.add({
severity: novoAtivo ? 'success' : 'info',
@@ -422,7 +422,7 @@ async function saveCard(cardKey) {
const tenantId = await getActiveTenantId(uid);
const payload = buildPayload(cardKey);
await tenantDb().from('agendador_configuracoes').upsert({ owner_id: uid, ...payload, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
await supabase.from('agendador_configuracoes').upsert({ owner_id: uid, tenant_id: tenantId, ...payload, updated_at: new Date().toISOString() }, { onConflict: 'owner_id' });
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 });
expandedCard.value = new Set();
+10 -7
View File
@@ -343,7 +343,7 @@ async function load() {
ownerId.value = uid;
const [{ data, error }] = await Promise.all([
tenantDb().from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(),
supabase.from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(),
loadPaymentSettings(uid)
]);
if (error) throw error;
@@ -382,9 +382,10 @@ async function toggleAtivo() {
cfg.value.ativo = novoAtivo;
try {
const tenantId = await getActiveTenantId(uid);
const { error } = await tenantDb().from('agendador_configuracoes')
const { error } = await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, ativo: novoAtivo, updated_at: new Date().toISOString() },
{ owner_id: uid, tenant_id: tenantId, ativo: novoAtivo, updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
if (error) throw error;
@@ -473,9 +474,10 @@ async function saveCard(cardKey) {
const uid = ownerId.value;
const tenantId = await getActiveTenantId(uid);
const payload = buildPayload(cardKey);
const { error } = await tenantDb().from('agendador_configuracoes')
const { error } = await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, ...payload, updated_at: new Date().toISOString() },
{ owner_id: uid, tenant_id: tenantId, ...payload, updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
if (error) throw error;
@@ -516,9 +518,10 @@ async function onFileSelected(event, field) {
if (field === 'fundo') cfg.value.imagem_fundo_url = url;
const uid = ownerId.value;
const tenantId = await getActiveTenantId(uid);
await tenantDb().from('agendador_configuracoes')
await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, ...buildPayload('identidade'), updated_at: new Date().toISOString() },
{ owner_id: uid, tenant_id: tenantId, ...buildPayload('identidade'), updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
toast.add({ severity: 'success', summary: 'Imagem salva', life: 2000 });
@@ -222,12 +222,14 @@ async function fetchSolicitacoes() {
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, recusado_motivo, autorizado_em, 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, recusado_motivo, autorizado_em, 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);
const { data, error } = await q;
if (error) throw error;
@@ -293,7 +295,8 @@ async function autorizar() {
if (!item) return;
dlg.value.saving = true;
try {
const { error } = await tenantDb().from('agendador_solicitacoes')
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'autorizado', autorizado_em: new Date().toISOString() })
.eq('id', item.id);
if (error) throw error;
@@ -323,7 +326,8 @@ async function recusar() {
dlg.value.saving = true;
try {
const motivo = String(dlg.value.recusa_note || '').trim() || null;
const { error } = await tenantDb().from('agendador_solicitacoes')
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'recusado', recusado_motivo: motivo })
.eq('id', item.id);
if (error) throw error;
@@ -442,7 +446,8 @@ async function onEventSaved(arg) {
const dbPayload = {};
for (const k of dbFields) if (normalized[k] !== undefined) dbPayload[k] = normalized[k];
await createEvento(dbPayload);
const { error } = await tenantDb().from('agendador_solicitacoes')
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'convertido' })
.eq('id', target.id);
if (error) throw error;
@@ -16,7 +16,6 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — extração pro repository.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
@@ -258,7 +257,8 @@ const pagedItems = computed(() => {
async function fetchIntakes() {
loading.value = true;
try {
const { data, error } = await tenantDb().from('patient_intake_requests').select('*')
const { data, error } = await supabase
.from('patient_intake_requests').select('*')
.order('created_at', { ascending: false });
if (error) throw error;
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9);
@@ -346,7 +346,7 @@ async function markRejected() {
dlg.value.saving = true;
try {
const reason = String(dlg.value.reject_note || '').trim() || null;
const { error } = await tenantDb().from('patient_intake_requests')
const { error } = await supabase.from('patient_intake_requests')
.update({ status: 'rejected', rejected_reason: reason, updated_at: new Date().toISOString() })
.eq('id', item.id);
if (error) throw error;
+7 -5
View File
@@ -15,8 +15,6 @@
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
@@ -60,9 +58,11 @@ export async function createShareLink(documentoId, opts = {}) {
const expiraEm = new Date();
expiraEm.setHours(expiraEm.getHours() + expiracaoHoras);
const { data, error } = await tenantDb().from('document_share_links')
const { data, error } = await supabase
.from('document_share_links')
.insert({
documento_id: documentoId,
tenant_id: tenantId,
expira_em: expiraEm.toISOString(),
usos_max: opts.usosMax || 5,
criado_por: ownerId
@@ -81,7 +81,8 @@ export async function listShareLinks(documentoId) {
const ownerId = await getOwnerId();
const { data, error } = await tenantDb().from('document_share_links')
const { data, error } = await supabase
.from('document_share_links')
.select('*')
.eq('documento_id', documentoId)
.eq('criado_por', ownerId)
@@ -129,7 +130,8 @@ export async function deactivateShareLink(linkId) {
const ownerId = await getOwnerId();
const { error } = await tenantDb().from('document_share_links')
const { error } = await supabase
.from('document_share_links')
.update({ ativo: false })
.eq('id', linkId)
.eq('criado_por', ownerId);
+6 -4
View File
@@ -432,15 +432,17 @@ async function load() {
.eq('tenant_id', tid)
.in('role', ['therapist', 'tenant_admin'])
.eq('status', 'active'),
tenantDb().from('agendador_solicitacoes')
supabase
.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada')
.eq('tenant_id', tid)
.eq('status', 'pendente')
.order('created_at', { ascending: false })
.limit(10),
tenantDb().from('patient_intake_requests')
supabase
.from('patient_intake_requests')
.select('id, nome_completo, status, created_at')
.eq('tenant_id', tid)
.eq('status', 'new')
.order('created_at', { ascending: false })
.limit(10)
@@ -562,7 +562,7 @@ const solicitacoesPendentes = computed(() => solicitacoes.value.length);
async function aceitarSol(id) {
try {
await tenantDb().from('agendador_solicitacoes').update({ status: 'autorizado' }).eq('id', id);
await supabase.from('agendador_solicitacoes').update({ status: 'autorizado' }).eq('id', id);
_solicitacoesBruto.value = _solicitacoesBruto.value.filter((s) => s.id !== id);
} catch (e) {
console.error('[TherapistDashboard] aceitarSol:', e);
@@ -570,7 +570,7 @@ async function aceitarSol(id) {
}
async function recusarSol(id) {
try {
await tenantDb().from('agendador_solicitacoes').update({ status: 'recusado' }).eq('id', id);
await supabase.from('agendador_solicitacoes').update({ status: 'recusado' }).eq('id', id);
_solicitacoesBruto.value = _solicitacoesBruto.value.filter((s) => s.id !== id);
} catch (e) {
console.error('[TherapistDashboard] recusarSol:', e);
@@ -900,13 +900,14 @@ async function load() {
.order('start_date', { ascending: false });
return q;
})(),
tenantDb().from('agendador_solicitacoes')
supabase
.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada')
.eq('owner_id', ownerId.value)
.eq('status', 'pendente')
.order('created_at', { ascending: false })
.limit(10),
tenantDb().from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10)
supabase.from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10)
]);
eventosDoMes.value = eventosRes.data || [];
_solicitacoesBruto.value = solRes.data || [];
@@ -13,7 +13,7 @@
|--------------------------------------------------------------------------
*/
import { adminClient, listTenantSchemas } from '../_shared/tenant.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -37,56 +37,48 @@ Deno.serve(async (req: Request) => {
const body = await req.json().catch(() => ({})) as { idle_minutes?: number }
const idleMinutes = Math.max(5, Math.min(1440, Number(body.idle_minutes) || DEFAULT_IDLE_MINUTES))
const admin = adminClient()
const supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const cutoff = new Date(Date.now() - idleMinutes * 60 * 1000).toISOString()
let checked = 0
let eligibleCount = 0
// Busca candidatos: in_progress, last_progress_at antigo, tem minimo nome OU telefone
const { data: candidates, error: fetchErr } = await supa
.from('patient_intake_requests')
.select('id, nome_completo, telefone, email_principal')
.eq('status', 'in_progress')
.lt('last_progress_at', cutoff)
if (fetchErr) return json({ error: fetchErr.message }, 500)
const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone)
if (eligible.length === 0) {
return json({ checked: candidates?.length || 0, converted: 0, errors: 0 })
}
let converted = 0
let errors = 0
const results: Array<{ tenant_id: string; intake_id: string; ok: boolean; error?: string }> = []
const results: Array<{ intake_id: string; ok: boolean; error?: string }> = []
// Varre todos os tenants; patient_intake_requests é tenant → tdb
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
// Busca candidatos: in_progress, last_progress_at antigo, tem minimo nome OU telefone
const { data: candidates, error: fetchErr } = await tdb
.from('patient_intake_requests')
.select('id, nome_completo, telefone, email_principal')
.eq('status', 'in_progress')
.lt('last_progress_at', cutoff)
if (fetchErr) {
console.error(`[convert-abandoned-intakes] fetch error (tenant ${t.tenantId}):`, fetchErr.message)
continue
}
checked += candidates?.length || 0
const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone)
eligibleCount += eligible.length
for (const row of eligible) {
// RPC opera no schema do tenant → tdb.rpc (assinatura só com p_intake_id).
// TODO(F6): se a RPC passar a exigir p_tenant_id, adicionar t.tenantId aqui.
const { error: rpcErr } = await tdb.rpc('convert_abandoned_intake_to_lead', {
p_intake_id: row.id
})
if (rpcErr) {
errors++
results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: false, error: rpcErr.message })
} else {
converted++
results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: true })
}
for (const row of eligible) {
const { error: rpcErr } = await supa.rpc('convert_abandoned_intake_to_lead', {
p_intake_id: row.id
})
if (rpcErr) {
errors++
results.push({ intake_id: row.id, ok: false, error: rpcErr.message })
} else {
converted++
results.push({ intake_id: row.id, ok: true })
}
}
return json({
checked,
eligible: eligibleCount,
checked: candidates?.length || 0,
eligible: eligible.length,
converted,
errors,
idle_minutes: idleMinutes,