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>
374 lines
11 KiB
Vue
374 lines
11 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI — GlobalInboundNotifier
|
|
|--------------------------------------------------------------------------
|
|
| Componente global que escuta INSERTs de conversation_messages (direction
|
|
| inbound) e mostra card flutuante no canto inferior direito, além de tocar
|
|
| som opcional. Mesmo estando em outra tela, o usuário é avisado da nova
|
|
| mensagem e pode clicar "Abrir" pra ir direto à conversa.
|
|
|
|
|
| Monta uma vez em AppLayout.
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { tenantDb } from '@/lib/supabase/tenantClient';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
|
import { logEvent, logError } from '@/support/supportLogger';
|
|
|
|
const LOG_SRC = 'GlobalInboundNotifier';
|
|
|
|
const tenantStore = useTenantStore();
|
|
const drawerStore = useConversationDrawerStore();
|
|
|
|
const activeNotifs = ref([]); // array de popups ativos
|
|
let channel = null;
|
|
|
|
const SOUND_KEY = 'agenciapsi.inbound_sound_enabled';
|
|
const soundEnabled = ref(true);
|
|
|
|
function loadSoundPref() {
|
|
try {
|
|
soundEnabled.value = localStorage.getItem(SOUND_KEY) !== 'false';
|
|
} catch { soundEnabled.value = true; }
|
|
}
|
|
|
|
function toggleSound() {
|
|
soundEnabled.value = !soundEnabled.value;
|
|
try {
|
|
localStorage.setItem(SOUND_KEY, soundEnabled.value ? 'true' : 'false');
|
|
} catch {}
|
|
}
|
|
|
|
function playBeep() {
|
|
if (!soundEnabled.value) return;
|
|
try {
|
|
// WebAudio API — beep sintético de 2 tons curtos
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const now = ctx.currentTime;
|
|
function tone(freq, start, dur, vol = 0.15) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(0, now + start);
|
|
gain.gain.linearRampToValueAtTime(vol, now + start + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur);
|
|
osc.connect(gain).connect(ctx.destination);
|
|
osc.start(now + start);
|
|
osc.stop(now + start + dur);
|
|
}
|
|
tone(880, 0, 0.12);
|
|
tone(1320, 0.14, 0.12);
|
|
// Fecha o context após som
|
|
setTimeout(() => { try { ctx.close(); } catch {} }, 500);
|
|
} catch {
|
|
// se WebAudio falhar (sem interação prévia, etc), apenas ignora
|
|
}
|
|
}
|
|
|
|
function truncate(s, n = 80) {
|
|
if (!s) return '';
|
|
const str = String(s).replace(/\s+/g, ' ').trim();
|
|
return str.length > n ? str.slice(0, n - 1) + '…' : str;
|
|
}
|
|
|
|
async function showNotif(msg) {
|
|
logEvent(LOG_SRC, 'showNotif chamado', { id: msg.id, from: msg.from_number });
|
|
// Se o drawer dessa thread já está aberto, não notifica (o user já vê)
|
|
if (drawerStore.isOpen && drawerStore.messageBelongsToCurrentThread(msg)) {
|
|
logEvent(LOG_SRC, 'suprimido: drawer aberto na thread', { id: msg.id });
|
|
return;
|
|
}
|
|
// Se está na rota /conversas com aba visível, também não notifica (já tá no Kanban)
|
|
const onConversasRoute = typeof window !== 'undefined' && String(window.location.pathname).includes('/conversas');
|
|
if (onConversasRoute && document.visibilityState === 'visible') {
|
|
logEvent(LOG_SRC, 'suprimido: na rota /conversas visível', { id: msg.id });
|
|
return;
|
|
}
|
|
|
|
let name = msg.from_number || 'Desconhecido';
|
|
if (msg.patient_id) {
|
|
const { data } = await tenantDb().from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
|
|
if (data?.nome_completo) name = data.nome_completo;
|
|
}
|
|
|
|
const notif = {
|
|
id: msg.id,
|
|
name,
|
|
body: msg.body || (msg.media_url ? '[mídia]' : ''),
|
|
patient_id: msg.patient_id,
|
|
from_number: msg.from_number,
|
|
channel: msg.channel
|
|
};
|
|
|
|
activeNotifs.value.push(notif);
|
|
logEvent(LOG_SRC, 'popup push', { total: activeNotifs.value.length });
|
|
playBeep();
|
|
|
|
setTimeout(() => dismiss(notif.id), 10000);
|
|
}
|
|
|
|
function dismiss(id) {
|
|
activeNotifs.value = activeNotifs.value.filter(n => n.id !== id);
|
|
}
|
|
|
|
async function openNotif(notif) {
|
|
dismiss(notif.id);
|
|
if (notif.patient_id) {
|
|
await drawerStore.openForPatient(notif.patient_id);
|
|
} else {
|
|
// thread anônima (número não vinculado a paciente)
|
|
await drawerStore.openForThread({
|
|
thread_key: `anon:${notif.from_number}`,
|
|
tenant_id: tenantStore.activeTenantId,
|
|
patient_id: null,
|
|
patient_name: null,
|
|
contact_number: notif.from_number,
|
|
channel: notif.channel,
|
|
message_count: 1,
|
|
unread_count: 1,
|
|
kanban_status: 'awaiting_us',
|
|
last_message_at: new Date().toISOString()
|
|
});
|
|
}
|
|
}
|
|
|
|
function channelIcon(ch) {
|
|
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
|
|
return map[ch] || 'pi-bell';
|
|
}
|
|
|
|
function subscribe() {
|
|
const tenantId = tenantStore.activeTenantId;
|
|
const tenantSchema = tenantStore.activeTenantSchema;
|
|
if (!tenantId || !tenantSchema) {
|
|
logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
|
|
return;
|
|
}
|
|
if (channel) supabase.removeChannel(channel);
|
|
logEvent(LOG_SRC, 'subscribing', { tenantId });
|
|
channel = supabase
|
|
.channel(`global_inbound_${tenantId}_${Date.now()}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'INSERT',
|
|
schema: tenantSchema,
|
|
table: 'conversation_messages'
|
|
},
|
|
(payload) => {
|
|
const m = payload.new;
|
|
logEvent(LOG_SRC, 'INSERT recebido', {
|
|
id: m.id,
|
|
direction: m.direction,
|
|
from: m.from_number,
|
|
preview: m.body?.slice(0, 40)
|
|
});
|
|
if (m.direction !== 'inbound') {
|
|
logEvent(LOG_SRC, 'ignorando (não é inbound)', { direction: m.direction });
|
|
return;
|
|
}
|
|
showNotif(m);
|
|
}
|
|
)
|
|
.subscribe((status) => {
|
|
logEvent(LOG_SRC, 'channel status', { status });
|
|
});
|
|
}
|
|
|
|
function unsubscribe() {
|
|
if (channel) {
|
|
supabase.removeChannel(channel);
|
|
channel = null;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadSoundPref();
|
|
subscribe();
|
|
});
|
|
|
|
onUnmounted(() => unsubscribe());
|
|
|
|
watch(() => tenantStore.activeTenantId, () => subscribe());
|
|
|
|
// expõe toggle pra quem quiser usar (ex: na aside do CRM)
|
|
defineExpose({ soundEnabled, toggleSound });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="global-notif-container" aria-live="polite">
|
|
<TransitionGroup name="notif-slide" tag="div" class="flex flex-col gap-2">
|
|
<div
|
|
v-for="n in activeNotifs"
|
|
:key="n.id"
|
|
class="global-notif-card"
|
|
role="alert"
|
|
>
|
|
<div class="notif-icon" :class="n.channel === 'whatsapp' ? 'text-emerald-600 bg-emerald-500/10' : 'text-blue-600 bg-blue-500/10'">
|
|
<i :class="['pi', channelIcon(n.channel)]" />
|
|
</div>
|
|
<div class="notif-body">
|
|
<div class="notif-header">
|
|
<span class="notif-name">{{ n.name }}</span>
|
|
<span class="notif-channel">{{ n.channel === 'whatsapp' ? 'WhatsApp' : n.channel }}</span>
|
|
</div>
|
|
<div class="notif-preview">{{ truncate(n.body, 80) }}</div>
|
|
</div>
|
|
<div class="notif-actions">
|
|
<Button label="Abrir" size="small" severity="success" class="!text-xs" @click="openNotif(n)" />
|
|
<button class="notif-close" title="Dispensar" @click="dismiss(n.id)">
|
|
<i class="pi pi-times" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<!-- Toggle de som (mini, aparece se tem notificação ou pode ficar sempre visível) -->
|
|
<button
|
|
v-if="activeNotifs.length > 0"
|
|
class="sound-toggle"
|
|
:title="soundEnabled ? 'Som ativado (clique pra desativar)' : 'Som desativado'"
|
|
@click="toggleSound"
|
|
>
|
|
<i :class="soundEnabled ? 'pi pi-volume-up' : 'pi pi-volume-off'" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.global-notif-container {
|
|
position: fixed;
|
|
bottom: 1.25rem;
|
|
right: 1.25rem;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
width: min(380px, calc(100vw - 2rem));
|
|
}
|
|
|
|
.global-notif-card {
|
|
pointer-events: auto;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
background: var(--surface-card, #fff);
|
|
border: 1px solid var(--surface-border, #e5e7eb);
|
|
border-radius: 10px;
|
|
padding: 0.75rem;
|
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.notif-icon {
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: 50%;
|
|
display: grid;
|
|
place-items: center;
|
|
flex-shrink: 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.notif-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.notif-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.notif-name {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
color: var(--text-color, #111827);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.notif-channel {
|
|
font-size: 0.65rem;
|
|
padding: 0 0.35rem;
|
|
background: var(--surface-ground, #f3f4f6);
|
|
color: var(--text-color-secondary, #6b7280);
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.notif-preview {
|
|
font-size: 0.8rem;
|
|
color: var(--text-color-secondary, #6b7280);
|
|
line-height: 1.3;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.notif-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.notif-close {
|
|
width: 1.75rem;
|
|
height: 1.75rem;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color-secondary, #6b7280);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 0.75rem;
|
|
transition: background 0.15s;
|
|
}
|
|
.notif-close:hover {
|
|
background: var(--surface-ground, #f3f4f6);
|
|
}
|
|
|
|
.sound-toggle {
|
|
pointer-events: auto;
|
|
margin-top: 0.5rem;
|
|
align-self: flex-end;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--surface-border, #e5e7eb);
|
|
background: var(--surface-card, #fff);
|
|
color: var(--text-color-secondary, #6b7280);
|
|
cursor: pointer;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 0.75rem;
|
|
}
|
|
.sound-toggle:hover {
|
|
background: var(--surface-ground, #f3f4f6);
|
|
color: var(--text-color, #111827);
|
|
}
|
|
|
|
/* Animação de entrada/saída */
|
|
.notif-slide-enter-active {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.notif-slide-leave-active {
|
|
transition: all 0.2s ease-in;
|
|
}
|
|
.notif-slide-enter-from {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
.notif-slide-leave-to {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
</style>
|