Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+55 -55
View File
@@ -20,71 +20,71 @@
*/
export const TEMPLATE_DOMAINS = {
SESSION: 'session',
INTAKE: 'intake',
BILLING: 'billing',
SYSTEM: 'system',
}
SESSION: 'session',
INTAKE: 'intake',
BILLING: 'billing',
SYSTEM: 'system'
};
export const TEMPLATE_CHANNELS = {
EMAIL: 'email',
WHATSAPP: 'whatsapp',
SMS: 'sms',
}
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',
// 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',
// 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',
// 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',
}
// 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',
},
}
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'
}
};
+142 -170
View File
@@ -26,36 +26,27 @@
* permitindo sobrescrever só o subject sem reescrever o body.
*/
import { supabase } from '@/lib/supabase/client'
import { MOCK_DATA } from './emailTemplateConstants'
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()
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
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')
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 ?? []
if (error) console.error('[EmailTemplates] Erro ao listar globais:', error);
return data ?? [];
}
/**
@@ -64,27 +55,19 @@ async function _fetchAllGlobalTemplates() {
* 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)
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)
}
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
const { data, error } = await query.maybeSingle();
if (error) console.error('[EmailTemplates] Erro ao buscar tenant override:', error);
return data ?? null;
}
// ─────────────────────────────────────────────
@@ -92,32 +75,32 @@ async function _fetchTenantTemplate(tenantId, ownerId, key) {
// ─────────────────────────────────────────────
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,
}
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',
}
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'
};
}
// ─────────────────────────────────────────────
@@ -128,8 +111,8 @@ function _fallbackTemplate(key) {
* 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)
if (!key.includes('.')) return variables[key];
return key.split('.').reduce((obj, part) => obj?.[part], variables);
}
/**
@@ -140,32 +123,29 @@ function _resolveVariable(key, variables) {
* @returns {string}
*/
export function renderTemplate(template, variables = {}) {
if (!template) return ''
if (!template) return '';
let result = template
let result = template;
// Blocos condicionais {{#if var}}...{{/if}}
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => _resolveVariable(key, variables) ? content : ''
)
// 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) : ''
})
// Substituições simples {{variavel}}
result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const value = _resolveVariable(key, variables);
return value !== undefined && value !== null ? String(value) : '';
});
return result
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()
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// ─────────────────────────────────────────────
@@ -182,54 +162,48 @@ function _stripHtml(html) {
* @returns {string}
*/
export function generateLayoutSection(config, logoUrl, isHeader = true) {
if (!config?.enabled) return ''
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 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() || ''
const layout = config.layout || null;
const text = config.content?.trim() || '';
if (!layout && !text) return ''
if (!layout && !text) return '';
const logoImg = logoUrl
? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:block;">`
: ''
const logoImg = logoUrl ? `<img src="${logoUrl}" style="max-width:90px;max-height:56px;object-fit:contain;display:block;">` : '';
let inner = ''
let inner = '';
if (layout === 'logo-left') {
inner = `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
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;">
</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
}
</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>`
if (!inner.trim()) return '';
return `<div style="${borderStyle}">${inner}</div>`;
}
/**
@@ -241,20 +215,18 @@ export function generateLayoutSection(config, logoUrl, isHeader = true) {
* @returns {{ subject: string, body_html: string, body_text: string }}
*/
export function renderEmail(resolvedTemplate, variables = {}, options = {}) {
const { headerConfig, footerConfig, logoUrl } = 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
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),
}
return {
subject: renderTemplate(resolvedTemplate.subject, variables),
body_html: full_html,
body_text: resolvedTemplate.body_text ? renderTemplate(resolvedTemplate.body_text, variables) : _stripHtml(full_html)
};
}
// ─────────────────────────────────────────────
@@ -269,32 +241,32 @@ export function renderEmail(resolvedTemplate, variables = {}, options = {}) {
* @returns {Promise<Object>}
*/
export async function getEmailTemplate(templateKey, context = {}) {
const { tenantId = null, ownerId = null } = context
const { tenantId = null, ownerId = null } = context;
const global = await _fetchGlobalTemplate(templateKey)
const global = await _fetchGlobalTemplate(templateKey);
if (!global) {
console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`)
return _fallbackTemplate(templateKey)
}
if (!global) {
console.warn(`[EmailTemplates] Template não encontrado: ${templateKey}`);
return _fallbackTemplate(templateKey);
}
if (!tenantId) return { ...global, _source: 'global' }
if (!tenantId) return { ...global, _source: 'global' };
// Busca override (owner tem prioridade sobre tenant geral)
const tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey)
// Busca override (owner tem prioridade sobre tenant geral)
const tenantOverride = await _fetchTenantTemplate(tenantId, ownerId, templateKey);
if (!tenantOverride) return { ...global, _source: 'global' }
if (!tenantOverride) return { ...global, _source: 'global' };
return _mergeTemplates(global, tenantOverride)
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
if (templateKey.startsWith('session') || templateKey.startsWith('scheduler')) return MOCK_DATA.session;
if (templateKey.startsWith('intake')) return MOCK_DATA.intake;
return MOCK_DATA.system;
}
/**
@@ -307,20 +279,20 @@ function _getMockDataForKey(templateKey) {
* @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)
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),
},
}
return {
...rendered,
_meta: {
key: templateKey,
source: template._source || 'global',
version: template.version,
needs_sync: template._needs_sync || false,
variables_used: Object.keys(variables)
}
};
}
/**
@@ -331,21 +303,21 @@ export async function previewTemplate(templateKey, mockData = {}, context = {})
* @returns {Promise<Array>}
*/
export async function listTemplates(context = {}) {
const globals = await _fetchAllGlobalTemplates()
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,
}
})
)
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
};
})
);
}
+335 -351
View File
@@ -45,21 +45,21 @@
* 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
}
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
}
EMAIL: 'email',
WHATSAPP: 'whatsapp', // futuro
SMS: 'sms' // futuro
};
/**
* Chaves de template — identificadores únicos por domínio.ação.canal
@@ -70,25 +70,25 @@ export const TEMPLATE_CHANNELS = {
* 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',
// 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',
// 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',
// 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',
}
// Sistema
SYSTEM_WELCOME: 'system.welcome.email',
SYSTEM_PASSWORD_RESET: 'system.password_reset.email'
};
/**
* Schema de template global (tabela: email_templates_global)
@@ -110,21 +110,21 @@ export const TEMPLATE_KEYS = {
* );
*/
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,
}
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
};
}
/**
@@ -147,20 +147,20 @@ export function createGlobalTemplate(overrides = {}) {
* );
*/
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,
}
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
};
}
// ─────────────────────────────────────────────
@@ -169,11 +169,11 @@ export function createTenantTemplate(overrides = {}) {
// ─────────────────────────────────────────────
export const globalTemplates = [
createGlobalTemplate({
key: TEMPLATE_KEYS.SESSION_REMINDER,
domain: TEMPLATE_DOMAINS.SESSION,
subject: 'Lembrete: sua sessão amanhã às {{session_time}}',
body_html: `
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>
@@ -183,23 +183,23 @@ export const globalTemplates = [
<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,
}),
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: `
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>
@@ -210,78 +210,78 @@ export const globalTemplates = [
</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,
}),
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: `
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,
}),
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: `
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,
}),
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: `
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,
}),
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: `
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>
@@ -292,34 +292,34 @@ export const globalTemplates = [
</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,
}),
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: `
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,
}),
]
variables: {
patient_name: 'Nome do paciente',
clinic_name: 'Nome da clínica',
portal_link: 'Link do portal'
},
version: 1
})
];
// ─────────────────────────────────────────────
// RENDERER — Processamento de variáveis
@@ -339,27 +339,24 @@ export const globalTemplates = [
* @returns {string}
*/
export function renderTemplate(template, variables = {}) {
if (!template) return ''
if (!template) return '';
let result = template
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 : ''
}
)
// 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) : ''
})
// 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
return result;
}
/**
@@ -367,8 +364,8 @@ export function renderTemplate(template, variables = {}) {
* 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)
if (!key.includes('.')) return variables[key];
return key.split('.').reduce((obj, part) => obj?.[part], variables);
}
/**
@@ -380,25 +377,23 @@ function resolveVariable(key, variables) {
* @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)),
}
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()
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// ─────────────────────────────────────────────
@@ -440,14 +435,14 @@ function stripHtml(html) {
*/
// Store in-memory de overrides para simulação
const tenantOverridesStore = []
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))
tenantOverridesStore.push(createTenantTemplate(override));
}
/**
@@ -466,35 +461,35 @@ export function registerTenantOverride(override) {
* @returns {Object|null} - template mesclado pronto para renderização
*/
export async function getEmailTemplate(templateKey, context = {}) {
const { tenantId = null, ownerId = null } = context
const { tenantId = null, ownerId = null } = context;
// 1. Buscar global (sempre necessário como base)
const global = await _fetchGlobalTemplate(templateKey)
// 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)
}
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
// 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)
}
// 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)
}
// 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
// 5. Sem override → usar global
if (!tenantOverride) return global;
// 6. Merge: override + global como fallback por campo
return _mergeTemplates(global, tenantOverride)
// 6. Merge: override + global como fallback por campo
return _mergeTemplates(global, tenantOverride);
}
/**
@@ -502,17 +497,17 @@ export async function getEmailTemplate(templateKey, context = {}) {
* 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,
}
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
};
}
/**
@@ -520,27 +515,22 @@ function _mergeTemplates(global, override) {
* 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',
})
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
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
return tenantOverridesStore.find((t) => t.tenant_id === tenantId && t.owner_id === ownerId && t.template_key === key && t.enabled) || null;
}
// ─────────────────────────────────────────────
@@ -552,47 +542,47 @@ async function _fetchTenantTemplate(tenantId, ownerId, key) {
* 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: 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',
},
// 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',
},
}
// 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
if (templateKey.startsWith('session') || templateKey.startsWith('scheduler')) return MOCK_DATA.session;
if (templateKey.startsWith('intake')) return MOCK_DATA.intake;
return MOCK_DATA.system;
}
/**
@@ -606,20 +596,20 @@ function getMockDataForKey(templateKey) {
* @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)
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),
},
}
return {
...rendered,
_meta: {
key: templateKey,
source: template._source || 'global',
version: template.version,
needs_sync: template._needs_sync || false,
variables_used: Object.keys(variables)
}
};
}
/**
@@ -631,21 +621,21 @@ export async function previewTemplate(templateKey, mockData = {}, context = {})
* @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,
}
})
)
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
};
})
);
}
// ─────────────────────────────────────────────
@@ -653,70 +643,64 @@ export async function listTemplates(context = {}) {
// ─────────────────────────────────────────────
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('═══ 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,
})
// ─────────────────────────────────
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'
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,
})
// ─────────────────────────────────
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'
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 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 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
// ─────────────────────────────────
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)
exemploDeUso().catch(console.error);