Files
agenciapsilmno/src/composables/useConversationOptouts.js
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

193 lines
7.1 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationOptouts.js
| Data: 2026-04-21
|
| Gerencia opt-outs do CRM WhatsApp (LGPD Art. 18 Sec.2).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function normalizePhoneBR(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
// Sem DDI 55 → agrega
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
export function useConversationOptouts() {
const tenantStore = useTenantStore();
const optouts = ref([]);
const keywords = ref([]);
const loading = ref(false);
const saving = ref(false);
const activeOptouts = computed(() => optouts.value.filter((o) => !o.opted_back_in_at));
const historyOptouts = computed(() => optouts.value.filter((o) => o.opted_back_in_at));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [optsRes, kwsRes] = await Promise.all([
tenantDb().from('conversation_optouts')
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
.order('opted_out_at', { ascending: false }),
tenantDb().from('conversation_optout_keywords')
.select('id, keyword, enabled, is_system')
.order('is_system', { ascending: false })
.order('keyword', { ascending: true })
]);
optouts.value = optsRes.data || [];
keywords.value = kwsRes.data || [];
// Enriquece com nome do paciente
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
if (patIds.length) {
const { data: pats } = await tenantDb().from('patients').select('id, nome_completo').in('id', patIds);
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
}
} catch (e) {
console.error('[useConversationOptouts] load:', e?.message);
} finally {
loading.value = false;
}
}
async function addManual({ phone, patientId = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const cleanPhone = normalizePhoneBR(phone);
if (!tenantId || !cleanPhone) return { ok: false, error: 'invalid_params' };
if (!/^\d{6,15}$/.test(cleanPhone)) return { ok: false, error: 'invalid_phone_format' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
// Verifica se já existe ativo
const { data: existing } = await tenantDb().from('conversation_optouts')
.select('id')
.eq('phone', cleanPhone)
.is('opted_back_in_at', null)
.maybeSingle();
if (existing) return { ok: false, error: 'already_opted_out' };
const { data, error } = await tenantDb().from('conversation_optouts')
.insert({
phone: cleanPhone,
patient_id: patientId,
source: 'manual',
notes,
blocked_by: userId
})
.select('id, phone, patient_id, source, notes, opted_out_at, blocked_by')
.single();
if (error) throw error;
optouts.value = [{ ...data, _patient_name: null }, ...optouts.value];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function restore(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const now = new Date().toISOString();
const { error } = await tenantDb().from('conversation_optouts')
.update({ opted_back_in_at: now })
.eq('id', id);
if (error) throw error;
const item = optouts.value.find((o) => o.id === id);
if (item) item.opted_back_in_at = now;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'restore_failed' };
} finally {
saving.value = false;
}
}
async function addKeyword(keyword) {
const tenantId = tenantStore.activeTenantId;
const clean = String(keyword || '').trim().slice(0, 100);
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data, error } = await tenantDb().from('conversation_optout_keywords')
.insert({ keyword: clean, is_system: false, enabled: true })
.select('id, keyword, enabled, is_system')
.single();
if (error) throw error;
keywords.value = [...keywords.value, data];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_keyword_failed' };
} finally {
saving.value = false;
}
}
async function toggleKeyword(id, enabled) {
saving.value = true;
try {
const { error } = await tenantDb().from('conversation_optout_keywords')
.update({ enabled })
.eq('id', id);
if (error) throw error;
const item = keywords.value.find((k) => k.id === id);
if (item) item.enabled = enabled;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'toggle_failed' };
} finally {
saving.value = false;
}
}
async function deleteKeyword(id) {
saving.value = true;
try {
const { error } = await tenantDb().from('conversation_optout_keywords')
.delete()
.eq('id', id);
if (error) throw error;
keywords.value = keywords.value.filter((k) => k.id !== id);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
return {
optouts,
keywords,
activeOptouts,
historyOptouts,
loading,
saving,
load,
addManual,
restore,
addKeyword,
toggleKeyword,
deleteKeyword
};
}