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,305 @@
/**
* Serviço centralizado de envio de e-mails
* Sistema de Chamados Raven
*/
import { sendSmtpMail } from "../email-smtp"
import { renderTemplate, type TemplateData, type TemplateName } from "./email-templates"
// ============================================
// Tipos
// ============================================
export type NotificationType =
// Ciclo de vida do ticket
| "ticket_created"
| "ticket_assigned"
| "ticket_resolved"
| "ticket_reopened"
| "ticket_status_changed"
| "ticket_priority_changed"
// Comunicação
| "comment_public"
| "comment_response"
| "comment_mention"
// SLA
| "sla_at_risk"
| "sla_breached"
| "sla_daily_digest"
// Autenticação
| "security_password_reset"
| "security_email_verify"
| "security_email_change"
| "security_new_login"
| "security_invite"
export interface NotificationConfig {
label: string
defaultEnabled: boolean
staffOnly?: boolean
required?: boolean
collaboratorCanDisable?: boolean
}
export const NOTIFICATION_TYPES: Record<NotificationType, NotificationConfig> = {
// Ciclo de vida do ticket
ticket_created: {
label: "Abertura de chamado",
defaultEnabled: true,
required: true,
},
ticket_assigned: {
label: "Atribuição de chamado",
defaultEnabled: true,
collaboratorCanDisable: false,
},
ticket_resolved: {
label: "Resolução de chamado",
defaultEnabled: true,
required: true,
},
ticket_reopened: {
label: "Reabertura de chamado",
defaultEnabled: true,
collaboratorCanDisable: true,
},
ticket_status_changed: {
label: "Mudança de status",
defaultEnabled: false,
collaboratorCanDisable: true,
},
ticket_priority_changed: {
label: "Mudança de prioridade",
defaultEnabled: true,
collaboratorCanDisable: true,
},
// Comunicação
comment_public: {
label: "Comentários públicos",
defaultEnabled: true,
collaboratorCanDisable: true,
},
comment_response: {
label: "Resposta do solicitante",
defaultEnabled: true,
staffOnly: true,
},
comment_mention: {
label: "Menções em comentários",
defaultEnabled: true,
staffOnly: true,
},
// SLA
sla_at_risk: {
label: "SLA em risco",
defaultEnabled: true,
staffOnly: true,
},
sla_breached: {
label: "SLA violado",
defaultEnabled: true,
staffOnly: true,
},
sla_daily_digest: {
label: "Resumo diário de SLA",
defaultEnabled: false,
staffOnly: true,
},
// Autenticação
security_password_reset: {
label: "Redefinição de senha",
defaultEnabled: true,
required: true,
},
security_email_verify: {
label: "Verificação de e-mail",
defaultEnabled: true,
required: true,
},
security_email_change: {
label: "Alteração de e-mail",
defaultEnabled: true,
required: true,
},
security_new_login: {
label: "Novo login detectado",
defaultEnabled: true,
collaboratorCanDisable: false,
},
security_invite: {
label: "Convite de usuário",
defaultEnabled: true,
required: true,
},
}
export interface EmailRecipient {
email: string
name?: string
userId?: string
role?: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR"
}
export interface SendEmailOptions {
to: EmailRecipient | EmailRecipient[]
subject: string
template: TemplateName
data: TemplateData
notificationType?: NotificationType
tenantId?: string
skipPreferenceCheck?: boolean
}
export interface SendEmailResult {
success: boolean
skipped?: boolean
reason?: string
recipientCount?: number
}
// ============================================
// Configuração SMTP
// ============================================
function getSmtpConfig() {
const host = process.env.SMTP_HOST
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER
const password = process.env.SMTP_PASS
const fromEmail = process.env.SMTP_FROM_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Sistema de Chamados"
if (!host || !port || !username || !password || !fromEmail) {
return null
}
return {
host,
port: parseInt(port, 10),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
// ============================================
// Serviço de E-mail
// ============================================
/**
* Envia um e-mail usando o sistema de templates
*/
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const config = getSmtpConfig()
if (!config) {
console.warn("[EmailService] SMTP não configurado, e-mail ignorado")
return { success: false, skipped: true, reason: "smtp_not_configured" }
}
const recipients = Array.isArray(options.to) ? options.to : [options.to]
if (recipients.length === 0) {
return { success: false, skipped: true, reason: "no_recipients" }
}
// Renderiza o template
const html = renderTemplate(options.template, options.data)
// Extrai apenas os e-mails
const emailAddresses = recipients.map((r) => r.email)
try {
await sendSmtpMail(config, emailAddresses, options.subject, html)
console.log(`[EmailService] E-mail enviado para ${emailAddresses.length} destinatário(s)`)
return {
success: true,
recipientCount: emailAddresses.length,
}
} catch (error) {
console.error("[EmailService] Erro ao enviar e-mail:", error)
return {
success: false,
reason: error instanceof Error ? error.message : "unknown_error",
}
}
}
/**
* Verifica se um tipo de notificação é obrigatório
*/
export function isRequiredNotification(type: NotificationType): boolean {
return NOTIFICATION_TYPES[type]?.required === true
}
/**
* Verifica se um tipo de notificação é apenas para staff
*/
export function isStaffOnlyNotification(type: NotificationType): boolean {
return NOTIFICATION_TYPES[type]?.staffOnly === true
}
/**
* Verifica se um colaborador pode desativar um tipo de notificação
*/
export function canCollaboratorDisable(type: NotificationType): boolean {
return NOTIFICATION_TYPES[type]?.collaboratorCanDisable === true
}
/**
* Retorna o label de um tipo de notificação
*/
export function getNotificationLabel(type: NotificationType): string {
return NOTIFICATION_TYPES[type]?.label ?? type
}
/**
* Retorna todos os tipos de notificação disponíveis para um role
*/
export function getAvailableNotificationTypes(role: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR"): NotificationType[] {
const isStaff = ["ADMIN", "MANAGER", "AGENT"].includes(role)
return (Object.entries(NOTIFICATION_TYPES) as [NotificationType, NotificationConfig][])
.filter(([, config]) => {
if (config.staffOnly && !isStaff) return false
return true
})
.map(([type]) => type)
}
/**
* Retorna os tipos de notificação que um colaborador pode desativar
*/
export function getCollaboratorDisableableTypes(): NotificationType[] {
return (Object.entries(NOTIFICATION_TYPES) as [NotificationType, NotificationConfig][])
.filter(([, config]) => config.collaboratorCanDisable === true)
.map(([type]) => type)
}
// ============================================
// E-mail de Teste
// ============================================
const TEST_EMAIL_RECIPIENT = process.env.TEST_EMAIL_RECIPIENT ?? "monkeyesdras@gmail.com"
/**
* Envia um e-mail de teste
*/
export async function sendTestEmail(to?: string): Promise<SendEmailResult> {
return sendEmail({
to: { email: to ?? TEST_EMAIL_RECIPIENT },
subject: "Teste - Sistema de Chamados Raven",
template: "test",
data: {
title: "E-mail de Teste",
message: "Este é um e-mail de teste do Sistema de Chamados Raven.",
timestamp: new Date().toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" }),
},
skipPreferenceCheck: true,
})
}

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[]
}

28
src/server/email/index.ts Normal file
View file

@ -0,0 +1,28 @@
/**
* Módulo de E-mail
* Sistema de Chamados Raven
*/
export {
sendEmail,
sendTestEmail,
isRequiredNotification,
isStaffOnlyNotification,
canCollaboratorDisable,
getNotificationLabel,
getAvailableNotificationTypes,
getCollaboratorDisableableTypes,
NOTIFICATION_TYPES,
type NotificationType,
type NotificationConfig,
type EmailRecipient,
type SendEmailOptions,
type SendEmailResult,
} from "./email-service"
export {
renderTemplate,
getAvailableTemplates,
type TemplateName,
type TemplateData,
} from "./email-templates"

View file

@ -0,0 +1,33 @@
/**
* Módulo de Notificações
* Sistema de Chamados Raven
*/
// Serviço de Notificações
export {
notifyTicketCreated,
notifyTicketAssigned,
notifyTicketResolved,
notifyTicketStatusChanged,
notifyPublicComment,
notifyRequesterResponse,
notifyPasswordReset,
notifyEmailVerification,
notifyUserInvite,
notifyNewLogin,
notifySlaAtRisk,
notifySlaBreached,
} from "./notification-service"
// Serviço de Tokens
export {
generateAccessToken,
validateAccessToken,
markTokenAsUsed,
invalidateTicketTokens,
invalidateUserTokens,
cleanupExpiredTokens,
hasScope,
type GenerateTokenOptions,
type ValidatedToken,
} from "./token-service"

View file

@ -0,0 +1,635 @@
/**
* Serviço de Notificações
* Orquestrador de notificações por e-mail
* Sistema de Chamados Raven
*/
import { prisma } from "@/lib/prisma"
import { sendEmail, type NotificationType } from "../email"
import { generateAccessToken } from "./token-service"
// ============================================
// Tipos
// ============================================
interface TicketData {
id: string
tenantId: string
reference: number
subject: string
status: string
priority: string
createdAt: Date
resolvedAt?: Date | null
requester: {
id: string
name: string
email: string
role: string
}
assignee?: {
id: string
name: string
email: string
role: string
} | null
company?: {
id: string
name: string
} | null
}
interface CommentData {
id: string
body: string
visibility: string
createdAt: Date
author: {
id: string
name: string
email: string
role: string
}
}
// ============================================
// Helpers
// ============================================
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
function getTicketViewUrl(ticketId: string, token?: string): string {
if (token) {
return `${APP_URL}/ticket-view/${token}`
}
return `${APP_URL}/portal/tickets/${ticketId}`
}
function getRateUrl(token: string): string {
return `${APP_URL}/rate/${token}`
}
async function shouldSendNotification(
userId: string,
notificationType: NotificationType,
tenantId: string
): Promise<boolean> {
try {
const prefs = await prisma.notificationPreferences.findUnique({
where: { userId },
})
// Se não tem preferências, usa os defaults
if (!prefs) return true
// Se e-mail está desabilitado globalmente
if (!prefs.emailEnabled) return false
// Verifica preferências por tipo
const typePrefs = prefs.typePreferences as Record<string, boolean>
if (typePrefs && notificationType in typePrefs) {
return typePrefs[notificationType] !== false
}
return true
} catch {
// Em caso de erro, envia a notificação
return true
}
}
// ============================================
// Notificações de Ciclo de Vida
// ============================================
/**
* Notificação de abertura de chamado
* Enviada para: Solicitante
*/
export async function notifyTicketCreated(ticket: TicketData): Promise<void> {
const { requester } = ticket
// Gera token de acesso para visualização
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "view",
expiresInDays: 7,
})
const viewUrl = getTicketViewUrl(ticket.id, accessToken)
await sendEmail({
to: {
email: requester.email,
name: requester.name,
userId: requester.id,
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Chamado Aberto - ${ticket.subject}`,
template: "ticket_created",
data: {
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
createdAt: ticket.createdAt.toISOString(),
viewUrl,
},
notificationType: "ticket_created",
tenantId: ticket.tenantId,
})
}
/**
* Notificação de atribuição de chamado
* Enviada para: Solicitante e Agente atribuído
*/
export async function notifyTicketAssigned(ticket: TicketData): Promise<void> {
if (!ticket.assignee) return
const { requester, assignee } = ticket
// Gera tokens de acesso
const requesterToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "view",
expiresInDays: 7,
})
const assigneeToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: assignee.id,
scope: "interact",
expiresInDays: 7,
})
// Notifica o solicitante
if (await shouldSendNotification(requester.id, "ticket_assigned", ticket.tenantId)) {
await sendEmail({
to: {
email: requester.email,
name: requester.name,
userId: requester.id,
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Agente Atribuído - ${ticket.subject}`,
template: "ticket_assigned",
data: {
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
requesterName: requester.name,
assigneeName: assignee.name,
isForRequester: true,
viewUrl: getTicketViewUrl(ticket.id, requesterToken),
},
notificationType: "ticket_assigned",
tenantId: ticket.tenantId,
})
}
// Notifica o agente atribuído
if (await shouldSendNotification(assignee.id, "ticket_assigned", ticket.tenantId)) {
await sendEmail({
to: {
email: assignee.email,
name: assignee.name,
userId: assignee.id,
role: assignee.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Novo Chamado Atribuído - ${ticket.subject}`,
template: "ticket_assigned",
data: {
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
requesterName: requester.name,
assigneeName: assignee.name,
isForRequester: false,
viewUrl: getTicketViewUrl(ticket.id, assigneeToken),
},
notificationType: "ticket_assigned",
tenantId: ticket.tenantId,
})
}
}
/**
* Notificação de resolução de chamado
* Enviada para: Solicitante (com link de avaliação)
*/
export async function notifyTicketResolved(
ticket: TicketData,
resolutionSummary?: string
): Promise<void> {
const { requester, assignee } = ticket
// Gera token de acesso para avaliação
const rateToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "rate",
expiresInDays: 30, // 30 dias para avaliar
})
const viewToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "view",
expiresInDays: 7,
})
await sendEmail({
to: {
email: requester.email,
name: requester.name,
userId: requester.id,
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Chamado Resolvido - ${ticket.subject}`,
template: "ticket_resolved",
data: {
reference: ticket.reference,
subject: ticket.subject,
assigneeName: assignee?.name ?? "Equipe de Suporte",
resolutionSummary,
viewUrl: getTicketViewUrl(ticket.id, viewToken),
rateUrl: getRateUrl(rateToken),
},
notificationType: "ticket_resolved",
tenantId: ticket.tenantId,
})
}
/**
* Notificação de mudança de status
* Enviada para: Solicitante
*/
export async function notifyTicketStatusChanged(
ticket: TicketData,
oldStatus: string,
newStatus: string
): Promise<void> {
const { requester } = ticket
if (!(await shouldSendNotification(requester.id, "ticket_status_changed", ticket.tenantId))) {
return
}
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "view",
expiresInDays: 7,
})
await sendEmail({
to: {
email: requester.email,
name: requester.name,
userId: requester.id,
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Status Atualizado - ${ticket.subject}`,
template: "ticket_status",
data: {
reference: ticket.reference,
subject: ticket.subject,
oldStatus,
newStatus,
viewUrl: getTicketViewUrl(ticket.id, accessToken),
},
notificationType: "ticket_status_changed",
tenantId: ticket.tenantId,
})
}
/**
* Notificação de novo comentário público
* Enviada para: Solicitante (quando agente comenta)
*/
export async function notifyPublicComment(
ticket: TicketData,
comment: CommentData
): Promise<void> {
// Só notifica comentários públicos
if (comment.visibility !== "PUBLIC") return
// Não notifica se o autor é o próprio solicitante
if (comment.author.id === ticket.requester.id) return
const { requester } = ticket
if (!(await shouldSendNotification(requester.id, "comment_public", ticket.tenantId))) {
return
}
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: requester.id,
scope: "view",
expiresInDays: 7,
})
await sendEmail({
to: {
email: requester.email,
name: requester.name,
userId: requester.id,
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Nova Atualização - ${ticket.subject}`,
template: "ticket_comment",
data: {
reference: ticket.reference,
subject: ticket.subject,
authorName: comment.author.name,
commentBody: comment.body,
commentedAt: comment.createdAt.toISOString(),
viewUrl: getTicketViewUrl(ticket.id, accessToken),
},
notificationType: "comment_public",
tenantId: ticket.tenantId,
})
}
/**
* Notificação de resposta do solicitante
* Enviada para: Agente atribuído
*/
export async function notifyRequesterResponse(
ticket: TicketData,
comment: CommentData
): Promise<void> {
// Só notifica se tem agente atribuído
if (!ticket.assignee) return
// Só notifica se o autor é o solicitante
if (comment.author.id !== ticket.requester.id) return
const { assignee } = ticket
if (!(await shouldSendNotification(assignee.id, "comment_response", ticket.tenantId))) {
return
}
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: assignee.id,
scope: "interact",
expiresInDays: 7,
})
await sendEmail({
to: {
email: assignee.email,
name: assignee.name,
userId: assignee.id,
role: assignee.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[#${ticket.reference}] Resposta do Solicitante - ${ticket.subject}`,
template: "ticket_comment",
data: {
reference: ticket.reference,
subject: ticket.subject,
authorName: comment.author.name,
commentBody: comment.body,
commentedAt: comment.createdAt.toISOString(),
viewUrl: getTicketViewUrl(ticket.id, accessToken),
},
notificationType: "comment_response",
tenantId: ticket.tenantId,
})
}
// ============================================
// Notificações de Autenticação
// ============================================
/**
* Notificação de reset de senha
*/
export async function notifyPasswordReset(
email: string,
name: string,
resetUrl: string
): Promise<void> {
await sendEmail({
to: { email, name },
subject: "Redefinição de Senha - Sistema de Chamados Raven",
template: "password_reset",
data: { resetUrl },
notificationType: "security_password_reset",
skipPreferenceCheck: true,
})
}
/**
* Notificação de verificação de e-mail
*/
export async function notifyEmailVerification(
email: string,
name: string,
verifyUrl: string
): Promise<void> {
await sendEmail({
to: { email, name },
subject: "Confirme seu E-mail - Sistema de Chamados Raven",
template: "email_verify",
data: { verifyUrl },
notificationType: "security_email_verify",
skipPreferenceCheck: true,
})
}
/**
* Notificação de convite de usuário
*/
export async function notifyUserInvite(
email: string,
name: string | null,
inviterName: string,
roleName: string,
companyName: string | null,
inviteUrl: string
): Promise<void> {
await sendEmail({
to: { email, name: name ?? undefined },
subject: "Você foi convidado! - Sistema de Chamados Raven",
template: "invite",
data: {
inviterName,
roleName,
companyName,
inviteUrl,
},
notificationType: "security_invite",
skipPreferenceCheck: true,
})
}
/**
* Notificação de novo login
*/
export async function notifyNewLogin(
userId: string,
email: string,
name: string,
loginAt: Date,
userAgent: string,
ipAddress: string,
tenantId: string
): Promise<void> {
if (!(await shouldSendNotification(userId, "security_new_login", tenantId))) {
return
}
await sendEmail({
to: { email, name, userId },
subject: "Novo Acesso Detectado - Sistema de Chamados Raven",
template: "new_login",
data: {
loginAt: loginAt.toISOString(),
userAgent,
ipAddress,
},
notificationType: "security_new_login",
tenantId,
})
}
// ============================================
// Notificações de SLA
// ============================================
/**
* Notificação de SLA em risco
* Enviada para: Agente atribuído e supervisor
*/
export async function notifySlaAtRisk(
ticket: TicketData,
dueAt: Date,
timeRemaining: string
): Promise<void> {
const recipients: Array<{ email: string; name: string; userId: string; role: string }> = []
// Adiciona o agente atribuído
if (ticket.assignee) {
if (await shouldSendNotification(ticket.assignee.id, "sla_at_risk", ticket.tenantId)) {
recipients.push({
email: ticket.assignee.email,
name: ticket.assignee.name,
userId: ticket.assignee.id,
role: ticket.assignee.role,
})
}
}
if (recipients.length === 0) return
for (const recipient of recipients) {
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: recipient.userId,
scope: "interact",
expiresInDays: 7,
})
await sendEmail({
to: {
email: recipient.email,
name: recipient.name,
userId: recipient.userId,
role: recipient.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[ATENÇÃO] SLA em Risco - #${ticket.reference} ${ticket.subject}`,
template: "sla_warning",
data: {
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
requesterName: ticket.requester.name,
assigneeName: ticket.assignee?.name,
dueAt: dueAt.toISOString(),
timeRemaining,
viewUrl: getTicketViewUrl(ticket.id, accessToken),
},
notificationType: "sla_at_risk",
tenantId: ticket.tenantId,
})
}
}
/**
* Notificação de SLA violado
* Enviada para: Agente atribuído e administradores
*/
export async function notifySlaBreached(
ticket: TicketData,
dueAt: Date,
timeExceeded: string
): Promise<void> {
const recipients: Array<{ email: string; name: string; userId: string; role: string }> = []
// Adiciona o agente atribuído
if (ticket.assignee) {
if (await shouldSendNotification(ticket.assignee.id, "sla_breached", ticket.tenantId)) {
recipients.push({
email: ticket.assignee.email,
name: ticket.assignee.name,
userId: ticket.assignee.id,
role: ticket.assignee.role,
})
}
}
if (recipients.length === 0) return
for (const recipient of recipients) {
const accessToken = await generateAccessToken({
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: recipient.userId,
scope: "interact",
expiresInDays: 7,
})
await sendEmail({
to: {
email: recipient.email,
name: recipient.name,
userId: recipient.userId,
role: recipient.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
},
subject: `[URGENTE] SLA Violado - #${ticket.reference} ${ticket.subject}`,
template: "sla_breached",
data: {
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
requesterName: ticket.requester.name,
assigneeName: ticket.assignee?.name,
dueAt: dueAt.toISOString(),
timeExceeded,
viewUrl: getTicketViewUrl(ticket.id, accessToken),
},
notificationType: "sla_breached",
tenantId: ticket.tenantId,
})
}
}

View file

@ -0,0 +1,171 @@
/**
* Serviço de Tokens de Acesso
* Gera e valida tokens para acesso direto aos chamados
* Sistema de Chamados Raven
*/
import { prisma } from "@/lib/prisma"
import { randomBytes } from "crypto"
// ============================================
// Tipos
// ============================================
export interface GenerateTokenOptions {
tenantId: string
ticketId: string
userId: string
machineId?: string
scope: "view" | "interact" | "rate"
expiresInDays?: number
}
export interface ValidatedToken {
id: string
tenantId: string
ticketId: string
userId: string
machineId: string | null
scope: string
expiresAt: Date
usedAt: Date | null
createdAt: Date
}
// ============================================
// Geração de Token
// ============================================
/**
* Gera um token seguro de acesso ao chamado
*/
export async function generateAccessToken(options: GenerateTokenOptions): Promise<string> {
const {
tenantId,
ticketId,
userId,
machineId,
scope,
expiresInDays = 7,
} = options
// Gera um token aleatório de 32 bytes (64 caracteres hex)
const token = randomBytes(32).toString("hex")
// Calcula a data de expiração
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + expiresInDays)
// Salva o token no banco
await prisma.ticketAccessToken.create({
data: {
tenantId,
token,
ticketId,
userId,
machineId,
scope,
expiresAt,
},
})
return token
}
/**
* Valida um token de acesso
*/
export async function validateAccessToken(token: string): Promise<ValidatedToken | null> {
const tokenRecord = await prisma.ticketAccessToken.findUnique({
where: { token },
})
if (!tokenRecord) {
return null
}
// Verifica se o token expirou
if (tokenRecord.expiresAt < new Date()) {
return null
}
return {
id: tokenRecord.id,
tenantId: tokenRecord.tenantId,
ticketId: tokenRecord.ticketId,
userId: tokenRecord.userId,
machineId: tokenRecord.machineId,
scope: tokenRecord.scope,
expiresAt: tokenRecord.expiresAt,
usedAt: tokenRecord.usedAt,
createdAt: tokenRecord.createdAt,
}
}
/**
* Marca um token como usado
*/
export async function markTokenAsUsed(token: string): Promise<void> {
await prisma.ticketAccessToken.update({
where: { token },
data: { usedAt: new Date() },
})
}
/**
* Invalida todos os tokens de um chamado
*/
export async function invalidateTicketTokens(ticketId: string): Promise<void> {
await prisma.ticketAccessToken.updateMany({
where: { ticketId },
data: { expiresAt: new Date() },
})
}
/**
* Invalida todos os tokens de um usuário
*/
export async function invalidateUserTokens(userId: string): Promise<void> {
await prisma.ticketAccessToken.updateMany({
where: { userId },
data: { expiresAt: new Date() },
})
}
/**
* Limpa tokens expirados (pode ser chamado periodicamente)
*/
export async function cleanupExpiredTokens(): Promise<number> {
const result = await prisma.ticketAccessToken.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
})
return result.count
}
/**
* Verifica se um token tem o escopo necessário
*/
export function hasScope(tokenScope: string, requiredScope: "view" | "interact" | "rate"): boolean {
const scopeHierarchy: Record<string, number> = {
view: 1,
interact: 2,
rate: 3,
}
const tokenLevel = scopeHierarchy[tokenScope] ?? 0
const requiredLevel = scopeHierarchy[requiredScope] ?? 0
// rate é especial, só pode avaliar
if (requiredScope === "rate") {
return tokenScope === "rate"
}
// interact permite view
// view só permite view
return tokenLevel >= requiredLevel
}