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,231 @@
/**
* API de Preferências de Notificação
* GET - Retorna preferências do usuário
* PUT - Atualiza preferências do usuário
*/
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "@/lib/auth-server"
import { prisma } from "@/lib/prisma"
import {
NOTIFICATION_TYPES,
canCollaboratorDisable,
isRequiredNotification,
type NotificationType,
} from "@/server/email"
// Tipos de notificação que colaboradores podem ver
const COLLABORATOR_VISIBLE_TYPES: NotificationType[] = [
"ticket_created",
"ticket_assigned",
"ticket_resolved",
"ticket_reopened",
"ticket_status_changed",
"ticket_priority_changed",
"comment_public",
"security_password_reset",
"security_email_verify",
"security_email_change",
"security_new_login",
]
export async function GET(_request: NextRequest) {
try {
const session = await getServerSession()
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Não autorizado" },
{ status: 401 }
)
}
const userId = session.user.id
const userRole = session.user.role as string
const isStaff = ["ADMIN", "MANAGER", "AGENT"].includes(userRole)
// Busca preferências existentes
let prefs = await prisma.notificationPreferences.findUnique({
where: { userId },
})
// Se não existir, cria com valores padrão
if (!prefs) {
const user = await prisma.user.findFirst({
where: { email: session.user.email },
select: { tenantId: true },
})
if (!user) {
return NextResponse.json(
{ error: "Usuário não encontrado" },
{ status: 404 }
)
}
prefs = await prisma.notificationPreferences.create({
data: {
userId,
tenantId: user.tenantId,
emailEnabled: true,
digestFrequency: "immediate",
typePreferences: {},
categoryPreferences: {},
},
})
}
// Filtra tipos disponíveis baseado no role
const availableTypes = isStaff
? Object.keys(NOTIFICATION_TYPES)
: COLLABORATOR_VISIBLE_TYPES
// Monta resposta com configuração de cada tipo
const typeConfigs = availableTypes.map((type) => {
const config = NOTIFICATION_TYPES[type as NotificationType]
const typePrefs = prefs!.typePreferences as Record<string, boolean>
const enabled = typePrefs[type] ?? config.defaultEnabled
return {
type,
label: config.label,
enabled,
required: config.required ?? false,
canDisable: isStaff
? !config.required
: canCollaboratorDisable(type as NotificationType),
}
})
return NextResponse.json({
emailEnabled: prefs.emailEnabled,
quietHoursStart: prefs.quietHoursStart,
quietHoursEnd: prefs.quietHoursEnd,
timezone: prefs.timezone,
digestFrequency: prefs.digestFrequency,
types: typeConfigs,
categoryPreferences: prefs.categoryPreferences,
isStaff,
})
} catch (error) {
console.error("[notifications/preferences] Erro ao buscar preferências:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession()
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Não autorizado" },
{ status: 401 }
)
}
const userId = session.user.id
const userRole = session.user.role as string
const isStaff = ["ADMIN", "MANAGER", "AGENT"].includes(userRole)
const body = await request.json()
const {
emailEnabled,
quietHoursStart,
quietHoursEnd,
timezone,
digestFrequency,
typePreferences,
categoryPreferences,
} = body
// Valida typePreferences
const validatedTypePrefs: Record<string, boolean> = {}
if (typePreferences && typeof typePreferences === "object") {
for (const [type, enabled] of Object.entries(typePreferences)) {
// Verifica se é um tipo válido
if (!(type in NOTIFICATION_TYPES)) continue
// Verifica se o tipo é obrigatório
if (isRequiredNotification(type as NotificationType)) {
validatedTypePrefs[type] = true // Força true para obrigatórios
continue
}
// Para colaboradores, verifica se pode desativar
if (!isStaff && !canCollaboratorDisable(type as NotificationType)) {
continue // Ignora tipos que colaboradores não podem modificar
}
validatedTypePrefs[type] = Boolean(enabled)
}
}
// Busca preferências existentes
const existingPrefs = await prisma.notificationPreferences.findUnique({
where: { userId },
})
if (!existingPrefs) {
// Busca tenantId do usuário
const user = await prisma.user.findFirst({
where: { email: session.user.email },
select: { tenantId: true },
})
if (!user) {
return NextResponse.json(
{ error: "Usuário não encontrado" },
{ status: 404 }
)
}
// Cria novas preferências
await prisma.notificationPreferences.create({
data: {
userId,
tenantId: user.tenantId,
emailEnabled: emailEnabled ?? true,
quietHoursStart: quietHoursStart ?? null,
quietHoursEnd: quietHoursEnd ?? null,
timezone: timezone ?? "America/Sao_Paulo",
digestFrequency: digestFrequency ?? "immediate",
typePreferences: validatedTypePrefs,
categoryPreferences: categoryPreferences ?? {},
},
})
} else {
// Atualiza preferências existentes
await prisma.notificationPreferences.update({
where: { userId },
data: {
emailEnabled: emailEnabled ?? existingPrefs.emailEnabled,
quietHoursStart: quietHoursStart !== undefined ? quietHoursStart : existingPrefs.quietHoursStart,
quietHoursEnd: quietHoursEnd !== undefined ? quietHoursEnd : existingPrefs.quietHoursEnd,
timezone: timezone ?? existingPrefs.timezone,
digestFrequency: digestFrequency ?? existingPrefs.digestFrequency,
typePreferences: {
...(existingPrefs.typePreferences as Record<string, boolean>),
...validatedTypePrefs,
},
categoryPreferences: categoryPreferences ?? existingPrefs.categoryPreferences,
},
})
}
return NextResponse.json({
success: true,
message: "Preferências atualizadas com sucesso",
})
} catch (error) {
console.error("[notifications/preferences] Erro ao atualizar preferências:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,181 @@
/**
* API de Acesso a Ticket por Token
* GET - Valida token e retorna informações do ticket
* POST - Marca token como usado e cria sessão temporária
*/
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { validateAccessToken, markTokenAsUsed } from "@/server/notification"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const { token } = await params
// Valida o token
const validatedToken = await validateAccessToken(token)
if (!validatedToken) {
return NextResponse.json(
{ error: "Token inválido ou expirado" },
{ status: 401 }
)
}
// Busca informações do ticket
const ticket = await prisma.ticket.findUnique({
where: { id: validatedToken.ticketId },
include: {
requester: {
select: {
id: true,
name: true,
email: true,
},
},
assignee: {
select: {
id: true,
name: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
comments: {
where: {
visibility: "PUBLIC",
},
orderBy: {
createdAt: "desc",
},
take: 10,
include: {
author: {
select: {
id: true,
name: true,
},
},
},
},
rating: true,
},
})
if (!ticket) {
return NextResponse.json(
{ error: "Chamado não encontrado" },
{ status: 404 }
)
}
// Verifica se o usuário tem acesso ao ticket
if (
ticket.requesterId !== validatedToken.userId &&
ticket.assigneeId !== validatedToken.userId
) {
return NextResponse.json(
{ error: "Acesso negado a este chamado" },
{ status: 403 }
)
}
return NextResponse.json({
token: {
id: validatedToken.id,
scope: validatedToken.scope,
expiresAt: validatedToken.expiresAt.toISOString(),
used: validatedToken.usedAt !== null,
},
ticket: {
id: ticket.id,
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
createdAt: ticket.createdAt.toISOString(),
resolvedAt: ticket.resolvedAt?.toISOString() ?? null,
requester: {
id: ticket.requester.id,
name: ticket.requester.name,
},
assignee: ticket.assignee
? {
id: ticket.assignee.id,
name: ticket.assignee.name,
}
: null,
company: ticket.company
? {
id: ticket.company.id,
name: ticket.company.name,
}
: null,
comments: ticket.comments.map((comment) => ({
id: comment.id,
body: comment.body,
createdAt: comment.createdAt.toISOString(),
author: {
id: comment.author.id,
name: comment.author.name,
},
})),
rating: ticket.rating
? {
rating: ticket.rating.rating,
comment: ticket.rating.comment,
createdAt: ticket.rating.createdAt.toISOString(),
}
: null,
},
})
} catch (error) {
console.error("[ticket-access] Erro ao validar token:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const { token } = await params
// Valida o token
const validatedToken = await validateAccessToken(token)
if (!validatedToken) {
return NextResponse.json(
{ error: "Token inválido ou expirado" },
{ status: 401 }
)
}
// Marca o token como usado
await markTokenAsUsed(token)
return NextResponse.json({
success: true,
message: "Token marcado como usado",
})
} catch (error) {
console.error("[ticket-access] Erro ao usar token:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,203 @@
/**
* API de Avaliação de Ticket
* GET - Registra avaliação via link do e-mail
* POST - Registra avaliação com comentário opcional
*/
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { validateAccessToken, markTokenAsUsed, hasScope } from "@/server/notification"
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get("token")
const ratingStr = searchParams.get("rating")
if (!token) {
return NextResponse.redirect(new URL("/error?message=token_missing", request.url))
}
if (!ratingStr) {
return NextResponse.redirect(new URL("/error?message=rating_missing", request.url))
}
const rating = parseInt(ratingStr, 10)
if (isNaN(rating) || rating < 1 || rating > 5) {
return NextResponse.redirect(new URL("/error?message=rating_invalid", request.url))
}
// Valida o token
const validatedToken = await validateAccessToken(token)
if (!validatedToken) {
return NextResponse.redirect(new URL("/error?message=token_expired", request.url))
}
// Verifica se o token tem escopo de avaliação
if (!hasScope(validatedToken.scope, "rate")) {
return NextResponse.redirect(new URL("/error?message=token_scope_invalid", request.url))
}
// Busca o ticket
const ticket = await prisma.ticket.findUnique({
where: { id: validatedToken.ticketId },
select: {
id: true,
tenantId: true,
reference: true,
subject: true,
status: true,
requesterId: true,
rating: true,
},
})
if (!ticket) {
return NextResponse.redirect(new URL("/error?message=ticket_not_found", request.url))
}
// Verifica se o usuário é o solicitante
if (ticket.requesterId !== validatedToken.userId) {
return NextResponse.redirect(new URL("/error?message=access_denied", request.url))
}
// Verifica se já existe avaliação
if (ticket.rating) {
// Redireciona para página de confirmação mostrando que já foi avaliado
return NextResponse.redirect(
new URL(`/rate/${token}?already_rated=true&existing_rating=${ticket.rating.rating}`, request.url)
)
}
// Registra a avaliação
await prisma.ticketRating.create({
data: {
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: validatedToken.userId,
rating,
},
})
// Marca o token como usado
await markTokenAsUsed(token)
// Redireciona para página de confirmação com opção de adicionar comentário
return NextResponse.redirect(
new URL(`/rate/${token}?success=true&rating=${rating}`, request.url)
)
} catch (error) {
console.error("[tickets/rate] Erro ao registrar avaliação:", error)
return NextResponse.redirect(new URL("/error?message=server_error", request.url))
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { token, rating, comment } = body
if (!token) {
return NextResponse.json(
{ error: "Token é obrigatório" },
{ status: 400 }
)
}
if (!rating || typeof rating !== "number" || rating < 1 || rating > 5) {
return NextResponse.json(
{ error: "Avaliação deve ser um número entre 1 e 5" },
{ status: 400 }
)
}
// Valida o token
const validatedToken = await validateAccessToken(token)
if (!validatedToken) {
return NextResponse.json(
{ error: "Token inválido ou expirado" },
{ status: 401 }
)
}
// Verifica se o token tem escopo de avaliação
if (!hasScope(validatedToken.scope, "rate")) {
return NextResponse.json(
{ error: "Token não permite avaliação" },
{ status: 403 }
)
}
// Busca o ticket
const ticket = await prisma.ticket.findUnique({
where: { id: validatedToken.ticketId },
select: {
id: true,
tenantId: true,
requesterId: true,
rating: true,
},
})
if (!ticket) {
return NextResponse.json(
{ error: "Chamado não encontrado" },
{ status: 404 }
)
}
// Verifica se o usuário é o solicitante
if (ticket.requesterId !== validatedToken.userId) {
return NextResponse.json(
{ error: "Acesso negado" },
{ status: 403 }
)
}
// Verifica se já existe avaliação
if (ticket.rating) {
// Atualiza o comentário se já existe avaliação
await prisma.ticketRating.update({
where: { ticketId: ticket.id },
data: {
rating,
comment: comment ?? null,
},
})
return NextResponse.json({
success: true,
message: "Avaliação atualizada com sucesso",
updated: true,
})
}
// Registra a avaliação
await prisma.ticketRating.create({
data: {
tenantId: ticket.tenantId,
ticketId: ticket.id,
userId: validatedToken.userId,
rating,
comment: comment ?? null,
},
})
// Marca o token como usado
await markTokenAsUsed(token)
return NextResponse.json({
success: true,
message: "Avaliação registrada com sucesso",
})
} catch (error) {
console.error("[tickets/rate] Erro ao registrar avaliação:", error)
return NextResponse.json(
{ error: "Erro interno do servidor" },
{ status: 500 }
)
}
}

View file

@ -0,0 +1,43 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { NotificationPreferencesForm } from "@/components/settings/notification-preferences-form"
import { requireAuthenticatedSession } from "@/lib/auth-server"
export const metadata: Metadata = {
title: "Preferencias de notificacao",
description: "Configure quais notificacoes por e-mail deseja receber.",
}
export default async function PortalNotificationSettingsPage() {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
const persona = (session.user.machinePersona ?? "").toLowerCase()
// Colaboradores e maquinas com persona de colaborador podem acessar
const allowedRoles = new Set(["collaborator", "manager", "admin", "agent"])
const isMachinePersonaAllowed = role === "machine" && (persona === "collaborator" || persona === "manager")
const allowed = allowedRoles.has(role) || isMachinePersonaAllowed
if (!allowed) {
redirect("/portal")
}
// Staff deve usar a pagina de configuracoes completa
const staffRoles = new Set(["admin", "manager", "agent"])
if (staffRoles.has(role)) {
redirect("/settings/notifications")
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Preferencias de notificacao</h1>
<p className="text-muted-foreground">
Configure quais notificacoes por e-mail deseja receber sobre seus chamados.
</p>
</div>
<NotificationPreferencesForm isPortal />
</div>
)
}

View file

@ -0,0 +1,270 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Loader2, CheckCircle, Star, AlertCircle } from "lucide-react"
export default function RatePage() {
const params = useParams()
const searchParams = useSearchParams()
const token = params.token as string
const success = searchParams.get("success") === "true"
const alreadyRated = searchParams.get("already_rated") === "true"
const existingRatingStr = searchParams.get("existing_rating")
const initialRatingStr = searchParams.get("rating")
const existingRating = existingRatingStr ? parseInt(existingRatingStr, 10) : null
const initialRating = initialRatingStr ? parseInt(initialRatingStr, 10) : null
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(success)
const [rating, setRating] = useState<number>(initialRating ?? existingRating ?? 0)
const [hoveredRating, setHoveredRating] = useState<number>(0)
const [comment, setComment] = useState("")
const [error, setError] = useState<string | null>(null)
// Se ja avaliou, mostra mensagem
if (alreadyRated && existingRating) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<CheckCircle className="h-16 w-16 text-emerald-500" />
</div>
<CardTitle>Chamado ja avaliado</CardTitle>
<CardDescription>
Voce ja avaliou este chamado anteriormente.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<div className="flex justify-center gap-1 mb-4">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-8 w-8 ${
star <= existingRating ? "fill-amber-400 text-amber-400" : "text-muted"
}`}
/>
))}
</div>
<p className="text-muted-foreground text-sm">
Sua avaliacao: {existingRating} estrela{existingRating > 1 ? "s" : ""}
</p>
</CardContent>
</Card>
</div>
)
}
// Se acabou de avaliar, mostra formulario para comentario
if (submitted && rating > 0) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<CheckCircle className="h-16 w-16 text-emerald-500" />
</div>
<CardTitle>Obrigado pela avaliacao!</CardTitle>
<CardDescription>
Sua opiniao e muito importante para nos.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-8 w-8 ${
star <= rating ? "fill-amber-400 text-amber-400" : "text-muted"
}`}
/>
))}
</div>
<div className="space-y-2">
<Label htmlFor="comment">Gostaria de deixar um comentario? (opcional)</Label>
<Textarea
id="comment"
placeholder="Conte-nos mais sobre sua experiencia..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
</div>
{error && (
<div className="text-destructive text-sm text-center">{error}</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
window.close()
}}
>
Fechar
</Button>
<Button
className="flex-1"
disabled={loading}
onClick={async () => {
if (!comment.trim()) {
window.close()
return
}
setLoading(true)
setError(null)
try {
const response = await fetch("/api/tickets/rate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, rating, comment }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Erro ao enviar comentario")
}
window.close()
} catch (err) {
setError(err instanceof Error ? err.message : "Erro desconhecido")
} finally {
setLoading(false)
}
}}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Enviando...
</>
) : (
"Enviar comentario"
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
// Formulario de avaliacao
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-4">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">R</span>
</div>
<span className="text-xl font-semibold">Raven</span>
</div>
<CardTitle>Como foi o atendimento?</CardTitle>
<CardDescription>
Sua avaliacao nos ajuda a melhorar nosso servico.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Estrelas */}
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
className="p-1 transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
onMouseEnter={() => setHoveredRating(star)}
onMouseLeave={() => setHoveredRating(0)}
onClick={() => setRating(star)}
>
<Star
className={`h-10 w-10 transition-colors ${
star <= (hoveredRating || rating)
? "fill-amber-400 text-amber-400"
: "text-muted hover:text-amber-200"
}`}
/>
</button>
))}
</div>
{/* Labels */}
<div className="flex justify-between text-xs text-muted-foreground px-2">
<span>Ruim</span>
<span>Excelente</span>
</div>
{/* Comentario */}
<div className="space-y-2">
<Label htmlFor="comment">Comentario (opcional)</Label>
<Textarea
id="comment"
placeholder="Conte-nos mais sobre sua experiencia..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{/* Botao */}
<Button
className="w-full"
size="lg"
disabled={rating === 0 || loading}
onClick={async () => {
setLoading(true)
setError(null)
try {
const response = await fetch("/api/tickets/rate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, rating, comment }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Erro ao enviar avaliacao")
}
setSubmitted(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Erro desconhecido")
} finally {
setLoading(false)
}
}}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Enviando...
</>
) : (
"Enviar avaliacao"
)}
</Button>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,38 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { NotificationPreferencesForm } from "@/components/settings/notification-preferences-form"
import { requireAuthenticatedSession } from "@/lib/auth-server"
export const metadata: Metadata = {
title: "Preferencias de notificacao",
description: "Configure quais notificacoes por e-mail deseja receber.",
}
export default async function NotificationSettingsPage() {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
// Apenas staff pode acessar esta pagina
const staffRoles = new Set(["admin", "manager", "agent"])
if (!staffRoles.has(role)) {
redirect("/portal/profile/notifications")
}
return (
<AppShell
header={
<SiteHeader
title="Preferencias de notificacao"
lead="Configure como e quando deseja receber notificacoes por e-mail"
/>
}
>
<div className="mx-auto w-full max-w-3xl px-4 pb-12 lg:px-6">
<NotificationPreferencesForm />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,340 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Loader2, ExternalLink, CheckCircle, AlertCircle, Clock, MessageSquare } from "lucide-react"
import { getTicketStatusMeta } from "@/lib/ticket-status-style"
import { getTicketPriorityMeta } from "@/lib/ticket-priority-style"
interface TicketData {
id: string
reference: number
subject: string
summary: string | null
status: string
priority: string
channel: string
createdAt: string
resolvedAt: string | null
requester: {
id: string
name: string
}
assignee: {
id: string
name: string
} | null
company: {
id: string
name: string
} | null
comments: Array<{
id: string
body: string
createdAt: string
author: {
id: string
name: string
}
}>
rating: {
rating: number
comment: string | null
createdAt: string
} | null
}
interface TokenData {
id: string
scope: string
expiresAt: string
used: boolean
}
export default function TicketViewPage() {
const params = useParams()
const token = params.token as string
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [ticket, setTicket] = useState<TicketData | null>(null)
const [tokenData, setTokenData] = useState<TokenData | null>(null)
const [tryingRaven, setTryingRaven] = useState(true)
const [ravenAvailable, setRavenAvailable] = useState(false)
const loadTicket = useCallback(async () => {
try {
setLoading(true)
const response = await fetch(`/api/ticket-access/${token}`)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Erro ao carregar chamado")
}
const data = await response.json()
setTicket(data.ticket)
setTokenData(data.token)
} catch (err) {
setError(err instanceof Error ? err.message : "Erro desconhecido")
} finally {
setLoading(false)
}
}, [token])
// Tenta abrir o protocolo raven://
useEffect(() => {
if (!token) return
const tryRavenProtocol = async () => {
setTryingRaven(true)
// Tenta abrir o protocolo raven://
const ravenUrl = `raven://ticket/${token}`
// Cria um iframe oculto para detectar se o protocolo foi aceito
const iframe = document.createElement("iframe")
iframe.style.display = "none"
document.body.appendChild(iframe)
let protocolHandled = false
// Listener para detectar se o navegador aceitou o protocolo
const handleBlur = () => {
protocolHandled = true
setRavenAvailable(true)
}
window.addEventListener("blur", handleBlur)
// Tenta navegar para o protocolo
try {
window.location.href = ravenUrl
} catch {
// Protocolo nao suportado
}
// Aguarda 2 segundos para ver se o protocolo foi aceito
setTimeout(() => {
window.removeEventListener("blur", handleBlur)
document.body.removeChild(iframe)
if (!protocolHandled) {
// Protocolo nao foi aceito, carrega o ticket no navegador
setTryingRaven(false)
loadTicket()
}
}, 2000)
}
tryRavenProtocol()
}, [token, loadTicket])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString("pt-BR", {
timeZone: "America/Sao_Paulo",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Mostra tela de "Abrindo no Raven..."
if (tryingRaven) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<Loader2 className="h-12 w-12 animate-spin mx-auto mb-4 text-primary" />
<h2 className="text-xl font-semibold mb-2">Abrindo no Raven...</h2>
<p className="text-muted-foreground text-sm">
Se o aplicativo Raven estiver instalado, ele abrira automaticamente.
</p>
<p className="text-muted-foreground text-sm mt-2">
Caso contrario, o chamado sera exibido aqui em instantes.
</p>
</CardContent>
</Card>
</div>
)
}
// Mostra loading
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<Loader2 className="h-12 w-12 animate-spin mx-auto mb-4 text-primary" />
<h2 className="text-xl font-semibold">Carregando chamado...</h2>
</CardContent>
</Card>
</div>
)
}
// Mostra erro
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-semibold mb-2">Erro ao carregar</h2>
<p className="text-muted-foreground">{error}</p>
<Button className="mt-4" onClick={() => loadTicket()}>
Tentar novamente
</Button>
</CardContent>
</Card>
</div>
)
}
if (!ticket) return null
const statusMeta = getTicketStatusMeta(ticket.status)
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<div className="min-h-screen bg-background p-4">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">R</span>
</div>
<span className="text-xl font-semibold">Raven</span>
</div>
{ravenAvailable && (
<Button variant="outline" size="sm" asChild>
<a href={`raven://ticket/${token}`}>
<ExternalLink className="h-4 w-4 mr-2" />
Abrir no Raven
</a>
</Button>
)}
</div>
{/* Ticket Card */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono">
#{ticket.reference}
</Badge>
<Badge className={statusMeta.badgeClass}>
{statusMeta.label}
</Badge>
<Badge className={priorityMeta.badgeClass}>
{priorityMeta.label}
</Badge>
</div>
<CardTitle className="text-xl">{ticket.subject}</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Informacoes */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Solicitante</span>
<p className="font-medium">{ticket.requester.name}</p>
</div>
<div>
<span className="text-muted-foreground">Responsavel</span>
<p className="font-medium">{ticket.assignee?.name ?? "Nao atribuido"}</p>
</div>
<div>
<span className="text-muted-foreground">Criado em</span>
<p className="font-medium">{formatDate(ticket.createdAt)}</p>
</div>
{ticket.resolvedAt && (
<div>
<span className="text-muted-foreground">Resolvido em</span>
<p className="font-medium">{formatDate(ticket.resolvedAt)}</p>
</div>
)}
{ticket.company && (
<div>
<span className="text-muted-foreground">Empresa</span>
<p className="font-medium">{ticket.company.name}</p>
</div>
)}
</div>
{/* Resumo */}
{ticket.summary && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Descricao</h3>
<p className="text-sm whitespace-pre-wrap">{ticket.summary}</p>
</div>
)}
{/* Avaliacao */}
{ticket.rating && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Avaliacao</span>
</div>
<div className="flex items-center gap-1 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xl ${star <= ticket.rating!.rating ? "text-amber-400" : "text-muted"}`}
>
</span>
))}
</div>
{ticket.rating.comment && (
<p className="text-sm text-muted-foreground">{ticket.rating.comment}</p>
)}
</div>
)}
{/* Comentarios */}
{ticket.comments.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Ultimas atualizacoes
</h3>
<div className="space-y-4">
{ticket.comments.map((comment) => (
<div key={comment.id} className="border-l-2 border-primary pl-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{comment.author.name}</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(comment.createdAt)}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Este link expira em {tokenData ? formatDate(tokenData.expiresAt) : "breve"}.
<br />
Para acesso completo, utilize o aplicativo Raven ou acesse o portal.
</p>
</div>
</div>
)
}

View file

@ -1,9 +1,11 @@
"use client"
import { FormEvent, useMemo, useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Bell } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
@ -115,11 +117,24 @@ export function PortalProfileSettings({ initialEmail }: PortalProfileSettingsPro
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting || !hasChanges}>
{isSubmitting ? "Salvando..." : "Salvar alterações"}
{isSubmitting ? "Salvando..." : "Salvar alteracoes"}
</Button>
</div>
</form>
</CardContent>
<CardFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bell className="h-4 w-4" />
<span>Configurar notificacoes por e-mail</span>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/portal/profile/notifications">
Preferencias
</Link>
</Button>
</div>
</CardFooter>
</Card>
)
}

View file

@ -0,0 +1,361 @@
"use client"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Loader2, Bell, BellOff, Clock, Mail, Lock } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
interface NotificationType {
type: string
label: string
enabled: boolean
required: boolean
canDisable: boolean
}
interface NotificationPreferences {
emailEnabled: boolean
quietHoursStart: string | null
quietHoursEnd: string | null
timezone: string
digestFrequency: string
types: NotificationType[]
categoryPreferences: Record<string, boolean>
isStaff: boolean
}
interface NotificationPreferencesFormProps {
isPortal?: boolean
}
// Agrupamento de tipos de notificacao
const TYPE_GROUPS = {
lifecycle: {
label: "Ciclo de vida do chamado",
description: "Notificacoes sobre abertura, resolucao e mudancas de status",
types: ["ticket_created", "ticket_assigned", "ticket_resolved", "ticket_reopened", "ticket_status_changed", "ticket_priority_changed"],
},
communication: {
label: "Comunicacao",
description: "Comentarios e respostas nos chamados",
types: ["comment_public", "comment_response", "comment_mention"],
},
sla: {
label: "SLA e alertas",
description: "Alertas de prazo e metricas",
types: ["sla_at_risk", "sla_breached", "sla_daily_digest"],
},
security: {
label: "Seguranca",
description: "Notificacoes de autenticacao e acesso",
types: ["security_password_reset", "security_email_verify", "security_email_change", "security_new_login", "security_invite"],
},
}
export function NotificationPreferencesForm({ isPortal = false }: NotificationPreferencesFormProps) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [preferences, setPreferences] = useState<NotificationPreferences | null>(null)
const [localTypePrefs, setLocalTypePrefs] = useState<Record<string, boolean>>({})
useEffect(() => {
loadPreferences()
}, [])
async function loadPreferences() {
try {
const response = await fetch("/api/notifications/preferences")
if (!response.ok) {
throw new Error("Erro ao carregar preferencias")
}
const data = await response.json()
setPreferences(data)
// Inicializa preferencias locais
const typePrefs: Record<string, boolean> = {}
data.types.forEach((t: NotificationType) => {
typePrefs[t.type] = t.enabled
})
setLocalTypePrefs(typePrefs)
} catch (error) {
console.error("Erro ao carregar preferencias:", error)
toast.error("Nao foi possivel carregar suas preferencias")
} finally {
setLoading(false)
}
}
async function savePreferences() {
if (!preferences) return
setSaving(true)
try {
const response = await fetch("/api/notifications/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
emailEnabled: preferences.emailEnabled,
quietHoursStart: preferences.quietHoursStart,
quietHoursEnd: preferences.quietHoursEnd,
timezone: preferences.timezone,
digestFrequency: preferences.digestFrequency,
typePreferences: localTypePrefs,
}),
})
if (!response.ok) {
throw new Error("Erro ao salvar preferencias")
}
toast.success("Preferencias salvas com sucesso")
} catch (error) {
console.error("Erro ao salvar preferencias:", error)
toast.error("Nao foi possivel salvar suas preferencias")
} finally {
setSaving(false)
}
}
function toggleType(type: string, enabled: boolean) {
setLocalTypePrefs(prev => ({ ...prev, [type]: enabled }))
}
function toggleEmailEnabled(enabled: boolean) {
setPreferences(prev => prev ? { ...prev, emailEnabled: enabled } : null)
}
function setQuietHours(start: string | null, end: string | null) {
setPreferences(prev => prev ? { ...prev, quietHoursStart: start, quietHoursEnd: end } : null)
}
function setDigestFrequency(frequency: string) {
setPreferences(prev => prev ? { ...prev, digestFrequency: frequency } : null)
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!preferences) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
Nao foi possivel carregar suas preferencias de notificacao.
</p>
<div className="flex justify-center mt-4">
<Button onClick={loadPreferences}>Tentar novamente</Button>
</div>
</CardContent>
</Card>
)
}
// Filtra tipos visiveis para o usuario
const visibleTypes = preferences.types
// Agrupa tipos por categoria
const groupedTypes: Record<string, NotificationType[]> = {}
for (const [groupKey, group] of Object.entries(TYPE_GROUPS)) {
const types = visibleTypes.filter(t => group.types.includes(t.type))
if (types.length > 0) {
groupedTypes[groupKey] = types
}
}
return (
<div className="space-y-6">
{/* Configuracao global de e-mail */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Notificacoes por e-mail
</CardTitle>
<CardDescription>
Controle se deseja receber notificacoes por e-mail.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Receber e-mails</Label>
<p className="text-sm text-muted-foreground">
{preferences.emailEnabled
? "Voce recebera notificacoes por e-mail conforme suas preferencias"
: "Todas as notificacoes por e-mail estao desativadas"}
</p>
</div>
<Switch
checked={preferences.emailEnabled}
onCheckedChange={toggleEmailEnabled}
/>
</div>
{preferences.emailEnabled && preferences.isStaff && (
<>
<Separator />
{/* Horario de silencio - apenas staff */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<Label className="text-base">Horario de silencio</Label>
</div>
<p className="text-sm text-muted-foreground">
Durante este periodo, notificacoes nao urgentes serao adiadas.
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="quietStart" className="text-sm">Das</Label>
<Input
id="quietStart"
type="time"
value={preferences.quietHoursStart || ""}
onChange={(e) => setQuietHours(e.target.value || null, preferences.quietHoursEnd)}
className="w-32"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="quietEnd" className="text-sm">as</Label>
<Input
id="quietEnd"
type="time"
value={preferences.quietHoursEnd || ""}
onChange={(e) => setQuietHours(preferences.quietHoursStart, e.target.value || null)}
className="w-32"
/>
</div>
{(preferences.quietHoursStart || preferences.quietHoursEnd) && (
<Button
variant="ghost"
size="sm"
onClick={() => setQuietHours(null, null)}
>
Limpar
</Button>
)}
</div>
</div>
<Separator />
{/* Frequencia de resumo - apenas staff */}
<div className="space-y-4">
<Label className="text-base">Frequencia de resumo</Label>
<p className="text-sm text-muted-foreground">
Como voce prefere receber as notificacoes nao urgentes.
</p>
<Select
value={preferences.digestFrequency}
onValueChange={setDigestFrequency}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Imediato</SelectItem>
<SelectItem value="daily">Resumo diario</SelectItem>
<SelectItem value="weekly">Resumo semanal</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</CardContent>
</Card>
{/* Tipos de notificacao */}
{preferences.emailEnabled && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Tipos de notificacao
</CardTitle>
<CardDescription>
Escolha quais tipos de notificacao deseja receber.
{!preferences.isStaff && (
<span className="block mt-1 text-amber-600">
Algumas notificacoes sao obrigatorias e nao podem ser desativadas.
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{Object.entries(groupedTypes).map(([groupKey, types]) => {
const group = TYPE_GROUPS[groupKey as keyof typeof TYPE_GROUPS]
return (
<div key={groupKey} className="space-y-4">
<div>
<h3 className="font-medium">{group.label}</h3>
<p className="text-sm text-muted-foreground">{group.description}</p>
</div>
<div className="space-y-3">
{types.map((notifType) => (
<div
key={notifType.type}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
{notifType.required ? (
<Lock className="h-4 w-4 text-muted-foreground" />
) : localTypePrefs[notifType.type] ? (
<Bell className="h-4 w-4 text-primary" />
) : (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
<div>
<Label className="text-sm font-medium">
{notifType.label}
</Label>
{notifType.required && (
<Badge variant="secondary" className="ml-2 text-xs">
Obrigatorio
</Badge>
)}
</div>
</div>
<Switch
checked={localTypePrefs[notifType.type] ?? notifType.enabled}
onCheckedChange={(checked) => toggleType(notifType.type, checked)}
disabled={!notifType.canDisable}
/>
</div>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Botao de salvar */}
<div className="flex justify-end">
<Button onClick={savePreferences} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando...
</>
) : (
"Salvar preferencias"
)}
</Button>
</div>
</div>
)
}

View file

@ -84,10 +84,18 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
icon: MessageSquareText,
},
{
title: "Preferências da equipe",
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",
title: "Notificacoes por e-mail",
description: "Configure quais notificacoes por e-mail deseja receber e como recebe-las.",
href: "/settings/notifications",
cta: "Configurar notificacoes",
requiredRole: "staff",
icon: BellRing,
},
{
title: "Preferencias da equipe",
description: "Defina padroes de notificacao e comportamento do modo play para toda a equipe.",
href: "#preferencias",
cta: "Ajustar preferências",
cta: "Ajustar preferencias",
requiredRole: "staff",
icon: Settings2,
},

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
}