Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.

This commit is contained in:
Leonardo
2026-03-18 15:47:37 -03:00
parent d6d2fe29d1
commit 29ed349cf2
21 changed files with 5366 additions and 41 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* Constantes compartilhadas do sistema de Email Templates.
* Importável em Vue components, workers, Edge Functions, etc.
*/
export const TEMPLATE_DOMAINS = {
SESSION: 'session',
INTAKE: 'intake',
BILLING: 'billing',
SYSTEM: 'system',
}
export const TEMPLATE_CHANNELS = {
EMAIL: 'email',
WHATSAPP: 'whatsapp',
SMS: 'sms',
}
export const TEMPLATE_KEYS = {
// Sessões
SESSION_REMINDER: 'session.reminder.email',
SESSION_CONFIRMATION: 'session.confirmation.email',
SESSION_CANCELLATION: 'session.cancellation.email',
SESSION_RESCHEDULED: 'session.rescheduled.email',
// Triagem
INTAKE_RECEIVED: 'intake.received.email',
INTAKE_APPROVED: 'intake.approved.email',
INTAKE_REJECTED: 'intake.rejected.email',
// Agendador online
SCHEDULER_REQUEST_ACCEPTED: 'scheduler.request_accepted.email',
SCHEDULER_REQUEST_REJECTED: 'scheduler.request_rejected.email',
// Sistema
SYSTEM_WELCOME: 'system.welcome.email',
SYSTEM_PASSWORD_RESET: 'system.password_reset.email',
}
/**
* Dados mock realistas para preview de templates na UI.
* Espelham as entidades reais: patients, agenda_eventos, patient_intake_requests.
*/
export const MOCK_DATA = {
session: {
patient_name: 'Ana Clara Mendes',
session_date: '20/03/2026',
session_time: '14:00',
session_modality: 'Online',
session_link: 'https://meet.google.com/abc-defg-hij',
session_address: null,
session_type: 'Sessão de acompanhamento',
therapist_name: 'Dra. Beatriz Costa',
cancellation_reason: null,
rejection_reason: null,
},
intake: {
patient_name: 'Roberto Alves',
clinic_name: 'Espaço Terapêutico Beatriz Costa',
therapist_name: 'Dra. Beatriz Costa',
portal_link: 'https://app.exemplo.com.br/portal',
session_date: '22/03/2026',
session_time: '10:00',
session_type: 'Primeira consulta',
session_modality: 'Presencial',
rejection_reason: null,
},
system: {
patient_name: 'Mariana Souza',
clinic_name: 'Clínica Harmonia',
portal_link: 'https://app.exemplo.com.br/portal',
reset_link: 'https://app.exemplo.com.br/reset-password?token=mock',
},
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Email Template Service — Multi-tenant
*
* Hierarquia de resolução:
* 1. Override do terapeuta (owner_id + tenant_id + key)
* 2. Override do tenant (tenant_id + key, sem owner_id)
* 3. Template global (key, is_active = true)
*
* Merging inteligente: campos null no override herdam do global,
* permitindo sobrescrever só o subject sem reescrever o body.
*/
import { supabase } from '@/lib/supabase/client'
import { MOCK_DATA } from './emailTemplateConstants'
// ─────────────────────────────────────────────
// QUERIES SUPABASE
// ─────────────────────────────────────────────
async function _fetchGlobalTemplate(key) {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.eq('key', key)
.eq('is_active', true)
.single()
if (error && error.code !== 'PGRST116') {
console.error('[EmailTemplates] Erro ao buscar global:', error)
}
return data ?? null
}
async function _fetchAllGlobalTemplates() {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.eq('is_active', true)
.order('domain')
if (error) console.error('[EmailTemplates] Erro ao listar globais:', error)
return data ?? []
}
/**
* Busca override de tenant/owner para uma key.
* Se ownerId fornecido: prioriza override do owner; fallback para tenant geral.
* Se não: busca só o override do tenant (owner_id IS NULL).
*/
async function _fetchTenantTemplate(tenantId, ownerId, key) {
let query = supabase
.from('email_templates_tenant')
.select('*')
.eq('tenant_id', tenantId)
.eq('template_key', key)
.eq('enabled', true)
if (ownerId) {
// Retorna até 2 linhas: a do owner e a do tenant geral
// Ordena owner_id DESC NULLS LAST → owner primeiro
query = query
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('owner_id', { nullsFirst: false })
.limit(1)
} else {
query = query.is('owner_id', null).limit(1)
}
const { data, error } = await query.maybeSingle()
if (error) console.error('[EmailTemplates] Erro ao buscar tenant override:', error)
return data ?? null
}
// ─────────────────────────────────────────────
// MERGE & FALLBACK
// ─────────────────────────────────────────────
function _mergeTemplates(global, override) {
return {
...global,
subject: override.subject ?? global.subject,
body_html: override.body_html ?? global.body_html,
body_text: override.body_text ?? global.body_text,
_source: (override.subject || override.body_html) ? 'tenant' : 'global',
_synced_version: override.synced_version,
_global_version: global.version,
_needs_sync: override.synced_version !== null && override.synced_version < global.version,
}
}
function _fallbackTemplate(key) {
return {
id: null,
key,
domain: 'system',
channel: 'email',
subject: `[${key}] Notificação do sistema`,
body_html: '<p>Você recebeu uma notificação do sistema.</p>',
body_text: 'Você recebeu uma notificação do sistema.',
version: 1,
is_active: true,
variables: {},
_source: 'fallback',
}
}
// ─────────────────────────────────────────────
// RENDERER
// ─────────────────────────────────────────────
/**
* Resolve uma variável com suporte a notação de ponto (patient.name).
*/
function _resolveVariable(key, variables) {
if (!key.includes('.')) return variables[key]
return key.split('.').reduce((obj, part) => obj?.[part], variables)
}
/**
* Renderiza um template substituindo {{variavel}} e blocos {{#if}}.
*
* @param {string} template
* @param {Object} variables
* @returns {string}
*/
export function renderTemplate(template, variables = {}) {
if (!template) return ''
let result = template
// Blocos condicionais {{#if var}}...{{/if}}
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => _resolveVariable(key, variables) ? content : ''
)
// Substituições simples {{variavel}}
result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const value = _resolveVariable(key, variables)
return value !== undefined && value !== null ? String(value) : ''
})
return result
}
function _stripHtml(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ─────────────────────────────────────────────
// LAYOUT — Header / Footer gerado por config
// ─────────────────────────────────────────────
/**
* Gera o HTML inline de header ou footer a partir da config do tenant.
* Usa <table> para máxima compatibilidade com clientes de e-mail.
*
* @param {Object} config - { enabled, show_logo, logo_position, text, text_align }
* @param {string|null} logoUrl
* @param {boolean} isHeader - true = header, false = footer
* @returns {string}
*/
export function generateLayoutSection(config, logoUrl, isHeader = true) {
if (!config?.enabled) return ''
const borderStyle = isHeader
? 'border-bottom:2px solid #e5e7eb;padding-bottom:16px;margin-bottom:20px;'
: 'border-top:1px solid #e5e7eb;padding-top:16px;margin-top:20px;'
const layout = config.layout || null
const text = config.content?.trim() || ''
if (!layout && !text) return ''
const logoImg = logoUrl
? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:block;">`
: ''
let inner = ''
if (layout === 'logo-left') {
inner = `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="width:106px;vertical-align:middle;padding-right:16px;">${logoImg}</td>
<td style="vertical-align:middle;">${text}</td>
</tr>
</table>`
} else if (layout === 'logo-right') {
inner = `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="vertical-align:middle;">${text}</td>
<td style="width:106px;vertical-align:middle;padding-left:16px;text-align:right;">${logoImg}</td>
</tr>
</table>`
} else if (layout === 'logo-center') {
const centeredImg = logoUrl
? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:inline-block;">`
: ''
inner = `<div style="text-align:center;">${centeredImg}<div style="margin-top:8px;">${text}</div></div>`
} else if (layout === 'text-left') {
inner = `<div style="text-align:left;">${text}</div>`
} else if (layout === 'text-center') {
inner = `<div style="text-align:center;">${text}</div>`
} else if (layout === 'text-right') {
inner = `<div style="text-align:right;">${text}</div>`
} else {
inner = text
}
if (!inner.trim()) return ''
return `<div style="${borderStyle}">${inner}</div>`
}
/**
* Renderiza subject e ambos os bodies de um template resolvido.
*
* @param {Object} resolvedTemplate
* @param {Object} variables
* @param {Object} [options] - { headerConfig, footerConfig, logoUrl }
* @returns {{ subject: string, body_html: string, body_text: string }}
*/
export function renderEmail(resolvedTemplate, variables = {}, options = {}) {
const { headerConfig, footerConfig, logoUrl } = options
const headerHtml = generateLayoutSection(headerConfig, logoUrl, true)
const footerHtml = generateLayoutSection(footerConfig, logoUrl, false)
const body_html = renderTemplate(resolvedTemplate.body_html, variables)
const full_html = headerHtml + body_html + footerHtml
return {
subject: renderTemplate(resolvedTemplate.subject, variables),
body_html: full_html,
body_text: resolvedTemplate.body_text
? renderTemplate(resolvedTemplate.body_text, variables)
: _stripHtml(full_html),
}
}
// ─────────────────────────────────────────────
// API PÚBLICA
// ─────────────────────────────────────────────
/**
* Resolve o template correto para um contexto de tenant/owner.
*
* @param {string} templateKey - TEMPLATE_KEYS.*
* @param {{ tenantId?: string, ownerId?: string }} [context]
* @returns {Promise<Object>}
*/
export async function getEmailTemplate(templateKey, context = {}) {
const { tenantId = null, ownerId = null } = context
const global = await _fetchGlobalTemplate(templateKey)
if (!global) {
console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`)
return _fallbackTemplate(templateKey)
}
if (!tenantId) return { ...global, _source: 'global' }
// Busca override (owner tem prioridade sobre tenant geral)
const tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey)
if (!tenantOverride) return { ...global, _source: 'global' }
return _mergeTemplates(global, tenantOverride)
}
/**
* Retorna dados mock para um template key (baseado no domínio).
*/
function _getMockDataForKey(templateKey) {
if (templateKey.startsWith('session') || templateKey.startsWith('scheduler')) return MOCK_DATA.session
if (templateKey.startsWith('intake')) return MOCK_DATA.intake
return MOCK_DATA.system
}
/**
* Gera preview renderizado com dados mockados.
* Uso principal: tela de edição de templates na UI do terapeuta.
*
* @param {string} templateKey
* @param {Object} [mockData] - substitui campos do mock padrão
* @param {Object} [context] - { tenantId, ownerId }
* @returns {Promise<{ subject, body_html, body_text, _meta }>}
*/
export async function previewTemplate(templateKey, mockData = {}, context = {}) {
const template = await getEmailTemplate(templateKey, context)
const variables = { ..._getMockDataForKey(templateKey), ...mockData }
const rendered = renderEmail(template, variables)
return {
...rendered,
_meta: {
key: templateKey,
source: template._source || 'global',
version: template.version,
needs_sync: template._needs_sync || false,
variables_used: Object.keys(variables),
},
}
}
/**
* Lista todos os templates globais com status de override por contexto.
* Útil para a tela de gerenciamento de templates na UI do terapeuta.
*
* @param {Object} [context] - { tenantId, ownerId }
* @returns {Promise<Array>}
*/
export async function listTemplates(context = {}) {
const globals = await _fetchAllGlobalTemplates()
return Promise.all(
globals.map(async (global) => {
const resolved = await getEmailTemplate(global.key, context)
return {
key: global.key,
domain: global.domain,
channel: global.channel,
version: global.version,
is_active: global.is_active,
variables: global.variables,
has_override: resolved._source === 'tenant',
needs_sync: resolved._needs_sync || false,
}
})
)
}
+706
View File
@@ -0,0 +1,706 @@
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ EMAIL TEMPLATE SYSTEM — Multi-tenant SaaS ║
* ║ Compatível com o modelo de dados do sistema terapêutico ║
* ╚══════════════════════════════════════════════════════════════╝
*
* Arquitetura:
* models/ → estrutura de dados e constantes
* renderer/ → processamento de variáveis e renderização
* service/ → lógica de resolução (global → tenant → owner)
* preview/ → geração de preview com dados mockados
*
* Decisões de design:
* - Template keys seguem domínio.ação (session.reminder, intake.confirmation)
* para facilitar agrupamento e expansão futura por módulo.
* - Três camadas de resolução: global → tenant → owner (therapist),
* espelhando como owner_id e tenant_id coexistem no banco.
* - Canal (email/whatsapp/sms) é first-class citizen no modelo,
* pois o sistema já tem modalidade online/presencial.
* - Versionamento preparado para sync futuro sem impactar consumers.
*/
// ─────────────────────────────────────────────
// MODELS — Estrutura de dados
// ─────────────────────────────────────────────
/**
* Domínios de template organizados por módulo do sistema.
* Facilita listar templates disponíveis por área (ex: todos os de 'session').
*/
export const TEMPLATE_DOMAINS = {
SESSION: 'session', // Agendamento e sessões
INTAKE: 'intake', // Triagem e cadastros externos
BILLING: 'billing', // Cobranças e recibos (futuro)
SYSTEM: 'system', // Avisos do sistema, boas-vindas
}
/**
* Canais suportados — preparado para WhatsApp/SMS além de email.
* Templates podem ter variantes por canal com conteúdo diferente.
*/
export const TEMPLATE_CHANNELS = {
EMAIL: 'email',
WHATSAPP: 'whatsapp', // futuro
SMS: 'sms', // futuro
}
/**
* Chaves de template — identificadores únicos por domínio.ação.canal
*
* Estrutura: `{domain}.{action}.{channel}`
* Ex: session.reminder.email, intake.confirmation.whatsapp
*
* Manter centralizado aqui evita typos e facilita descoberta.
*/
export const TEMPLATE_KEYS = {
// Sessões
SESSION_REMINDER: 'session.reminder.email',
SESSION_CONFIRMATION: 'session.confirmation.email',
SESSION_CANCELLATION: 'session.cancellation.email',
SESSION_RESCHEDULED: 'session.rescheduled.email',
// Triagem / Cadastros externos (patient_intake_requests)
INTAKE_RECEIVED: 'intake.received.email',
INTAKE_APPROVED: 'intake.approved.email',
INTAKE_REJECTED: 'intake.rejected.email',
// Solicitações do agendador online (agendador_solicitacoes)
SCHEDULER_REQUEST_ACCEPTED: 'scheduler.request_accepted.email',
SCHEDULER_REQUEST_REJECTED: 'scheduler.request_rejected.email',
// Sistema
SYSTEM_WELCOME: 'system.welcome.email',
SYSTEM_PASSWORD_RESET: 'system.password_reset.email',
}
/**
* Schema de template global (tabela: email_templates_global)
*
* SQL equivalente:
* CREATE TABLE email_templates_global (
* id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
* key TEXT UNIQUE NOT NULL,
* domain TEXT NOT NULL,
* channel TEXT NOT NULL DEFAULT 'email',
* subject TEXT NOT NULL,
* body_html TEXT NOT NULL,
* body_text TEXT,
* version INTEGER NOT NULL DEFAULT 1,
* is_active BOOLEAN NOT NULL DEFAULT true,
* variables JSONB, -- variáveis esperadas com descrição
* created_at TIMESTAMPTZ DEFAULT now(),
* updated_at TIMESTAMPTZ DEFAULT now()
* );
*/
export function createGlobalTemplate(overrides = {}) {
return {
id: null,
key: '',
domain: TEMPLATE_DOMAINS.SYSTEM,
channel: TEMPLATE_CHANNELS.EMAIL,
subject: '',
body_html: '',
body_text: null,
version: 1,
is_active: true,
variables: {}, // { patient_name: 'Nome do paciente', ... }
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}
}
/**
* Schema de override por tenant (tabela: email_templates_tenant)
*
* SQL equivalente:
* CREATE TABLE email_templates_tenant (
* id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
* tenant_id UUID NOT NULL REFERENCES tenants(id),
* owner_id UUID REFERENCES auth.users(id), -- override por terapeuta específico
* template_key TEXT NOT NULL,
* subject TEXT, -- null = usar global
* body_html TEXT, -- null = usar global
* body_text TEXT,
* enabled BOOLEAN NOT NULL DEFAULT true,
* synced_version INTEGER, -- versão global que foi base deste override
* created_at TIMESTAMPTZ DEFAULT now(),
* updated_at TIMESTAMPTZ DEFAULT now(),
* UNIQUE (tenant_id, owner_id, template_key)
* );
*/
export function createTenantTemplate(overrides = {}) {
return {
id: null,
tenant_id: null,
owner_id: null, // null = vale para todo o tenant; preenchido = só para esse terapeuta
template_key: '',
subject: null, // null = herda do global
body_html: null, // null = herda do global
body_text: null,
enabled: true,
synced_version: null, // preparado para sync futuro
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}
}
// ─────────────────────────────────────────────
// GLOBAL TEMPLATE STORE
// Simula o banco de dados. Em produção, substituir pelas queries Supabase.
// ─────────────────────────────────────────────
export const globalTemplates = [
createGlobalTemplate({
key: TEMPLATE_KEYS.SESSION_REMINDER,
domain: TEMPLATE_DOMAINS.SESSION,
subject: 'Lembrete: sua sessão amanhã às {{session_time}}',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Este é um lembrete da sua sessão agendada para <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong>.</p>
<p>Modalidade: <strong>{{session_modality}}</strong></p>
{{#if session_link}}
<p><a href="{{session_link}}">Clique aqui para entrar na sessão online</a></p>
{{/if}}
<p>Em caso de necessidade de cancelamento, entre em contato com antecedência.</p>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
`,
body_text: `Olá, {{patient_name}}! Lembrete da sua sessão: {{session_date}} às {{session_time}} ({{session_modality}}).`,
variables: {
patient_name: 'Nome completo do paciente',
session_date: 'Data da sessão (ex: 20/03/2026)',
session_time: 'Horário da sessão (ex: 14:00)',
session_modality: 'Presencial ou Online',
session_link: 'Link da videochamada (apenas online)',
therapist_name: 'Nome do terapeuta',
},
version: 2,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.SESSION_CONFIRMATION,
domain: TEMPLATE_DOMAINS.SESSION,
subject: 'Sessão confirmada — {{session_date}} às {{session_time}}',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua sessão foi confirmada com sucesso.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
{{#if session_address}}<li><strong>Local:</strong> {{session_address}}</li>{{/if}}
</ul>
<p>Até lá,<br><strong>{{therapist_name}}</strong></p>
`,
body_text: `Sessão confirmada: {{session_date}} às {{session_time}} ({{session_modality}}).`,
variables: {
patient_name: 'Nome do paciente',
session_date: 'Data da sessão',
session_time: 'Horário da sessão',
session_modality: 'Presencial ou Online',
session_address: 'Endereço (apenas presencial)',
therapist_name: 'Nome do terapeuta',
},
version: 1,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.SESSION_CANCELLATION,
domain: TEMPLATE_DOMAINS.SESSION,
subject: 'Sessão cancelada — {{session_date}}',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Informamos que sua sessão do dia <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong> foi cancelada.</p>
{{#if cancellation_reason}}<p>Motivo: {{cancellation_reason}}</p>{{/if}}
<p>Entre em contato para reagendar.</p>
<p><strong>{{therapist_name}}</strong></p>
`,
variables: {
patient_name: 'Nome do paciente',
session_date: 'Data cancelada',
session_time: 'Horário cancelado',
cancellation_reason: 'Motivo do cancelamento (opcional)',
therapist_name: 'Nome do terapeuta',
},
version: 1,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.INTAKE_RECEIVED,
domain: TEMPLATE_DOMAINS.INTAKE,
subject: 'Recebemos seu cadastro — {{clinic_name}}',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Recebemos seu cadastro com sucesso. Nossa equipe entrará em contato em breve para dar continuidade ao processo.</p>
<p>Obrigado pela confiança,<br><strong>{{clinic_name}}</strong></p>
`,
variables: {
patient_name: 'Nome do solicitante',
clinic_name: 'Nome da clínica ou terapeuta',
},
version: 1,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.INTAKE_APPROVED,
domain: TEMPLATE_DOMAINS.INTAKE,
subject: 'Cadastro aprovado — bem-vindo(a)!',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seu cadastro foi aprovado. Você já pode acessar o portal e agendar sua primeira sessão.</p>
<p><a href="{{portal_link}}">Acessar portal →</a></p>
<p>Qualquer dúvida, estamos à disposição.<br><strong>{{therapist_name}}</strong></p>
`,
variables: {
patient_name: 'Nome do paciente',
therapist_name:'Nome do terapeuta',
portal_link: 'Link do portal do paciente',
},
version: 1,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.SCHEDULER_REQUEST_ACCEPTED,
domain: TEMPLATE_DOMAINS.SESSION,
subject: 'Sua solicitação foi aceita — {{session_date}} às {{session_time}}',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua solicitação de agendamento foi aceita.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Tipo:</strong> {{session_type}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
</ul>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
`,
variables: {
patient_name: 'Nome do paciente',
session_date: 'Data confirmada',
session_time: 'Horário confirmado',
session_type: 'Primeira consulta / Retorno',
session_modality: 'Presencial ou Online',
therapist_name: 'Nome do terapeuta',
},
version: 1,
}),
createGlobalTemplate({
key: TEMPLATE_KEYS.SYSTEM_WELCOME,
domain: TEMPLATE_DOMAINS.SYSTEM,
subject: 'Bem-vindo(a) ao {{clinic_name}}!',
body_html: `
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seja bem-vindo(a)! Sua conta foi criada com sucesso.</p>
<p><a href="{{portal_link}}">Acessar minha área →</a></p>
`,
variables: {
patient_name: 'Nome do paciente',
clinic_name: 'Nome da clínica',
portal_link: 'Link do portal',
},
version: 1,
}),
]
// ─────────────────────────────────────────────
// RENDERER — Processamento de variáveis
// ─────────────────────────────────────────────
/**
* Renderiza um template substituindo {{variavel}} pelos valores fornecidos.
*
* Recursos:
* - {{variavel}} → substituição simples
* - {{#if variavel}}...{{/if}} → bloco condicional (omitido se falsy)
* - Variáveis ausentes são removidas silenciosamente (sem quebrar)
* - HTML-safe: não escapa o conteúdo (responsabilidade do caller)
*
* @param {string} template - HTML/texto com placeholders
* @param {Object} variables - par chave-valor das variáveis
* @returns {string}
*/
export function renderTemplate(template, variables = {}) {
if (!template) return ''
let result = template
// 1. Processar blocos condicionais {{#if var}}...{{/if}}
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => {
const value = resolveVariable(key, variables)
return value ? content : ''
}
)
// 2. Substituir {{variavel}} simples
result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const value = resolveVariable(key, variables)
// Variável ausente: remover o placeholder silenciosamente
return value !== undefined && value !== null ? String(value) : ''
})
return result
}
/**
* Resolve uma variável com suporte a notação de ponto (ex: patient.name).
* Permite passar objetos aninhados como variáveis.
*/
function resolveVariable(key, variables) {
if (!key.includes('.')) return variables[key]
return key.split('.').reduce((obj, part) => obj?.[part], variables)
}
/**
* Renderiza subject e body de um template resolvido.
* Retorna um objeto pronto para envio.
*
* @param {Object} resolvedTemplate - template já resolvido (global ou tenant)
* @param {Object} variables - variáveis dinâmicas
* @returns {{ subject: string, body_html: string, body_text: string }}
*/
export function renderEmail(resolvedTemplate, variables = {}) {
return {
subject: renderTemplate(resolvedTemplate.subject, variables),
body_html: renderTemplate(resolvedTemplate.body_html, variables),
body_text: resolvedTemplate.body_text
? renderTemplate(resolvedTemplate.body_text, variables)
: stripHtml(renderTemplate(resolvedTemplate.body_html, variables)),
}
}
/**
* Fallback simples para gerar body_text a partir do HTML.
*/
function stripHtml(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ─────────────────────────────────────────────
// SERVICE — Lógica de resolução
// ─────────────────────────────────────────────
/**
* REPOSITÓRIO — Em produção, substituir por queries Supabase reais.
*
* Exemplo de implementação real com Supabase:
*
* async function fetchGlobalTemplate(key) {
* const { data } = await supabase
* .from('email_templates_global')
* .select('*')
* .eq('key', key)
* .eq('is_active', true)
* .single()
* return data
* }
*
* async function fetchTenantTemplate(tenantId, ownerId, key) {
* let query = supabase
* .from('email_templates_tenant')
* .select('*')
* .eq('tenant_id', tenantId)
* .eq('template_key', key)
* .eq('enabled', true)
* // Prioriza override do terapeuta específico; fallback para tenant geral
* if (ownerId) {
* query = query.or(`owner_id.eq.${ownerId},owner_id.is.null`)
* .order('owner_id', { nullsFirst: false })
* } else {
* query = query.is('owner_id', null)
* }
* const { data } = await query.limit(1).single()
* return data
* }
*/
// Store in-memory de overrides para simulação
const tenantOverridesStore = []
/**
* Registra um override de tenant (usado para testes e simulação).
* Em produção, inserir diretamente na tabela email_templates_tenant.
*/
export function registerTenantOverride(override) {
tenantOverridesStore.push(createTenantTemplate(override))
}
/**
* RESOLUÇÃO CENTRAL — getEmailTemplate
*
* Hierarquia de resolução (da mais específica à mais genérica):
* 1. Override do terapeuta (owner_id + tenant_id + key + enabled)
* 2. Override do tenant (tenant_id + key + enabled, sem owner_id)
* 3. Template global (key + is_active)
*
* Merging inteligente: campos null no override herdam do global.
* Isso permite sobrescrever apenas o subject sem reescrever o body.
*
* @param {string} templateKey - chave do template (TEMPLATE_KEYS.*)
* @param {Object} [context] - { tenantId, ownerId }
* @returns {Object|null} - template mesclado pronto para renderização
*/
export async function getEmailTemplate(templateKey, context = {}) {
const { tenantId = null, ownerId = null } = context
// 1. Buscar global (sempre necessário como base)
const global = await _fetchGlobalTemplate(templateKey)
if (!global) {
console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`)
return _fallbackTemplate(templateKey)
}
// 2. Sem contexto de tenant → retornar global direto
if (!tenantId) return global
// 3. Buscar override do terapeuta específico (prioridade máxima)
let tenantOverride = null
if (ownerId) {
tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey)
}
// 4. Se não encontrou override do terapeuta, buscar override do tenant
if (!tenantOverride) {
tenantOverride = await _fetchTenantTemplate(tenantId, null, templateKey)
}
// 5. Sem override → usar global
if (!tenantOverride) return global
// 6. Merge: override + global como fallback por campo
return _mergeTemplates(global, tenantOverride)
}
/**
* Merge inteligente: campos null no override herdam do global.
* Preserva a versão do global para rastreamento de sincronização.
*/
function _mergeTemplates(global, override) {
return {
...global,
subject: override.subject ?? global.subject,
body_html: override.body_html ?? global.body_html,
body_text: override.body_text ?? global.body_text,
_source: override.subject || override.body_html ? 'tenant' : 'global',
_synced_version: override.synced_version,
_global_version: global.version,
// Flag para UI: indicar se tenant está desatualizado vs global
_needs_sync: override.synced_version !== null && override.synced_version < global.version,
}
}
/**
* Fallback de segurança — nunca retornar template vazio.
* Garante que o sistema nunca quebre por template faltando.
*/
function _fallbackTemplate(key) {
return createGlobalTemplate({
key,
subject: `[${key}] Notificação do sistema`,
body_html: `<p>Você recebeu uma notificação do sistema.</p>`,
body_text: 'Você recebeu uma notificação do sistema.',
_source: 'fallback',
})
}
// Implementações locais (simulação do banco)
async function _fetchGlobalTemplate(key) {
return globalTemplates.find(t => t.key === key && t.is_active) || null
}
async function _fetchTenantTemplate(tenantId, ownerId, key) {
return tenantOverridesStore.find(t =>
t.tenant_id === tenantId &&
t.owner_id === ownerId &&
t.template_key === key &&
t.enabled
) || null
}
// ─────────────────────────────────────────────
// PREVIEW — Dados mockados por domínio
// ─────────────────────────────────────────────
/**
* Dados de mock realistas para o contexto do sistema terapêutico.
* Espelham as entidades reais: patients, agenda_eventos, recurrence_rules.
*/
export const MOCK_DATA = {
// Espelha: patients + agenda_eventos
session: {
patient_name: 'Ana Clara Mendes',
session_date: '20/03/2026',
session_time: '14:00',
session_modality: 'Online',
session_link: 'https://meet.google.com/abc-defg-hij',
session_address: null,
session_type: 'Sessão de acompanhamento',
therapist_name: 'Dra. Beatriz Costa',
cancellation_reason: null,
},
// Espelha: patient_intake_requests + agendador_solicitacoes
intake: {
patient_name: 'Roberto Alves',
clinic_name: 'Espaço Terapêutico Beatriz Costa',
therapist_name:'Dra. Beatriz Costa',
portal_link: 'https://app.exemplo.com.br/portal',
session_date: '22/03/2026',
session_time: '10:00',
session_type: 'Primeira consulta',
session_modality: 'Presencial',
},
// Sistema
system: {
patient_name: 'Mariana Souza',
clinic_name: 'Clínica Harmonia',
portal_link: 'https://app.exemplo.com.br/portal',
},
}
/**
* Retorna dados mock adequados para um template key.
* Determina o domínio a partir do prefixo da key.
*/
function getMockDataForKey(templateKey) {
if (templateKey.startsWith('session') || templateKey.startsWith('scheduler')) return MOCK_DATA.session
if (templateKey.startsWith('intake')) return MOCK_DATA.intake
return MOCK_DATA.system
}
/**
* previewTemplate — Gera preview HTML com dados mockados.
*
* Uso principal: tela de edição de templates na UI do terapeuta.
*
* @param {string} templateKey - chave do template
* @param {Object} [mockData] - dados de override (sobrescreve o mock padrão)
* @param {Object} [context] - { tenantId, ownerId } para resolução correta
* @returns {Promise<{ subject, body_html, body_text, _meta }>}
*/
export async function previewTemplate(templateKey, mockData = {}, context = {}) {
const template = await getEmailTemplate(templateKey, context)
const variables = { ...getMockDataForKey(templateKey), ...mockData }
const rendered = renderEmail(template, variables)
return {
...rendered,
_meta: {
key: templateKey,
source: template._source || 'global',
version: template.version,
needs_sync: template._needs_sync || false,
variables_used: Object.keys(variables),
},
}
}
/**
* listTemplates — Lista todos os templates disponíveis, com status por tenant.
*
* Útil para a tela de gerenciamento de templates na UI.
*
* @param {Object} [context] - { tenantId, ownerId }
* @returns {Promise<Array>}
*/
export async function listTemplates(context = {}) {
return Promise.all(
globalTemplates.map(async (global) => {
const resolved = await getEmailTemplate(global.key, context)
return {
key: global.key,
domain: global.domain,
channel: global.channel,
version: global.version,
is_active: global.is_active,
has_override: resolved._source === 'tenant',
needs_sync: resolved._needs_sync || false,
variables: global.variables,
}
})
)
}
// ─────────────────────────────────────────────
// EXEMPLO DE USO — Fluxo completo
// ─────────────────────────────────────────────
async function exemploDeUso() {
console.log('═══ EXEMPLO 1: Template global (sem override) ═══')
const templateGlobal = await getEmailTemplate(TEMPLATE_KEYS.SESSION_REMINDER)
const emailGlobal = renderEmail(templateGlobal, MOCK_DATA.session)
console.log('Subject:', emailGlobal.subject)
console.log('Body Text:', emailGlobal.body_text)
// ─────────────────────────────────
console.log('\n═══ EXEMPLO 2: Override de tenant (apenas subject) ═══')
registerTenantOverride({
tenant_id: 'tenant-abc',
owner_id: null,
template_key: TEMPLATE_KEYS.SESSION_REMINDER,
subject: '⏰ Lembrete especial: sessão amanhã às {{session_time}} — {{patient_name}}',
body_html: null, // null = herda do global
synced_version: 2,
})
const templateTenant = await getEmailTemplate(
TEMPLATE_KEYS.SESSION_REMINDER,
{ tenantId: 'tenant-abc' }
)
const emailTenant = renderEmail(templateTenant, MOCK_DATA.session)
console.log('Subject:', emailTenant.subject) // → subject customizado
console.log('Source:', templateTenant._source) // → 'tenant'
// ─────────────────────────────────
console.log('\n═══ EXEMPLO 3: Override por terapeuta específico ═══')
registerTenantOverride({
tenant_id: 'tenant-abc',
owner_id: 'owner-dra-beatriz',
template_key: TEMPLATE_KEYS.SESSION_REMINDER,
subject: 'Dra. Beatriz — lembrete personalizado para {{patient_name}}',
body_html: '<p>Olá {{patient_name}}, sou a Dra. Beatriz. Até amanhã! 🌿</p>',
synced_version: 2,
})
const templateOwner = await getEmailTemplate(
TEMPLATE_KEYS.SESSION_REMINDER,
{ tenantId: 'tenant-abc', ownerId: 'owner-dra-beatriz' }
)
const emailOwner = renderEmail(templateOwner, MOCK_DATA.session)
console.log('Subject:', emailOwner.subject) // → override do owner
console.log('Source:', templateOwner._source) // → 'tenant'
// ─────────────────────────────────
console.log('\n═══ EXEMPLO 4: Preview com dados mockados ═══')
const preview = await previewTemplate(TEMPLATE_KEYS.INTAKE_RECEIVED)
console.log('Preview Subject:', preview.subject)
console.log('Preview Meta:', preview._meta)
// ─────────────────────────────────
console.log('\n═══ EXEMPLO 5: Listar todos os templates ═══')
const lista = await listTemplates({ tenantId: 'tenant-abc' })
lista.forEach(t => {
const status = t.has_override ? '[OVERRIDE]' : '[GLOBAL]'
const sync = t.needs_sync ? ' ⚠️ desatualizado' : ''
console.log(`${status} ${t.key}${sync}`)
})
// ─────────────────────────────────
console.log('\n═══ EXEMPLO 6: Template inexistente (fallback seguro) ═══')
const fallback = await getEmailTemplate('chave.que.nao.existe.email')
console.log('Fallback source:', fallback._source) // → 'fallback'
console.log('Nunca vazio:', !!fallback.subject) // → true
}
exemploDeUso().catch(console.error)