feat: sistema completo de notificacoes por e-mail

Implementa sistema de notificacoes por e-mail com:

- Notificacoes de ciclo de vida (abertura, resolucao, atribuicao, status)
- Sistema de avaliacao de chamados com estrelas (1-5)
- Deep linking via protocolo raven:// para abrir chamados no desktop
- Tokens de acesso seguro para visualizacao sem login
- Preferencias de notificacao configuraveis por usuario
- Templates HTML responsivos com design tokens da plataforma
- API completa para preferencias, tokens e avaliacoes

Modelos Prisma:
- TicketRating: avaliacoes de chamados
- TicketAccessToken: tokens de acesso direto
- NotificationPreferences: preferencias por usuario

Turbopack como bundler padrao (Next.js 16)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 20:45:37 -03:00
parent cb6add1a4a
commit f2c0298285
23 changed files with 4387 additions and 9 deletions

View file

@ -0,0 +1,767 @@
/**
* Sistema de Templates de E-mail
* Sistema de Chamados Raven
*/
// ============================================
// Tipos
// ============================================
export type TemplateName =
| "test"
| "ticket_created"
| "ticket_resolved"
| "ticket_assigned"
| "ticket_status"
| "ticket_comment"
| "password_reset"
| "email_verify"
| "invite"
| "new_login"
| "sla_warning"
| "sla_breached"
export type TemplateData = Record<string, unknown>
// ============================================
// Design Tokens
// ============================================
const COLORS = {
// Primárias
primary: "#00e8ff",
primaryDark: "#00c4d6",
primaryForeground: "#020617",
// Background
background: "#f7f8fb",
card: "#ffffff",
border: "#e2e8f0",
// Texto
textPrimary: "#0f172a",
textSecondary: "#475569",
textMuted: "#64748b",
// Status
statusPending: "#64748b",
statusPendingBg: "#f1f5f9",
statusProgress: "#0ea5e9",
statusProgressBg: "#e0f2fe",
statusPaused: "#f59e0b",
statusPausedBg: "#fef3c7",
statusResolved: "#10b981",
statusResolvedBg: "#d1fae5",
// Prioridade
priorityLow: "#64748b",
priorityLowBg: "#f1f5f9",
priorityMedium: "#0a4760",
priorityMediumBg: "#dff1fb",
priorityHigh: "#7d3b05",
priorityHighBg: "#fde8d1",
priorityUrgent: "#8b0f1c",
priorityUrgentBg: "#fbd9dd",
// Estrelas
starActive: "#fbbf24",
starInactive: "#d1d5db",
}
// ============================================
// Helpers
// ============================================
function escapeHtml(str: unknown): string {
if (str === null || str === undefined) return ""
const s = String(str)
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function formatDate(date: Date | string | number, options?: Intl.DateTimeFormatOptions): string {
const d = date instanceof Date ? date : new Date(date)
return d.toLocaleDateString("pt-BR", {
timeZone: "America/Sao_Paulo",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
...options,
})
}
// ============================================
// Componentes de E-mail
// ============================================
function getStatusStyle(status: string): { bg: string; color: string; label: string } {
const statusMap: Record<string, { bg: string; color: string; label: string }> = {
PENDING: { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: "Pendente" },
AWAITING_ATTENDANCE: { bg: COLORS.statusProgressBg, color: COLORS.statusProgress, label: "Em andamento" },
PAUSED: { bg: COLORS.statusPausedBg, color: COLORS.statusPaused, label: "Pausado" },
RESOLVED: { bg: COLORS.statusResolvedBg, color: COLORS.statusResolved, label: "Resolvido" },
}
return statusMap[status] ?? { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: status }
}
function getPriorityStyle(priority: string): { bg: string; color: string; label: string } {
const priorityMap: Record<string, { bg: string; color: string; label: string }> = {
LOW: { bg: COLORS.priorityLowBg, color: COLORS.priorityLow, label: "Baixa" },
MEDIUM: { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: "Média" },
HIGH: { bg: COLORS.priorityHighBg, color: COLORS.priorityHigh, label: "Alta" },
URGENT: { bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent, label: "Urgente" },
}
return priorityMap[priority] ?? { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: priority }
}
function badge(label: string, bg: string, color: string): string {
return `<span style="display:inline-block;padding:4px 12px;border-radius:16px;font-size:12px;font-weight:500;background:${bg};color:${color};">${escapeHtml(label)}</span>`
}
function statusBadge(status: string): string {
const style = getStatusStyle(status)
return badge(style.label, style.bg, style.color)
}
function priorityBadge(priority: string): string {
const style = getPriorityStyle(priority)
return badge(style.label, style.bg, style.color)
}
function button(label: string, url: string, variant: "primary" | "secondary" = "primary"): string {
const bg = variant === "primary" ? COLORS.primary : COLORS.card
const color = variant === "primary" ? COLORS.primaryForeground : COLORS.textPrimary
const border = variant === "primary" ? COLORS.primary : COLORS.border
return `<a href="${escapeHtml(url)}" style="display:inline-block;padding:12px 24px;background:${bg};color:${color};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;border:1px solid ${border};">${escapeHtml(label)}</a>`
}
function ticketInfoCard(data: {
reference: number | string
subject: string
status?: string
priority?: string
requesterName?: string
assigneeName?: string
createdAt?: Date | string
}): string {
const rows: string[] = []
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Chamado</td>
<td style="color:${COLORS.textPrimary};font-size:14px;font-weight:600;padding:4px 0;">#${escapeHtml(data.reference)}</td>
</tr>
`)
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Assunto</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.subject)}</td>
</tr>
`)
if (data.status) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Status</td>
<td style="padding:4px 0;">${statusBadge(data.status)}</td>
</tr>
`)
}
if (data.priority) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Prioridade</td>
<td style="padding:4px 0;">${priorityBadge(data.priority)}</td>
</tr>
`)
}
if (data.requesterName) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Solicitante</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.requesterName)}</td>
</tr>
`)
}
if (data.assigneeName) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Responsável</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.assigneeName)}</td>
</tr>
`)
}
if (data.createdAt) {
rows.push(`
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Criado em</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${formatDate(data.createdAt)}</td>
</tr>
`)
}
return `
<table width="100%" cellpadding="0" cellspacing="0" style="background:${COLORS.background};border-radius:8px;margin:16px 0;">
<tr>
<td style="padding:16px;">
<table width="100%" cellpadding="0" cellspacing="0">
${rows.join("")}
</table>
</td>
</tr>
</table>
`
}
function ratingStars(rateUrl: string): string {
const stars: string[] = []
for (let i = 1; i <= 5; i++) {
stars.push(`
<td style="padding:0 4px;">
<a href="${escapeHtml(rateUrl)}?rating=${i}" style="text-decoration:none;font-size:28px;color:${COLORS.starActive};">&#9733;</a>
</td>
`)
}
return `
<table cellpadding="0" cellspacing="0" style="margin:16px 0;">
<tr>
${stars.join("")}
</tr>
</table>
<p style="color:${COLORS.textMuted};font-size:12px;margin:4px 0 0 0;">Clique em uma estrela para avaliar</p>
`
}
// ============================================
// Template Base
// ============================================
function baseTemplate(content: string, data: TemplateData): string {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
const preferencesUrl = `${appUrl}/settings/notifications`
const helpUrl = `${appUrl}/help`
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${escapeHtml(data.subject ?? "Notificação")}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin:0;padding:0;background:${COLORS.background};font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;-webkit-font-smoothing:antialiased;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:${COLORS.background};padding:32px 16px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
<!-- Header com logo -->
<tr>
<td style="padding:0 0 24px 0;text-align:center;">
<table cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td style="background:${COLORS.primary};width:40px;height:40px;border-radius:8px;text-align:center;vertical-align:middle;">
<span style="color:${COLORS.primaryForeground};font-size:20px;font-weight:bold;">R</span>
</td>
<td style="padding-left:12px;">
<span style="color:${COLORS.textPrimary};font-size:20px;font-weight:600;">Raven</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Card principal -->
<tr>
<td style="background:${COLORS.card};border-radius:12px;padding:32px;border:1px solid ${COLORS.border};">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 0;text-align:center;">
<p style="color:${COLORS.textMuted};font-size:12px;margin:0 0 8px 0;">
Este e-mail foi enviado pelo Sistema de Chamados Raven.
</p>
<p style="margin:0;">
<a href="${preferencesUrl}" style="color:${COLORS.primaryDark};font-size:12px;text-decoration:none;">Gerenciar notificações</a>
<span style="color:${COLORS.textMuted};margin:0 8px;">|</span>
<a href="${helpUrl}" style="color:${COLORS.primaryDark};font-size:12px;text-decoration:none;">Ajuda</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
// ============================================
// Templates
// ============================================
const templates: Record<TemplateName, (data: TemplateData) => string> = {
// Template de teste
test: (data) => {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 16px 0;">
${escapeHtml(data.title)}
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
${escapeHtml(data.message)}
</p>
<p style="color:${COLORS.textMuted};font-size:12px;margin:0;">
Enviado em: ${escapeHtml(data.timestamp)}
</p>
`,
data
)
},
// Abertura de chamado
ticket_created: (data) => {
const viewUrl = data.viewUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Chamado Aberto
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
status: data.status as string,
priority: data.priority as string,
createdAt: data.createdAt as string,
})}
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Resolução de chamado
ticket_resolved: (data) => {
const viewUrl = data.viewUrl as string
const rateUrl = data.rateUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Chamado Resolvido
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório!
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
status: "RESOLVED",
assigneeName: data.assigneeName as string,
})}
${
data.resolutionSummary
? `
<div style="background:${COLORS.statusResolvedBg};border-radius:8px;padding:16px;margin:16px 0;">
<p style="color:${COLORS.statusResolved};font-size:12px;font-weight:600;margin:0 0 8px 0;">RESUMO DA RESOLUÇÃO</p>
<p style="color:${COLORS.textPrimary};font-size:14px;line-height:1.6;margin:0;">${escapeHtml(data.resolutionSummary)}</p>
</div>
`
: ""
}
<div style="text-align:center;margin:32px 0 16px 0;">
<p style="color:${COLORS.textPrimary};font-size:16px;font-weight:600;margin:0 0 8px 0;">Como foi o atendimento?</p>
<p style="color:${COLORS.textSecondary};font-size:14px;margin:0 0 16px 0;">Sua avaliação nos ajuda a melhorar!</p>
${ratingStars(rateUrl)}
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Atribuição de chamado
ticket_assigned: (data) => {
const viewUrl = data.viewUrl as string
const isForRequester = data.isForRequester as boolean
const title = isForRequester ? "Agente Atribuído ao Chamado" : "Novo Chamado Atribuído"
const message = isForRequester
? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.`
: `Um novo chamado foi atribuído a você. Por favor, verifique os detalhes abaixo.`
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
${title}
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
${message}
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
status: data.status as string,
priority: data.priority as string,
requesterName: data.requesterName as string,
assigneeName: data.assigneeName as string,
})}
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Mudança de status
ticket_status: (data) => {
const viewUrl = data.viewUrl as string
const oldStatus = getStatusStyle(data.oldStatus as string)
const newStatus = getStatusStyle(data.newStatus as string)
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Status Atualizado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O status do seu chamado foi alterado.
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
})}
<div style="text-align:center;margin:24px 0;">
<table cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td style="text-align:center;">
${badge(oldStatus.label, oldStatus.bg, oldStatus.color)}
</td>
<td style="padding:0 16px;color:${COLORS.textMuted};font-size:20px;"></td>
<td style="text-align:center;">
${badge(newStatus.label, newStatus.bg, newStatus.color)}
</td>
</tr>
</table>
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Novo comentário
ticket_comment: (data) => {
const viewUrl = data.viewUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Nova Atualização no Chamado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado.
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
})}
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid ${COLORS.primary};">
<p style="color:${COLORS.textMuted};font-size:12px;margin:0 0 8px 0;">
${escapeHtml(data.authorName)} ${formatDate(data.commentedAt as string)}
</p>
<p style="color:${COLORS.textPrimary};font-size:14px;line-height:1.6;margin:0;">
${escapeHtml(data.commentBody)}
</p>
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Reset de senha
password_reset: (data) => {
const resetUrl = data.resetUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Redefinição de Senha
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail.
</p>
<div style="text-align:center;margin:32px 0;">
${button("Redefinir Senha", resetUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
Este link expira em 24 horas. Se você não solicitou a redefinição de senha,
pode ignorar este e-mail com segurança.
</p>
`,
data
)
},
// Verificação de e-mail
email_verify: (data) => {
const verifyUrl = data.verifyUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Confirme seu E-mail
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.
</p>
<div style="text-align:center;margin:32px 0;">
${button("Confirmar E-mail", verifyUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
Se você não criou uma conta, pode ignorar este e-mail com segurança.
</p>
`,
data
)
},
// Convite de usuário
invite: (data) => {
const inviteUrl = data.inviteUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Você foi convidado!
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven.
</p>
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Função</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.roleName)}</td>
</tr>
${
data.companyName
? `
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Empresa</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.companyName)}</td>
</tr>
`
: ""
}
</table>
</div>
<div style="text-align:center;margin:32px 0;">
${button("Aceitar Convite", inviteUrl)}
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
Este convite expira em 7 dias. Se você não esperava este convite, pode ignorá-lo com segurança.
</p>
`,
data
)
},
// Novo login detectado
new_login: (data) => {
return baseTemplate(
`
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
Novo Acesso Detectado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail.
</p>
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Data/Hora</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${formatDate(data.loginAt as string)}</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Dispositivo</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.userAgent)}</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Endereço IP</td>
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.ipAddress)}</td>
</tr>
</table>
</div>
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
Se você não reconhece este acesso, recomendamos alterar sua senha imediatamente.
</p>
`,
data
)
},
// Alerta de SLA em risco
sla_warning: (data) => {
const viewUrl = data.viewUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.statusPaused};font-size:24px;font-weight:600;margin:0 0 8px 0;">
SLA em Risco
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O chamado abaixo está próximo de violar o SLA. Ação necessária!
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
status: data.status as string,
priority: data.priority as string,
requesterName: data.requesterName as string,
assigneeName: data.assigneeName as string,
})}
<div style="background:${COLORS.statusPausedBg};border-radius:8px;padding:16px;margin:16px 0;">
<p style="color:${COLORS.statusPaused};font-size:14px;font-weight:600;margin:0 0 8px 0;">
Tempo restante: ${escapeHtml(data.timeRemaining)}
</p>
<p style="color:${COLORS.textSecondary};font-size:12px;margin:0;">
Prazo: ${formatDate(data.dueAt as string)}
</p>
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
// Alerta de SLA violado
sla_breached: (data) => {
const viewUrl = data.viewUrl as string
return baseTemplate(
`
<h1 style="color:${COLORS.priorityUrgent};font-size:24px;font-weight:600;margin:0 0 8px 0;">
SLA Violado
</h1>
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária!
</p>
${ticketInfoCard({
reference: data.reference as number,
subject: data.subject as string,
status: data.status as string,
priority: data.priority as string,
requesterName: data.requesterName as string,
assigneeName: data.assigneeName as string,
})}
<div style="background:${COLORS.priorityUrgentBg};border-radius:8px;padding:16px;margin:16px 0;">
<p style="color:${COLORS.priorityUrgent};font-size:14px;font-weight:600;margin:0 0 8px 0;">
Tempo excedido: ${escapeHtml(data.timeExceeded)}
</p>
<p style="color:${COLORS.textSecondary};font-size:12px;margin:0;">
Prazo era: ${formatDate(data.dueAt as string)}
</p>
</div>
<div style="text-align:center;margin-top:24px;">
${button("Ver Chamado", viewUrl)}
</div>
`,
data
)
},
}
// ============================================
// Exportação
// ============================================
/**
* Renderiza um template de e-mail com os dados fornecidos
*/
export function renderTemplate(name: TemplateName, data: TemplateData): string {
const template = templates[name]
if (!template) {
throw new Error(`Template "${name}" não encontrado`)
}
return template(data)
}
/**
* Retorna a lista de templates disponíveis
*/
export function getAvailableTemplates(): TemplateName[] {
return Object.keys(templates) as TemplateName[]
}