Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.
This commit is contained in:
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user