/** * 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 // ============================================ // 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } 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 = { 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 = { 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 `${escapeHtml(label)}` } 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 `${escapeHtml(label)}` } 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(` Chamado #${escapeHtml(data.reference)} `) rows.push(` Assunto ${escapeHtml(data.subject)} `) if (data.status) { rows.push(` Status ${statusBadge(data.status)} `) } if (data.priority) { rows.push(` Prioridade ${priorityBadge(data.priority)} `) } if (data.requesterName) { rows.push(` Solicitante ${escapeHtml(data.requesterName)} `) } if (data.assigneeName) { rows.push(` Responsável ${escapeHtml(data.assigneeName)} `) } if (data.createdAt) { rows.push(` Criado em ${formatDate(data.createdAt)} `) } return `
${rows.join("")}
` } function ratingStars(rateUrl: string): string { const stars: string[] = [] for (let i = 1; i <= 5; i++) { stars.push(` `) } return ` ${stars.join("")}

Clique em uma estrela para avaliar

` } // ============================================ // 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 ` ${escapeHtml(data.subject ?? "Notificação")}
R Raven
${content}

Este e-mail foi enviado pelo Sistema de Chamados Raven.

Gerenciar notificações | Ajuda

` } // ============================================ // Templates // ============================================ const templates: Record string> = { // Template de teste test: (data) => { return baseTemplate( `

${escapeHtml(data.title)}

${escapeHtml(data.message)}

Enviado em: ${escapeHtml(data.timestamp)}

`, data ) }, // Abertura de chamado ticket_created: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

Chamado Aberto

Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.

${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, })}
${button("Ver Chamado", viewUrl)}
`, data ) }, // Resolução de chamado ticket_resolved: (data) => { const viewUrl = data.viewUrl as string const rateUrl = data.rateUrl as string return baseTemplate( `

Chamado Resolvido

Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório!

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: "RESOLVED", assigneeName: data.assigneeName as string, })} ${ data.resolutionSummary ? `

RESUMO DA RESOLUÇÃO

${escapeHtml(data.resolutionSummary)}

` : "" }

Como foi o atendimento?

Sua avaliação nos ajuda a melhorar!

${ratingStars(rateUrl)}
${button("Ver Chamado", viewUrl)}
`, 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( `

${title}

${message}

${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, })}
${button("Ver Chamado", viewUrl)}
`, 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( `

Status Atualizado

O status do seu chamado foi alterado.

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, })}
${badge(oldStatus.label, oldStatus.bg, oldStatus.color)} ${badge(newStatus.label, newStatus.bg, newStatus.color)}
${button("Ver Chamado", viewUrl)}
`, data ) }, // Novo comentário ticket_comment: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

Nova Atualização no Chamado

${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado.

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, })}

${escapeHtml(data.authorName)} • ${formatDate(data.commentedAt as string)}

${escapeHtml(data.commentBody)}

${button("Ver Chamado", viewUrl)}
`, data ) }, // Reset de senha password_reset: (data) => { const resetUrl = data.resetUrl as string return baseTemplate( `

Redefinição de Senha

Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail.

${button("Redefinir Senha", resetUrl)}

Este link expira em 24 horas. Se você não solicitou a redefinição de senha, pode ignorar este e-mail com segurança.

`, data ) }, // Verificação de e-mail email_verify: (data) => { const verifyUrl = data.verifyUrl as string return baseTemplate( `

Confirme seu E-mail

Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.

${button("Confirmar E-mail", verifyUrl)}

Se você não criou uma conta, pode ignorar este e-mail com segurança.

`, data ) }, // Convite de usuário invite: (data) => { const inviteUrl = data.inviteUrl as string return baseTemplate( `

Você foi convidado!

${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven.

${ data.companyName ? ` ` : "" }
Função ${escapeHtml(data.roleName)}
Empresa ${escapeHtml(data.companyName)}
${button("Aceitar Convite", inviteUrl)}

Este convite expira em 7 dias. Se você não esperava este convite, pode ignorá-lo com segurança.

`, data ) }, // Novo login detectado new_login: (data) => { return baseTemplate( `

Novo Acesso Detectado

Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail.

Data/Hora ${formatDate(data.loginAt as string)}
Dispositivo ${escapeHtml(data.userAgent)}
Endereço IP ${escapeHtml(data.ipAddress)}

Se você não reconhece este acesso, recomendamos alterar sua senha imediatamente.

`, data ) }, // Alerta de SLA em risco sla_warning: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

SLA em Risco

O chamado abaixo está próximo de violar o SLA. Ação necessária!

${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, })}

Tempo restante: ${escapeHtml(data.timeRemaining)}

Prazo: ${formatDate(data.dueAt as string)}

${button("Ver Chamado", viewUrl)}
`, data ) }, // Alerta de SLA violado sla_breached: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

SLA Violado

O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária!

${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, })}

Tempo excedido: ${escapeHtml(data.timeExceeded)}

Prazo era: ${formatDate(data.dueAt as string)}

${button("Ver Chamado", viewUrl)}
`, 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[] }