CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.
═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════
3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.
3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).
3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).
3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.
3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".
═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════
- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
envolve envio em dedução atômica + rollback). Consumido por Evolution E
Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand
═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════
Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
transactions, packages, purchases) + RPCs add_whatsapp_credits e
deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
de 11 ou 14 dígitos)
Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance
Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
error.context.json()
Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
- Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
toggle is_active inline, dialog de edição com validação
- Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
das últimas 20 transações topup/adjustment/refund
═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════
2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)
2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha
═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════
5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords
═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════
- contact_types + contact_phones (entity_type + entity_id) — migration
20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
e cria/atualiza phone como WhatsApp vinculado
═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════
- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
.dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
(fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
sino + popup + browser notification)
═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════
20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj
═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════
Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound
═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════
Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits
Stores: conversationDrawerStore
Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor
Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage
Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats
Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)
Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados
═══════════════════════════════════════════════════════════════════════════
NOTAS
- Após subir, rodar supabase functions serve --no-verify-jwt
--env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/stores/conversationDrawerStore.js
|
||||
|
|
||||
| Store global do drawer de conversa. Permite abrir de qualquer página
|
||||
| (ficha de paciente, lista, agenda, dashboard) sem navegar.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário.
|
||||
// O edge function retorna { ok: false, error: '<code>', message: '<human>' } — priorizamos message.
|
||||
function friendlySendError(code, providedMessage) {
|
||||
if (providedMessage) return providedMessage;
|
||||
const c = String(code || '').toLowerCase();
|
||||
if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.';
|
||||
if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.';
|
||||
if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) return 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
|
||||
if (c.includes('credenciais evolution incompletas')) return 'As credenciais do WhatsApp Pessoal estão incompletas. Acesse Configurações → WhatsApp.';
|
||||
if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.';
|
||||
if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.';
|
||||
if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.';
|
||||
if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.';
|
||||
if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.';
|
||||
// Fallback: retorna o código original se nada bateu
|
||||
return code || 'Falha ao enviar. Tente novamente.';
|
||||
}
|
||||
|
||||
export const useConversationDrawerStore = defineStore('conversationDrawer', {
|
||||
state: () => ({
|
||||
isOpen: false,
|
||||
thread: null, // { patient_id, patient_name, contact_number, channel, kanban_status, ... }
|
||||
messages: [],
|
||||
loading: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
_realtimeChannel: null,
|
||||
// cache compartilhado
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
templatesLoading: false,
|
||||
tenantName: ''
|
||||
}),
|
||||
|
||||
getters: {
|
||||
threadKey(state) {
|
||||
if (!state.thread) return null;
|
||||
return state.thread.patient_id || `anon:${state.thread.contact_number || 'unknown'}`;
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// ── Abertura ───────────────────────────────────────────────
|
||||
async openForThread(thread) {
|
||||
if (!thread) return;
|
||||
this.thread = { ...thread };
|
||||
this.isOpen = true;
|
||||
await this.loadMessages();
|
||||
this._ensureTenantName();
|
||||
this._subscribeRealtime();
|
||||
// Marca inbound como lidas
|
||||
if ((thread.unread_count || 0) > 0) {
|
||||
await this.markRead();
|
||||
}
|
||||
},
|
||||
|
||||
async openForPatient(patientId) {
|
||||
if (!patientId) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
// Procura thread existente pelo paciente
|
||||
const { data: existing } = await supabase
|
||||
.from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', patientId)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
return this.openForThread(existing);
|
||||
}
|
||||
|
||||
// Não tem thread — cria stub com dados do paciente
|
||||
const { data: pat } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, telefone')
|
||||
.eq('id', patientId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pat) {
|
||||
this.error = new Error('Paciente não encontrado');
|
||||
return;
|
||||
}
|
||||
if (!pat.telefone) {
|
||||
this.error = new Error('Paciente sem telefone cadastrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const stub = {
|
||||
thread_key: pat.id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: pat.id,
|
||||
patient_name: pat.nome_completo,
|
||||
contact_number: String(pat.telefone).replace(/\D/g, ''),
|
||||
channel: 'whatsapp',
|
||||
message_count: 0,
|
||||
unread_count: 0,
|
||||
kanban_status: 'awaiting_us',
|
||||
last_message_at: null,
|
||||
last_message_body: null,
|
||||
last_message_direction: null
|
||||
};
|
||||
return this.openForThread(stub);
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this._unsubscribeRealtime();
|
||||
// preserva thread por um momento pra transição, mas limpa messages
|
||||
setTimeout(() => {
|
||||
if (!this.isOpen) {
|
||||
this.thread = null;
|
||||
this.messages = [];
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// ── Mensagens da thread ────────────────────────────────────
|
||||
async loadMessages() {
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
this.loading = true;
|
||||
try {
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(500);
|
||||
|
||||
if (this.thread.patient_id) {
|
||||
q = q.eq('patient_id', this.thread.patient_id);
|
||||
} else {
|
||||
q = q.or(`from_number.eq.${this.thread.contact_number},to_number.eq.${this.thread.contact_number}`).is('patient_id', null);
|
||||
}
|
||||
|
||||
const { data, error: qErr } = await q;
|
||||
if (qErr) throw qErr;
|
||||
this.messages = data ?? [];
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
messageBelongsToCurrentThread(msg) {
|
||||
if (!msg || !this.thread) return false;
|
||||
if (this.thread.patient_id) return msg.patient_id === this.thread.patient_id;
|
||||
if (msg.patient_id) return false;
|
||||
return (
|
||||
msg.from_number === this.thread.contact_number ||
|
||||
msg.to_number === this.thread.contact_number
|
||||
);
|
||||
},
|
||||
|
||||
_subscribeRealtime() {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
this._unsubscribeRealtime();
|
||||
|
||||
this._realtimeChannel = supabase
|
||||
.channel(`conv_drawer_${tenantId}_${Date.now()}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
(payload) => {
|
||||
const msg = payload.new;
|
||||
if (this.messageBelongsToCurrentThread(msg)) {
|
||||
const exists = this.messages.some((m) => m.id === msg.id);
|
||||
if (!exists) this.messages.push(msg);
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
(payload) => {
|
||||
const msg = payload.new;
|
||||
if (this.messageBelongsToCurrentThread(msg)) {
|
||||
const idx = this.messages.findIndex((m) => m.id === msg.id);
|
||||
if (idx >= 0) this.messages[idx] = msg;
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
|
||||
_unsubscribeRealtime() {
|
||||
if (this._realtimeChannel) {
|
||||
supabase.removeChannel(this._realtimeChannel);
|
||||
this._realtimeChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Ações ──────────────────────────────────────────────────
|
||||
async sendMessage(text) {
|
||||
const cleanText = String(text || '').trim();
|
||||
if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' };
|
||||
if (!this.thread?.contact_number) return { ok: false, error: 'Conversa sem número de contato' };
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
this.sending = true;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-whatsapp-message', {
|
||||
body: {
|
||||
tenant_id: tenantStore.activeTenantId,
|
||||
to_number: this.thread.contact_number,
|
||||
body: cleanText,
|
||||
patient_id: this.thread.patient_id || null
|
||||
}
|
||||
});
|
||||
|
||||
// Erro HTTP (não-2xx) — extrai body da resposta pra mostrar msg amigável
|
||||
if (error) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await error.context?.json?.();
|
||||
} catch { /* noop */ }
|
||||
return { ok: false, error: friendlySendError(body?.error, body?.message) };
|
||||
}
|
||||
if (!data?.ok) {
|
||||
return { ok: false, error: friendlySendError(data?.error, data?.message) };
|
||||
}
|
||||
|
||||
this.thread.kanban_status = 'awaiting_patient';
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
return { ok: false, error: friendlySendError(err?.message || String(err)) };
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
},
|
||||
|
||||
async setKanbanStatus(status) {
|
||||
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return;
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const patch = { kanban_status: status };
|
||||
if (status === 'resolved') patch.resolved_at = new Date().toISOString();
|
||||
|
||||
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantStore.activeTenantId);
|
||||
if (this.thread.patient_id) q = q.eq('patient_id', this.thread.patient_id);
|
||||
else q = q.eq('from_number', this.thread.contact_number).is('patient_id', null);
|
||||
|
||||
await q;
|
||||
this.thread.kanban_status = status;
|
||||
},
|
||||
|
||||
async markRead() {
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const nowIso = new Date().toISOString();
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.update({ read_at: nowIso })
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
if (this.thread.patient_id) q = q.eq('patient_id', this.thread.patient_id);
|
||||
else q = q.eq('from_number', this.thread.contact_number).is('patient_id', null);
|
||||
await q;
|
||||
},
|
||||
|
||||
// ── Templates (cache global) ───────────────────────────────
|
||||
async loadTemplates({ force = false } = {}) {
|
||||
if ((this.templatesLoaded || this.templatesLoading) && !force) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
|
||||
this.templatesLoading = true;
|
||||
try {
|
||||
const { data: globals } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('id, key, body_text, variables, event_type')
|
||||
.eq('channel', 'whatsapp')
|
||||
.is('tenant_id', null)
|
||||
.eq('is_default', true)
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
const { data: customs } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('id, key, body_text, variables, event_type')
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
const customMap = new Map((customs || []).map((c) => [c.key, c]));
|
||||
const merged = (globals || []).map((g) => customMap.get(g.key) || g);
|
||||
for (const c of customs || []) {
|
||||
if (!merged.find((m) => m.key === c.key)) merged.push(c);
|
||||
}
|
||||
this.templates = merged;
|
||||
this.templatesLoaded = true;
|
||||
} finally {
|
||||
this.templatesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
invalidateTemplates() {
|
||||
this.templatesLoaded = false;
|
||||
},
|
||||
|
||||
async _ensureTenantName() {
|
||||
if (this.tenantName) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const { data } = await supabase.from('tenants').select('name').eq('id', tenantId).maybeSingle();
|
||||
this.tenantName = data?.name || '';
|
||||
},
|
||||
|
||||
// ── Resolução de variáveis de template ─────────────────────
|
||||
// Retorna: { text, missing } — missing é lista de variáveis não resolvidas
|
||||
async resolveTemplate(tpl) {
|
||||
if (!tpl?.body_text) return { text: '', missing: [] };
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
// Contexto base (sempre disponível)
|
||||
const now = new Date();
|
||||
const ctx = {
|
||||
data: now.toLocaleDateString('pt-BR'),
|
||||
hora: now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
|
||||
nome_paciente: this.thread?.patient_name || '',
|
||||
nome_clinica: this.tenantName || '',
|
||||
clinica: this.tenantName || '',
|
||||
// aliases curtos
|
||||
paciente: this.thread?.patient_name || '',
|
||||
nome: (this.thread?.patient_name || '').split(' ')[0] || '',
|
||||
primeiro_nome: (this.thread?.patient_name || '').split(' ')[0] || ''
|
||||
};
|
||||
|
||||
// Enriquece com dados do DB (terapeuta + próxima sessão)
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const uid = authData?.user?.id;
|
||||
if (uid) {
|
||||
const { data: profile } = await supabase.from('profiles').select('full_name, nickname').eq('id', uid).maybeSingle();
|
||||
const name = profile?.full_name || profile?.nickname || '';
|
||||
ctx.nome_terapeuta = name;
|
||||
ctx.terapeuta = name;
|
||||
}
|
||||
|
||||
if (this.thread?.patient_id && tenantId) {
|
||||
const { data: sess } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('inicio_em, modalidade, price')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', this.thread.patient_id)
|
||||
.gte('inicio_em', new Date().toISOString())
|
||||
.neq('tipo', 'bloqueio')
|
||||
.order('inicio_em', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (sess?.inicio_em) {
|
||||
const d = new Date(sess.inicio_em);
|
||||
ctx.data_sessao = d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
ctx.hora_sessao = d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
ctx.modalidade = sess.modalidade === 'online' ? 'Online' : 'Presencial';
|
||||
if (sess.price) {
|
||||
ctx.valor_sessao = Number(sess.price).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// contexto opcional — segue sem
|
||||
}
|
||||
|
||||
// Substitui {{var}} (Mustache) e também {var} (legado) case-insensitive
|
||||
const missing = [];
|
||||
const text = String(tpl.body_text).replace(/\{\{?\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}?\}/g, (match, varName) => {
|
||||
const key = varName.toLowerCase();
|
||||
if (key in ctx && ctx[key] != null && ctx[key] !== '') {
|
||||
return ctx[key];
|
||||
}
|
||||
if (!missing.includes(varName)) missing.push(varName);
|
||||
return match; // preserva placeholder pro user editar manualmente
|
||||
});
|
||||
|
||||
return { text, missing };
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,76 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ─── Browser Notification helpers ────────────────────────────
|
||||
const BROWSER_NOTIF_PREF_KEY = 'agenciapsi.browser_notifications_enabled';
|
||||
|
||||
function browserNotifSupported() {
|
||||
return typeof window !== 'undefined' && 'Notification' in window;
|
||||
}
|
||||
|
||||
function browserNotifAllowed() {
|
||||
return browserNotifSupported() && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
function browserNotifEnabled() {
|
||||
if (!browserNotifAllowed()) return false;
|
||||
try {
|
||||
const v = localStorage.getItem(BROWSER_NOTIF_PREF_KEY);
|
||||
return v !== 'false'; // default: on quando permitido
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function fireBrowserNotification(item) {
|
||||
if (!browserNotifEnabled()) return;
|
||||
if (document.hasFocus() && document.visibilityState === 'visible') return; // não notifica se tab ativa
|
||||
try {
|
||||
const title = item?.payload?.title || 'Nova notificação';
|
||||
const body = item?.payload?.detail || '';
|
||||
const n = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
tag: `agenciapsi:${item.id}`,
|
||||
requireInteraction: false
|
||||
});
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
if (item?.payload?.deeplink) {
|
||||
window.location.hash = '';
|
||||
window.location.pathname = item.payload.deeplink;
|
||||
}
|
||||
n.close();
|
||||
};
|
||||
} catch {
|
||||
// browser sem suporte ou permissão revogada
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestBrowserNotificationPermission() {
|
||||
if (!browserNotifSupported()) return false;
|
||||
if (Notification.permission === 'granted') return true;
|
||||
if (Notification.permission === 'denied') return false;
|
||||
try {
|
||||
const res = await Notification.requestPermission();
|
||||
return res === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBrowserNotificationEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(BROWSER_NOTIF_PREF_KEY, enabled ? 'true' : 'false');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function getBrowserNotificationEnabled() {
|
||||
return browserNotifEnabled();
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
@@ -59,6 +129,7 @@ export const useNotificationStore = defineStore('notifications', {
|
||||
},
|
||||
(payload) => {
|
||||
this.items.unshift(payload.new);
|
||||
fireBrowserNotification(payload.new);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
Reference in New Issue
Block a user