a7f6bcbe66
- 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>
193 lines
7.1 KiB
JavaScript
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
|
|
};
|
|
}
|