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:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
+407
View File
@@ -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 };
}
}
});
+71
View File
@@ -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();