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,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
}