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:
parent
cb6add1a4a
commit
f2c0298285
23 changed files with 4387 additions and 9 deletions
305
src/server/email/email-service.ts
Normal file
305
src/server/email/email-service.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
767
src/server/email/email-templates.ts
Normal file
767
src/server/email/email-templates.ts
Normal 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, "&")
|
||||
.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<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};">★</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
28
src/server/email/index.ts
Normal 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"
|
||||
33
src/server/notification/index.ts
Normal file
33
src/server/notification/index.ts
Normal 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"
|
||||
635
src/server/notification/notification-service.ts
Normal file
635
src/server/notification/notification-service.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
171
src/server/notification/token-service.ts
Normal file
171
src/server/notification/token-service.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue