feat: sistema completo de notificacoes por e-mail
Implementa sistema de notificacoes por e-mail com: - Notificacoes de ciclo de vida (abertura, resolucao, atribuicao, status) - Sistema de avaliacao de chamados com estrelas (1-5) - Deep linking via protocolo raven:// para abrir chamados no desktop - Tokens de acesso seguro para visualizacao sem login - Preferencias de notificacao configuraveis por usuario - Templates HTML responsivos com design tokens da plataforma - API completa para preferencias, tokens e avaliacoes Modelos Prisma: - TicketRating: avaliacoes de chamados - TicketAccessToken: tokens de acesso direto - NotificationPreferences: preferencias por usuario Turbopack como bundler padrao (Next.js 16) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cb6add1a4a
commit
f2c0298285
23 changed files with 4387 additions and 9 deletions
103
apps/desktop/src-tauri/Cargo.lock
generated
103
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -75,6 +75,7 @@ dependencies = [
|
|||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
|
|
@ -545,6 +546,26 @@ dependencies = [
|
|||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
|
|
@ -653,6 +674,12 @@ version = "0.8.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
|
|
@ -850,6 +877,15 @@ dependencies = [
|
|||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
|
|
@ -1539,6 +1575,12 @@ version = "0.12.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
|
|
@ -2673,6 +2715,16 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
|
|
@ -3429,6 +3481,16 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.26"
|
||||
|
|
@ -4217,6 +4279,27 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
|
|
@ -4523,6 +4606,15 @@ dependencies = [
|
|||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
|
|
@ -5404,6 +5496,17 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ tauri-plugin-store = "2.4.0"
|
|||
tauri-plugin-updater = "2.9.0"
|
||||
tauri-plugin-process = "2.3.0"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfi
|
|||
use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse};
|
||||
use chrono::Local;
|
||||
use usb_control::{UsbPolicy, UsbPolicyResult};
|
||||
use tauri::{Emitter, Manager, WindowEvent};
|
||||
use tauri::{Emitter, Listener, Manager, WindowEvent};
|
||||
use tauri_plugin_store::Builder as StorePluginBuilder;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
|
|
@ -348,6 +348,81 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
|
|||
chat::set_chat_minimized(&app, &ticket_id, minimized)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler de Deep Link (raven://)
|
||||
// ============================================================================
|
||||
|
||||
/// Processa URLs do protocolo raven://
|
||||
/// Formatos suportados:
|
||||
/// - raven://ticket/{token} - Abre visualizacao do chamado
|
||||
/// - raven://chat/{ticketId}?token={token} - Abre chat do chamado
|
||||
/// - raven://rate/{token} - Abre avaliacao do chamado
|
||||
fn handle_deep_link(app: &tauri::AppHandle, url: &str) {
|
||||
log_info!("Processando deep link: {url}");
|
||||
|
||||
// Remove o prefixo raven://
|
||||
let path = url.trim_start_matches("raven://");
|
||||
|
||||
// Parse do path
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
log_warn!("Deep link invalido: path vazio");
|
||||
return;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"ticket" => {
|
||||
if parts.len() > 1 {
|
||||
let token = parts[1].split('?').next().unwrap_or(parts[1]);
|
||||
log_info!("Abrindo ticket com token: {token}");
|
||||
|
||||
// Mostra a janela principal
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
|
||||
// Emite evento para o frontend navegar para o ticket
|
||||
let _ = app.emit("raven://deep-link/ticket", serde_json::json!({
|
||||
"token": token
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"chat" => {
|
||||
if parts.len() > 1 {
|
||||
let ticket_id = parts[1].split('?').next().unwrap_or(parts[1]);
|
||||
log_info!("Abrindo chat do ticket: {ticket_id}");
|
||||
|
||||
// Abre janela de chat
|
||||
if let Err(e) = chat::open_chat_window(app, ticket_id) {
|
||||
log_error!("Falha ao abrir chat: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"rate" => {
|
||||
if parts.len() > 1 {
|
||||
let token = parts[1].split('?').next().unwrap_or(parts[1]);
|
||||
log_info!("Abrindo avaliacao com token: {token}");
|
||||
|
||||
// Mostra a janela principal
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
|
||||
// Emite evento para o frontend navegar para avaliacao
|
||||
let _ = app.emit("raven://deep-link/rate", serde_json::json!({
|
||||
"token": token
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log_warn!("Deep link desconhecido: {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
|
@ -358,6 +433,7 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
|
|
@ -372,6 +448,17 @@ pub fn run() {
|
|||
|
||||
log_info!("Raven iniciando...");
|
||||
|
||||
// Configura handler de deep link (raven://)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
app.listen("deep-link://new-url", move |event| {
|
||||
let urls = event.payload();
|
||||
log_info!("Deep link recebido: {urls}");
|
||||
handle_deep_link(&handle, urls);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
setup_raven_autostart();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@
|
|||
"dialog": true,
|
||||
"active": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK"
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["raven"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
|
|
|||
476
docs/EMAIL-NOTIFICATIONS.md
Normal file
476
docs/EMAIL-NOTIFICATIONS.md
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
# Sistema de Notificacoes por E-mail
|
||||
|
||||
Este documento descreve o sistema completo de notificacoes por e-mail implementado na plataforma Raven.
|
||||
|
||||
## Visao Geral
|
||||
|
||||
O sistema de notificacoes por e-mail permite que usuarios sejam informados sobre eventos importantes relacionados aos seus chamados, alem de notificacoes de seguranca e autenticacao.
|
||||
|
||||
### Principais Funcionalidades
|
||||
|
||||
- Notificacoes de ciclo de vida do chamado (abertura, resolucao, atribuicao)
|
||||
- Notificacoes de comunicacao (comentarios publicos, respostas)
|
||||
- Sistema de avaliacao de chamados (estrelas 1-5)
|
||||
- Acesso direto ao chamado via protocolo `raven://`
|
||||
- Templates HTML responsivos
|
||||
- Sistema de preferencias configuravel
|
||||
- Alertas de SLA para staff
|
||||
- Notificacoes de seguranca (reset de senha, novo login)
|
||||
|
||||
---
|
||||
|
||||
## Tipos de Notificacoes
|
||||
|
||||
### Ciclo de Vida do Chamado
|
||||
|
||||
| Tipo | Codigo | Destinatario | Pode Desativar? |
|
||||
|------|--------|-------------|-----------------|
|
||||
| Abertura de chamado | `ticket_created` | Solicitante | Nao (obrigatorio) |
|
||||
| Atribuicao de chamado | `ticket_assigned` | Solicitante + Agente | Sim |
|
||||
| Resolucao de chamado | `ticket_resolved` | Solicitante | Nao (obrigatorio) |
|
||||
| Reabertura de chamado | `ticket_reopened` | Solicitante + Agente | Sim |
|
||||
| Mudanca de status | `ticket_status_changed` | Solicitante | Sim |
|
||||
| Mudanca de prioridade | `ticket_priority_changed` | Agente atribuido | Sim |
|
||||
|
||||
**Descricoes detalhadas:**
|
||||
|
||||
- **Abertura de chamado**: Enviado ao solicitante quando um novo chamado e criado, confirmando o numero de referencia e detalhes do chamado.
|
||||
|
||||
- **Atribuicao de chamado**: Notifica tanto o solicitante quanto o agente atribuido quando um chamado e designado para atendimento.
|
||||
|
||||
- **Resolucao de chamado**: Enviado ao solicitante quando o chamado e marcado como resolvido. Inclui link para avaliacao com estrelas (1-5).
|
||||
|
||||
- **Reabertura de chamado**: Notifica todas as partes quando um chamado fechado e reaberto.
|
||||
|
||||
- **Mudanca de status**: Informa o solicitante sobre alteracoes de status (Em andamento, Pausado, etc).
|
||||
|
||||
- **Mudanca de prioridade**: Notifica o agente atribuido quando a prioridade do chamado e alterada.
|
||||
|
||||
### Comunicacao
|
||||
|
||||
| Tipo | Codigo | Destinatario | Pode Desativar? |
|
||||
|------|--------|-------------|-----------------|
|
||||
| Comentario publico | `comment_public` | Solicitante | Sim (apenas solicitante) |
|
||||
| Resposta do solicitante | `comment_response` | Agente atribuido | Sim |
|
||||
| Mencao em comentario | `comment_mention` | Usuario mencionado | Sim |
|
||||
|
||||
**Descricoes detalhadas:**
|
||||
|
||||
- **Comentario publico**: Enviado ao solicitante quando um agente adiciona um comentario publico ao chamado.
|
||||
|
||||
- **Resposta do solicitante**: Notifica o agente atribuido quando o solicitante responde ao chamado.
|
||||
|
||||
- **Mencao em comentario**: Notifica um usuario quando ele e mencionado (@usuario) em um comentario.
|
||||
|
||||
### SLA e Alertas (Apenas Staff)
|
||||
|
||||
| Tipo | Codigo | Destinatario | Pode Desativar? |
|
||||
|------|--------|-------------|-----------------|
|
||||
| SLA em risco | `sla_at_risk` | Agente + Supervisor | Sim |
|
||||
| SLA violado | `sla_breached` | Agente + Admin | Sim |
|
||||
| Resumo diario de SLA | `sla_daily_digest` | Admin | Sim |
|
||||
|
||||
**Descricoes detalhadas:**
|
||||
|
||||
- **SLA em risco**: Enviado quando o chamado atinge 80% do tempo limite de resposta/resolucao.
|
||||
|
||||
- **SLA violado**: Notificacao urgente quando o SLA e excedido.
|
||||
|
||||
- **Resumo diario de SLA**: Relatorio enviado diariamente com metricas de SLA do dia anterior.
|
||||
|
||||
### Seguranca e Autenticacao
|
||||
|
||||
| Tipo | Codigo | Destinatario | Pode Desativar? |
|
||||
|------|--------|-------------|-----------------|
|
||||
| Reset de senha | `security_password_reset` | Usuario | Nao (obrigatorio) |
|
||||
| Verificacao de e-mail | `security_email_verify` | Usuario | Nao (obrigatorio) |
|
||||
| Alteracao de e-mail | `security_email_change` | E-mail antigo | Nao (obrigatorio) |
|
||||
| Novo login detectado | `security_new_login` | Usuario | Sim |
|
||||
| Convite de usuario | `security_invite` | Novo usuario | Nao (obrigatorio) |
|
||||
|
||||
**Descricoes detalhadas:**
|
||||
|
||||
- **Reset de senha**: Link para redefinicao de senha. Expira em 1 hora.
|
||||
|
||||
- **Verificacao de e-mail**: Link para confirmar novo endereco de e-mail.
|
||||
|
||||
- **Alteracao de e-mail**: Notifica o e-mail antigo quando o endereco e alterado.
|
||||
|
||||
- **Novo login detectado**: Alerta quando um login e realizado de um novo dispositivo ou localizacao.
|
||||
|
||||
- **Convite de usuario**: Convite para novos usuarios criarem conta na plataforma.
|
||||
|
||||
---
|
||||
|
||||
## Sistema de Avaliacao de Chamados
|
||||
|
||||
Quando um chamado e resolvido, o solicitante recebe um e-mail com opcao de avaliar o atendimento usando estrelas de 1 a 5.
|
||||
|
||||
### Fluxo de Avaliacao
|
||||
|
||||
1. Chamado e marcado como resolvido
|
||||
2. Solicitante recebe e-mail com 5 estrelas clicaveis
|
||||
3. Ao clicar em uma estrela, usuario e redirecionado para pagina de avaliacao
|
||||
4. Na pagina, pode adicionar um comentario opcional
|
||||
5. Avaliacao e registrada e associada ao chamado
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `GET /api/tickets/rate?token={token}&rating={1-5}` - Registra avaliacao via link do e-mail
|
||||
- `POST /api/tickets/rate` - Registra avaliacao com comentario
|
||||
|
||||
### Modelo de Dados
|
||||
|
||||
```prisma
|
||||
model TicketRating {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
ticketId String @unique
|
||||
userId String
|
||||
rating Int // 1-5 estrelas
|
||||
comment String? // Feedback opcional
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acesso Direto ao Chamado (Deep Linking)
|
||||
|
||||
### Protocolo raven://
|
||||
|
||||
O sistema suporta o protocolo `raven://` para abrir chamados diretamente no aplicativo desktop Raven.
|
||||
|
||||
**Formatos suportados:**
|
||||
|
||||
```
|
||||
raven://ticket/{ticketId}?token={accessToken}
|
||||
raven://chat/{ticketId}?token={accessToken}
|
||||
raven://rate/{ticketId}?rating={1-5}&token={accessToken}
|
||||
```
|
||||
|
||||
### Fluxo de Acesso
|
||||
|
||||
1. Usuario clica no link do e-mail (ex: `/ticket-view/{token}`)
|
||||
2. Pagina web tenta abrir o protocolo `raven://`
|
||||
3. Se o Raven estiver instalado, abre diretamente no app
|
||||
4. Se nao estiver instalado (timeout de 3s), exibe o chamado na web
|
||||
|
||||
### Tokens de Acesso
|
||||
|
||||
Tokens sao gerados para permitir acesso seguro sem necessidade de login.
|
||||
|
||||
```typescript
|
||||
interface TicketAccessToken {
|
||||
token: string // Token unico
|
||||
ticketId: string // Chamado associado
|
||||
userId: string // Usuario autorizado
|
||||
machineId?: string // Maquina esperada (opcional)
|
||||
scope: string // 'view', 'interact', 'rate'
|
||||
expiresAt: Date // Expira em 7 dias
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `GET /api/ticket-access/{token}` - Valida token e retorna informacoes
|
||||
- `POST /api/ticket-access/{token}` - Marca token como usado
|
||||
|
||||
---
|
||||
|
||||
## Preferencias de Notificacao
|
||||
|
||||
### Para Staff (Admin, Manager, Agent)
|
||||
|
||||
Staff tem controle total sobre suas preferencias:
|
||||
|
||||
- Ativar/desativar e-mails globalmente
|
||||
- Configurar horario de silencio
|
||||
- Escolher frequencia de resumo (imediato, diario, semanal)
|
||||
- Ativar/desativar cada tipo de notificacao individualmente
|
||||
- Configurar preferencias por categoria
|
||||
|
||||
**Acesso:** `/settings/notifications`
|
||||
|
||||
### Para Solicitantes (Collaborators)
|
||||
|
||||
Solicitantes tem opcoes limitadas:
|
||||
|
||||
**Podem desativar:**
|
||||
- Comentarios publicos
|
||||
- Mudancas de status
|
||||
- Mudancas de prioridade
|
||||
- Novo login detectado
|
||||
|
||||
**NAO podem desativar (obrigatorios):**
|
||||
- Abertura de chamado
|
||||
- Resolucao de chamado
|
||||
- Reset de senha
|
||||
- Verificacao/alteracao de e-mail
|
||||
- Convites
|
||||
|
||||
**Acesso:** `/portal/profile/notifications`
|
||||
|
||||
### Modelo de Dados
|
||||
|
||||
```prisma
|
||||
model NotificationPreferences {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
tenantId String
|
||||
emailEnabled Boolean @default(true)
|
||||
quietHoursStart String? // "22:00"
|
||||
quietHoursEnd String? // "08:00"
|
||||
timezone String @default("America/Sao_Paulo")
|
||||
digestFrequency String @default("immediate")
|
||||
typePreferences Json @default("{}")
|
||||
categoryPreferences Json @default("{}")
|
||||
}
|
||||
```
|
||||
|
||||
### API de Preferencias
|
||||
|
||||
- `GET /api/notifications/preferences` - Retorna preferencias do usuario
|
||||
- `PUT /api/notifications/preferences` - Atualiza preferencias
|
||||
|
||||
---
|
||||
|
||||
## Templates de E-mail
|
||||
|
||||
Todos os e-mails utilizam templates HTML responsivos com design consistente com a plataforma.
|
||||
|
||||
### Templates Disponiveis
|
||||
|
||||
| Template | Arquivo | Descricao |
|
||||
|----------|---------|-----------|
|
||||
| Teste | `test` | Template para testes de envio |
|
||||
| Abertura | `ticket_created` | Confirmacao de abertura de chamado |
|
||||
| Resolucao | `ticket_resolved` | Notificacao de resolucao com avaliacao |
|
||||
| Atribuicao | `ticket_assigned` | Notificacao de atribuicao |
|
||||
| Status | `ticket_status` | Mudanca de status |
|
||||
| Comentario | `ticket_comment` | Novo comentario publico |
|
||||
| Reset de senha | `password_reset` | Link para redefinir senha |
|
||||
| Verificar e-mail | `email_verify` | Confirmacao de e-mail |
|
||||
| Convite | `invite` | Convite para nova conta |
|
||||
| Novo login | `new_login` | Alerta de novo dispositivo |
|
||||
| SLA em risco | `sla_warning` | Alerta de SLA proximo do limite |
|
||||
| SLA violado | `sla_breached` | Alerta de SLA excedido |
|
||||
|
||||
### Tokens de Design
|
||||
|
||||
Os templates utilizam os mesmos tokens de design da plataforma:
|
||||
|
||||
```css
|
||||
/* Cores principais */
|
||||
--primary: #00e8ff
|
||||
--primary-dark: #00c4d6
|
||||
--background: #f7f8fb
|
||||
|
||||
/* Status */
|
||||
--status-pending: #64748b
|
||||
--status-progress: #0ea5e9
|
||||
--status-paused: #f59e0b
|
||||
--status-resolved: #10b981
|
||||
|
||||
/* Prioridade */
|
||||
--priority-low: #64748b
|
||||
--priority-medium: #0ea5e9
|
||||
--priority-high: #f97316
|
||||
--priority-urgent: #ef4444
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Estrutura de Arquivos
|
||||
|
||||
```
|
||||
src/
|
||||
├── server/
|
||||
│ ├── email/
|
||||
│ │ ├── email-service.ts # Servico principal de envio
|
||||
│ │ ├── email-templates.ts # Renderizacao de templates
|
||||
│ │ └── index.ts # Exports
|
||||
│ └── notification/
|
||||
│ ├── notification-service.ts # Orquestrador de notificacoes
|
||||
│ └── token-service.ts # Gerenciamento de tokens
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── notifications/
|
||||
│ │ │ └── preferences/
|
||||
│ │ │ └── route.ts # API de preferencias
|
||||
│ │ ├── ticket-access/
|
||||
│ │ │ └── [token]/
|
||||
│ │ │ └── route.ts # API de tokens de acesso
|
||||
│ │ └── tickets/
|
||||
│ │ └── rate/
|
||||
│ │ └── route.ts # API de avaliacao
|
||||
│ ├── ticket-view/
|
||||
│ │ └── [token]/
|
||||
│ │ └── page.tsx # Pagina de visualizacao
|
||||
│ ├── rate/
|
||||
│ │ └── [token]/
|
||||
│ │ └── page.tsx # Pagina de avaliacao
|
||||
│ ├── settings/
|
||||
│ │ └── notifications/
|
||||
│ │ └── page.tsx # Preferencias (staff)
|
||||
│ └── portal/
|
||||
│ └── profile/
|
||||
│ └── notifications/
|
||||
│ └── page.tsx # Preferencias (colaboradores)
|
||||
└── components/
|
||||
└── settings/
|
||||
└── notification-preferences-form.tsx
|
||||
```
|
||||
|
||||
### Servicos Principais
|
||||
|
||||
#### EmailService (`email-service.ts`)
|
||||
|
||||
Responsavel pelo envio de e-mails:
|
||||
|
||||
```typescript
|
||||
await sendEmail({
|
||||
to: 'usuario@email.com',
|
||||
subject: 'Assunto do e-mail',
|
||||
html: '<html>...</html>',
|
||||
text: 'Versao texto',
|
||||
})
|
||||
```
|
||||
|
||||
#### NotificationService (`notification-service.ts`)
|
||||
|
||||
Orquestra o envio de notificacoes:
|
||||
|
||||
```typescript
|
||||
// Notifica abertura de chamado
|
||||
await notifyTicketCreated(ticket)
|
||||
|
||||
// Notifica resolucao
|
||||
await notifyTicketResolved(ticket, resolutionSummary)
|
||||
|
||||
// Notifica comentario
|
||||
await notifyPublicComment(ticket, comment)
|
||||
```
|
||||
|
||||
#### TokenService (`token-service.ts`)
|
||||
|
||||
Gerencia tokens de acesso:
|
||||
|
||||
```typescript
|
||||
// Gera token
|
||||
const token = await generateAccessToken({
|
||||
ticketId: 'xxx',
|
||||
userId: 'xxx',
|
||||
scope: 'view',
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
// Valida token
|
||||
const validated = await validateAccessToken(token)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuracao SMTP
|
||||
|
||||
O sistema utiliza SMTP para envio de e-mails. Consulte `docs/SMTP.md` para configuracao detalhada.
|
||||
|
||||
### Variaveis de Ambiente
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.exemplo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=usuario
|
||||
SMTP_PASS=senha
|
||||
SMTP_FROM=noreply@exemplo.com
|
||||
SMTP_FROM_NAME=Raven
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seguranca
|
||||
|
||||
### Protecoes Implementadas
|
||||
|
||||
1. **Tokens expiraveis**: Tokens de acesso expiram em 7 dias
|
||||
2. **Uso unico para acoes**: Tokens de avaliacao sao invalidados apos uso
|
||||
3. **Rate limiting**: Limite de envios por usuario/minuto
|
||||
4. **Sanitizacao HTML**: Todo conteudo dinamico e escapado
|
||||
5. **Links HTTPS**: Todos os links utilizam HTTPS
|
||||
6. **Preferencias obrigatorias**: Notificacoes de seguranca nao podem ser desativadas
|
||||
|
||||
### Validacao de Tokens
|
||||
|
||||
```typescript
|
||||
// Verifica escopo do token
|
||||
if (!hasScope(token.scope, 'rate')) {
|
||||
return { error: 'Permissao insuficiente' }
|
||||
}
|
||||
|
||||
// Verifica expiracao
|
||||
if (new Date() > token.expiresAt) {
|
||||
return { error: 'Token expirado' }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testes
|
||||
|
||||
### E-mail de Teste
|
||||
|
||||
Para testar o sistema, utilize o endpoint de teste:
|
||||
|
||||
```typescript
|
||||
POST /api/email/test
|
||||
{
|
||||
"to": "email@teste.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Variaveis de Teste
|
||||
|
||||
```env
|
||||
TEST_EMAIL_RECIPIENT=monkeyesdras@gmail.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compatibilidade
|
||||
|
||||
### Clientes de E-mail Suportados
|
||||
|
||||
- Gmail (web + app)
|
||||
- Outlook (web + desktop + app)
|
||||
- Apple Mail (macOS + iOS)
|
||||
- Yahoo Mail
|
||||
- Thunderbird
|
||||
|
||||
### Tecnicas de Compatibilidade
|
||||
|
||||
- Layout com tabelas (nao flexbox/grid)
|
||||
- CSS inline (nao blocos `<style>`)
|
||||
- Imagens com atributo `alt`
|
||||
- Fallback para fontes do sistema
|
||||
- Max-width com media queries
|
||||
|
||||
---
|
||||
|
||||
## Proximos Passos
|
||||
|
||||
### Funcionalidades Planejadas
|
||||
|
||||
- [ ] Resumos digest (diario/semanal)
|
||||
- [ ] Preferencias por categoria
|
||||
- [ ] Relatorios semanais para admins
|
||||
- [ ] Notificacoes de backup
|
||||
- [ ] Fila de envio com retry
|
||||
|
||||
### Melhorias
|
||||
|
||||
- [ ] Testes automatizados para templates
|
||||
- [ ] Preview de e-mail na interface
|
||||
- [ ] Metricas de entrega
|
||||
- [ ] Integracao com servicos de e-mail marketing
|
||||
|
|
@ -5,10 +5,10 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "prisma generate",
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"dev:webpack": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:turbopack": "next build --turbopack",
|
||||
"build": "next build",
|
||||
"build:webpack": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
|
|
|
|||
|
|
@ -145,6 +145,11 @@ model User {
|
|||
reports User[] @relation("UserManager")
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
|
||||
// Relações do sistema de notificações
|
||||
ticketRatings TicketRating[]
|
||||
ticketAccessTokens TicketAccessToken[]
|
||||
notificationPreferences NotificationPreferences?
|
||||
|
||||
@@index([tenantId, role])
|
||||
@@index([tenantId, companyId])
|
||||
@@index([tenantId, managerId])
|
||||
|
|
@ -202,6 +207,10 @@ model Ticket {
|
|||
events TicketEvent[]
|
||||
comments TicketComment[]
|
||||
|
||||
// Relações do sistema de notificações
|
||||
rating TicketRating?
|
||||
accessTokens TicketAccessToken[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, queueId])
|
||||
@@index([tenantId, assigneeId])
|
||||
|
|
@ -398,3 +407,71 @@ model AuthVerification {
|
|||
|
||||
@@index([identifier])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sistema de Notificações por E-mail
|
||||
// ============================================
|
||||
|
||||
model TicketRating {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
ticketId String @unique
|
||||
userId String
|
||||
rating Int // 1-5 estrelas
|
||||
comment String? // Feedback opcional do usuário
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([tenantId, rating])
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
|
||||
model TicketAccessToken {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
token String @unique
|
||||
ticketId String
|
||||
userId String
|
||||
machineId String? // Máquina esperada (opcional)
|
||||
scope String @default("view") // view, interact, rate
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([tenantId, ticketId])
|
||||
@@index([tenantId, userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model NotificationPreferences {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
userId String @unique
|
||||
|
||||
// Preferências globais
|
||||
emailEnabled Boolean @default(true)
|
||||
quietHoursStart String? // "22:00"
|
||||
quietHoursEnd String? // "08:00"
|
||||
timezone String @default("America/Sao_Paulo")
|
||||
digestFrequency String @default("immediate") // immediate, daily, weekly
|
||||
|
||||
// Preferências por tipo de notificação (JSON)
|
||||
// Ex: { "ticket_created": true, "ticket_resolved": true, "comment_public": false }
|
||||
typePreferences Json @default("{}")
|
||||
|
||||
// Preferências por categoria de ticket (JSON)
|
||||
// Ex: { "category_id_1": true, "category_id_2": false }
|
||||
categoryPreferences Json @default("{}")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
|
|
|||
231
src/app/api/notifications/preferences/route.ts
Normal file
231
src/app/api/notifications/preferences/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
181
src/app/api/ticket-access/[token]/route.ts
Normal file
181
src/app/api/ticket-access/[token]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
203
src/app/api/tickets/rate/route.ts
Normal file
203
src/app/api/tickets/rate/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
43
src/app/portal/profile/notifications/page.tsx
Normal file
43
src/app/portal/profile/notifications/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
src/app/rate/[token]/page.tsx
Normal file
270
src/app/rate/[token]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/app/settings/notifications/page.tsx
Normal file
38
src/app/settings/notifications/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
340
src/app/ticket-view/[token]/page.tsx
Normal file
340
src/app/ticket-view/[token]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
361
src/components/settings/notification-preferences-form.tsx
Normal file
361
src/components/settings/notification-preferences-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
305
src/server/email/email-service.ts
Normal file
305
src/server/email/email-service.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/**
|
||||
* Serviço centralizado de envio de e-mails
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
import { sendSmtpMail } from "../email-smtp"
|
||||
import { renderTemplate, type TemplateData, type TemplateName } from "./email-templates"
|
||||
|
||||
// ============================================
|
||||
// Tipos
|
||||
// ============================================
|
||||
|
||||
export type NotificationType =
|
||||
// Ciclo de vida do ticket
|
||||
| "ticket_created"
|
||||
| "ticket_assigned"
|
||||
| "ticket_resolved"
|
||||
| "ticket_reopened"
|
||||
| "ticket_status_changed"
|
||||
| "ticket_priority_changed"
|
||||
// Comunicação
|
||||
| "comment_public"
|
||||
| "comment_response"
|
||||
| "comment_mention"
|
||||
// SLA
|
||||
| "sla_at_risk"
|
||||
| "sla_breached"
|
||||
| "sla_daily_digest"
|
||||
// Autenticação
|
||||
| "security_password_reset"
|
||||
| "security_email_verify"
|
||||
| "security_email_change"
|
||||
| "security_new_login"
|
||||
| "security_invite"
|
||||
|
||||
export interface NotificationConfig {
|
||||
label: string
|
||||
defaultEnabled: boolean
|
||||
staffOnly?: boolean
|
||||
required?: boolean
|
||||
collaboratorCanDisable?: boolean
|
||||
}
|
||||
|
||||
export const NOTIFICATION_TYPES: Record<NotificationType, NotificationConfig> = {
|
||||
// Ciclo de vida do ticket
|
||||
ticket_created: {
|
||||
label: "Abertura de chamado",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
ticket_assigned: {
|
||||
label: "Atribuição de chamado",
|
||||
defaultEnabled: true,
|
||||
collaboratorCanDisable: false,
|
||||
},
|
||||
ticket_resolved: {
|
||||
label: "Resolução de chamado",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
ticket_reopened: {
|
||||
label: "Reabertura de chamado",
|
||||
defaultEnabled: true,
|
||||
collaboratorCanDisable: true,
|
||||
},
|
||||
ticket_status_changed: {
|
||||
label: "Mudança de status",
|
||||
defaultEnabled: false,
|
||||
collaboratorCanDisable: true,
|
||||
},
|
||||
ticket_priority_changed: {
|
||||
label: "Mudança de prioridade",
|
||||
defaultEnabled: true,
|
||||
collaboratorCanDisable: true,
|
||||
},
|
||||
// Comunicação
|
||||
comment_public: {
|
||||
label: "Comentários públicos",
|
||||
defaultEnabled: true,
|
||||
collaboratorCanDisable: true,
|
||||
},
|
||||
comment_response: {
|
||||
label: "Resposta do solicitante",
|
||||
defaultEnabled: true,
|
||||
staffOnly: true,
|
||||
},
|
||||
comment_mention: {
|
||||
label: "Menções em comentários",
|
||||
defaultEnabled: true,
|
||||
staffOnly: true,
|
||||
},
|
||||
// SLA
|
||||
sla_at_risk: {
|
||||
label: "SLA em risco",
|
||||
defaultEnabled: true,
|
||||
staffOnly: true,
|
||||
},
|
||||
sla_breached: {
|
||||
label: "SLA violado",
|
||||
defaultEnabled: true,
|
||||
staffOnly: true,
|
||||
},
|
||||
sla_daily_digest: {
|
||||
label: "Resumo diário de SLA",
|
||||
defaultEnabled: false,
|
||||
staffOnly: true,
|
||||
},
|
||||
// Autenticação
|
||||
security_password_reset: {
|
||||
label: "Redefinição de senha",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
security_email_verify: {
|
||||
label: "Verificação de e-mail",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
security_email_change: {
|
||||
label: "Alteração de e-mail",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
security_new_login: {
|
||||
label: "Novo login detectado",
|
||||
defaultEnabled: true,
|
||||
collaboratorCanDisable: false,
|
||||
},
|
||||
security_invite: {
|
||||
label: "Convite de usuário",
|
||||
defaultEnabled: true,
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
|
||||
export interface EmailRecipient {
|
||||
email: string
|
||||
name?: string
|
||||
userId?: string
|
||||
role?: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR"
|
||||
}
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: EmailRecipient | EmailRecipient[]
|
||||
subject: string
|
||||
template: TemplateName
|
||||
data: TemplateData
|
||||
notificationType?: NotificationType
|
||||
tenantId?: string
|
||||
skipPreferenceCheck?: boolean
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean
|
||||
skipped?: boolean
|
||||
reason?: string
|
||||
recipientCount?: number
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Configuração SMTP
|
||||
// ============================================
|
||||
|
||||
function getSmtpConfig() {
|
||||
const host = process.env.SMTP_HOST
|
||||
const port = process.env.SMTP_PORT
|
||||
const username = process.env.SMTP_USER
|
||||
const password = process.env.SMTP_PASS
|
||||
const fromEmail = process.env.SMTP_FROM_EMAIL
|
||||
const fromName = process.env.SMTP_FROM_NAME ?? "Sistema de Chamados"
|
||||
|
||||
if (!host || !port || !username || !password || !fromEmail) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
username,
|
||||
password,
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
tls: process.env.SMTP_SECURE === "true",
|
||||
rejectUnauthorized: false,
|
||||
timeoutMs: 15000,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Serviço de E-mail
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Envia um e-mail usando o sistema de templates
|
||||
*/
|
||||
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const config = getSmtpConfig()
|
||||
|
||||
if (!config) {
|
||||
console.warn("[EmailService] SMTP não configurado, e-mail ignorado")
|
||||
return { success: false, skipped: true, reason: "smtp_not_configured" }
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(options.to) ? options.to : [options.to]
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return { success: false, skipped: true, reason: "no_recipients" }
|
||||
}
|
||||
|
||||
// Renderiza o template
|
||||
const html = renderTemplate(options.template, options.data)
|
||||
|
||||
// Extrai apenas os e-mails
|
||||
const emailAddresses = recipients.map((r) => r.email)
|
||||
|
||||
try {
|
||||
await sendSmtpMail(config, emailAddresses, options.subject, html)
|
||||
|
||||
console.log(`[EmailService] E-mail enviado para ${emailAddresses.length} destinatário(s)`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recipientCount: emailAddresses.length,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[EmailService] Erro ao enviar e-mail:", error)
|
||||
return {
|
||||
success: false,
|
||||
reason: error instanceof Error ? error.message : "unknown_error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um tipo de notificação é obrigatório
|
||||
*/
|
||||
export function isRequiredNotification(type: NotificationType): boolean {
|
||||
return NOTIFICATION_TYPES[type]?.required === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um tipo de notificação é apenas para staff
|
||||
*/
|
||||
export function isStaffOnlyNotification(type: NotificationType): boolean {
|
||||
return NOTIFICATION_TYPES[type]?.staffOnly === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um colaborador pode desativar um tipo de notificação
|
||||
*/
|
||||
export function canCollaboratorDisable(type: NotificationType): boolean {
|
||||
return NOTIFICATION_TYPES[type]?.collaboratorCanDisable === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o label de um tipo de notificação
|
||||
*/
|
||||
export function getNotificationLabel(type: NotificationType): string {
|
||||
return NOTIFICATION_TYPES[type]?.label ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna todos os tipos de notificação disponíveis para um role
|
||||
*/
|
||||
export function getAvailableNotificationTypes(role: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR"): NotificationType[] {
|
||||
const isStaff = ["ADMIN", "MANAGER", "AGENT"].includes(role)
|
||||
|
||||
return (Object.entries(NOTIFICATION_TYPES) as [NotificationType, NotificationConfig][])
|
||||
.filter(([, config]) => {
|
||||
if (config.staffOnly && !isStaff) return false
|
||||
return true
|
||||
})
|
||||
.map(([type]) => type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna os tipos de notificação que um colaborador pode desativar
|
||||
*/
|
||||
export function getCollaboratorDisableableTypes(): NotificationType[] {
|
||||
return (Object.entries(NOTIFICATION_TYPES) as [NotificationType, NotificationConfig][])
|
||||
.filter(([, config]) => config.collaboratorCanDisable === true)
|
||||
.map(([type]) => type)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// E-mail de Teste
|
||||
// ============================================
|
||||
|
||||
const TEST_EMAIL_RECIPIENT = process.env.TEST_EMAIL_RECIPIENT ?? "monkeyesdras@gmail.com"
|
||||
|
||||
/**
|
||||
* Envia um e-mail de teste
|
||||
*/
|
||||
export async function sendTestEmail(to?: string): Promise<SendEmailResult> {
|
||||
return sendEmail({
|
||||
to: { email: to ?? TEST_EMAIL_RECIPIENT },
|
||||
subject: "Teste - Sistema de Chamados Raven",
|
||||
template: "test",
|
||||
data: {
|
||||
title: "E-mail de Teste",
|
||||
message: "Este é um e-mail de teste do Sistema de Chamados Raven.",
|
||||
timestamp: new Date().toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" }),
|
||||
},
|
||||
skipPreferenceCheck: true,
|
||||
})
|
||||
}
|
||||
767
src/server/email/email-templates.ts
Normal file
767
src/server/email/email-templates.ts
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
/**
|
||||
* Sistema de Templates de E-mail
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Tipos
|
||||
// ============================================
|
||||
|
||||
export type TemplateName =
|
||||
| "test"
|
||||
| "ticket_created"
|
||||
| "ticket_resolved"
|
||||
| "ticket_assigned"
|
||||
| "ticket_status"
|
||||
| "ticket_comment"
|
||||
| "password_reset"
|
||||
| "email_verify"
|
||||
| "invite"
|
||||
| "new_login"
|
||||
| "sla_warning"
|
||||
| "sla_breached"
|
||||
|
||||
export type TemplateData = Record<string, unknown>
|
||||
|
||||
// ============================================
|
||||
// Design Tokens
|
||||
// ============================================
|
||||
|
||||
const COLORS = {
|
||||
// Primárias
|
||||
primary: "#00e8ff",
|
||||
primaryDark: "#00c4d6",
|
||||
primaryForeground: "#020617",
|
||||
|
||||
// Background
|
||||
background: "#f7f8fb",
|
||||
card: "#ffffff",
|
||||
border: "#e2e8f0",
|
||||
|
||||
// Texto
|
||||
textPrimary: "#0f172a",
|
||||
textSecondary: "#475569",
|
||||
textMuted: "#64748b",
|
||||
|
||||
// Status
|
||||
statusPending: "#64748b",
|
||||
statusPendingBg: "#f1f5f9",
|
||||
statusProgress: "#0ea5e9",
|
||||
statusProgressBg: "#e0f2fe",
|
||||
statusPaused: "#f59e0b",
|
||||
statusPausedBg: "#fef3c7",
|
||||
statusResolved: "#10b981",
|
||||
statusResolvedBg: "#d1fae5",
|
||||
|
||||
// Prioridade
|
||||
priorityLow: "#64748b",
|
||||
priorityLowBg: "#f1f5f9",
|
||||
priorityMedium: "#0a4760",
|
||||
priorityMediumBg: "#dff1fb",
|
||||
priorityHigh: "#7d3b05",
|
||||
priorityHighBg: "#fde8d1",
|
||||
priorityUrgent: "#8b0f1c",
|
||||
priorityUrgentBg: "#fbd9dd",
|
||||
|
||||
// Estrelas
|
||||
starActive: "#fbbf24",
|
||||
starInactive: "#d1d5db",
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
function escapeHtml(str: unknown): string {
|
||||
if (str === null || str === undefined) return ""
|
||||
const s = String(str)
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string | number, options?: Intl.DateTimeFormatOptions): string {
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
return d.toLocaleDateString("pt-BR", {
|
||||
timeZone: "America/Sao_Paulo",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Componentes de E-mail
|
||||
// ============================================
|
||||
|
||||
function getStatusStyle(status: string): { bg: string; color: string; label: string } {
|
||||
const statusMap: Record<string, { bg: string; color: string; label: string }> = {
|
||||
PENDING: { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: "Pendente" },
|
||||
AWAITING_ATTENDANCE: { bg: COLORS.statusProgressBg, color: COLORS.statusProgress, label: "Em andamento" },
|
||||
PAUSED: { bg: COLORS.statusPausedBg, color: COLORS.statusPaused, label: "Pausado" },
|
||||
RESOLVED: { bg: COLORS.statusResolvedBg, color: COLORS.statusResolved, label: "Resolvido" },
|
||||
}
|
||||
return statusMap[status] ?? { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: status }
|
||||
}
|
||||
|
||||
function getPriorityStyle(priority: string): { bg: string; color: string; label: string } {
|
||||
const priorityMap: Record<string, { bg: string; color: string; label: string }> = {
|
||||
LOW: { bg: COLORS.priorityLowBg, color: COLORS.priorityLow, label: "Baixa" },
|
||||
MEDIUM: { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: "Média" },
|
||||
HIGH: { bg: COLORS.priorityHighBg, color: COLORS.priorityHigh, label: "Alta" },
|
||||
URGENT: { bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent, label: "Urgente" },
|
||||
}
|
||||
return priorityMap[priority] ?? { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: priority }
|
||||
}
|
||||
|
||||
function badge(label: string, bg: string, color: string): string {
|
||||
return `<span style="display:inline-block;padding:4px 12px;border-radius:16px;font-size:12px;font-weight:500;background:${bg};color:${color};">${escapeHtml(label)}</span>`
|
||||
}
|
||||
|
||||
function statusBadge(status: string): string {
|
||||
const style = getStatusStyle(status)
|
||||
return badge(style.label, style.bg, style.color)
|
||||
}
|
||||
|
||||
function priorityBadge(priority: string): string {
|
||||
const style = getPriorityStyle(priority)
|
||||
return badge(style.label, style.bg, style.color)
|
||||
}
|
||||
|
||||
function button(label: string, url: string, variant: "primary" | "secondary" = "primary"): string {
|
||||
const bg = variant === "primary" ? COLORS.primary : COLORS.card
|
||||
const color = variant === "primary" ? COLORS.primaryForeground : COLORS.textPrimary
|
||||
const border = variant === "primary" ? COLORS.primary : COLORS.border
|
||||
|
||||
return `<a href="${escapeHtml(url)}" style="display:inline-block;padding:12px 24px;background:${bg};color:${color};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;border:1px solid ${border};">${escapeHtml(label)}</a>`
|
||||
}
|
||||
|
||||
function ticketInfoCard(data: {
|
||||
reference: number | string
|
||||
subject: string
|
||||
status?: string
|
||||
priority?: string
|
||||
requesterName?: string
|
||||
assigneeName?: string
|
||||
createdAt?: Date | string
|
||||
}): string {
|
||||
const rows: string[] = []
|
||||
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Chamado</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;font-weight:600;padding:4px 0;">#${escapeHtml(data.reference)}</td>
|
||||
</tr>
|
||||
`)
|
||||
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Assunto</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.subject)}</td>
|
||||
</tr>
|
||||
`)
|
||||
|
||||
if (data.status) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Status</td>
|
||||
<td style="padding:4px 0;">${statusBadge(data.status)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (data.priority) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Prioridade</td>
|
||||
<td style="padding:4px 0;">${priorityBadge(data.priority)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (data.requesterName) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Solicitante</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.requesterName)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (data.assigneeName) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Responsável</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.assigneeName)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (data.createdAt) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;vertical-align:top;">Criado em</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${formatDate(data.createdAt)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:${COLORS.background};border-radius:8px;margin:16px 0;">
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
${rows.join("")}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function ratingStars(rateUrl: string): string {
|
||||
const stars: string[] = []
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(`
|
||||
<td style="padding:0 4px;">
|
||||
<a href="${escapeHtml(rateUrl)}?rating=${i}" style="text-decoration:none;font-size:28px;color:${COLORS.starActive};">★</a>
|
||||
</td>
|
||||
`)
|
||||
}
|
||||
|
||||
return `
|
||||
<table cellpadding="0" cellspacing="0" style="margin:16px 0;">
|
||||
<tr>
|
||||
${stars.join("")}
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:4px 0 0 0;">Clique em uma estrela para avaliar</p>
|
||||
`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Template Base
|
||||
// ============================================
|
||||
|
||||
function baseTemplate(content: string, data: TemplateData): string {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
|
||||
const preferencesUrl = `${appUrl}/settings/notifications`
|
||||
const helpUrl = `${appUrl}/help`
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>${escapeHtml(data.subject ?? "Notificação")}</title>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:${COLORS.background};font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;-webkit-font-smoothing:antialiased;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:${COLORS.background};padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
||||
|
||||
<!-- Header com logo -->
|
||||
<tr>
|
||||
<td style="padding:0 0 24px 0;text-align:center;">
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td style="background:${COLORS.primary};width:40px;height:40px;border-radius:8px;text-align:center;vertical-align:middle;">
|
||||
<span style="color:${COLORS.primaryForeground};font-size:20px;font-weight:bold;">R</span>
|
||||
</td>
|
||||
<td style="padding-left:12px;">
|
||||
<span style="color:${COLORS.textPrimary};font-size:20px;font-weight:600;">Raven</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Card principal -->
|
||||
<tr>
|
||||
<td style="background:${COLORS.card};border-radius:12px;padding:32px;border:1px solid ${COLORS.border};">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 0;text-align:center;">
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:0 0 8px 0;">
|
||||
Este e-mail foi enviado pelo Sistema de Chamados Raven.
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
<a href="${preferencesUrl}" style="color:${COLORS.primaryDark};font-size:12px;text-decoration:none;">Gerenciar notificações</a>
|
||||
<span style="color:${COLORS.textMuted};margin:0 8px;">|</span>
|
||||
<a href="${helpUrl}" style="color:${COLORS.primaryDark};font-size:12px;text-decoration:none;">Ajuda</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Templates
|
||||
// ============================================
|
||||
|
||||
const templates: Record<TemplateName, (data: TemplateData) => string> = {
|
||||
// Template de teste
|
||||
test: (data) => {
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 16px 0;">
|
||||
${escapeHtml(data.title)}
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
${escapeHtml(data.message)}
|
||||
</p>
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:0;">
|
||||
Enviado em: ${escapeHtml(data.timestamp)}
|
||||
</p>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Abertura de chamado
|
||||
ticket_created: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Chamado Aberto
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
status: data.status as string,
|
||||
priority: data.priority as string,
|
||||
createdAt: data.createdAt as string,
|
||||
})}
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Resolução de chamado
|
||||
ticket_resolved: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
const rateUrl = data.rateUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Chamado Resolvido
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório!
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
status: "RESOLVED",
|
||||
assigneeName: data.assigneeName as string,
|
||||
})}
|
||||
|
||||
${
|
||||
data.resolutionSummary
|
||||
? `
|
||||
<div style="background:${COLORS.statusResolvedBg};border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="color:${COLORS.statusResolved};font-size:12px;font-weight:600;margin:0 0 8px 0;">RESUMO DA RESOLUÇÃO</p>
|
||||
<p style="color:${COLORS.textPrimary};font-size:14px;line-height:1.6;margin:0;">${escapeHtml(data.resolutionSummary)}</p>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<div style="text-align:center;margin:32px 0 16px 0;">
|
||||
<p style="color:${COLORS.textPrimary};font-size:16px;font-weight:600;margin:0 0 8px 0;">Como foi o atendimento?</p>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;margin:0 0 16px 0;">Sua avaliação nos ajuda a melhorar!</p>
|
||||
${ratingStars(rateUrl)}
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Atribuição de chamado
|
||||
ticket_assigned: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
const isForRequester = data.isForRequester as boolean
|
||||
|
||||
const title = isForRequester ? "Agente Atribuído ao Chamado" : "Novo Chamado Atribuído"
|
||||
const message = isForRequester
|
||||
? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.`
|
||||
: `Um novo chamado foi atribuído a você. Por favor, verifique os detalhes abaixo.`
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
${title}
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
${message}
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
status: data.status as string,
|
||||
priority: data.priority as string,
|
||||
requesterName: data.requesterName as string,
|
||||
assigneeName: data.assigneeName as string,
|
||||
})}
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Mudança de status
|
||||
ticket_status: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
const oldStatus = getStatusStyle(data.oldStatus as string)
|
||||
const newStatus = getStatusStyle(data.newStatus as string)
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Status Atualizado
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
O status do seu chamado foi alterado.
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
})}
|
||||
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td style="text-align:center;">
|
||||
${badge(oldStatus.label, oldStatus.bg, oldStatus.color)}
|
||||
</td>
|
||||
<td style="padding:0 16px;color:${COLORS.textMuted};font-size:20px;">→</td>
|
||||
<td style="text-align:center;">
|
||||
${badge(newStatus.label, newStatus.bg, newStatus.color)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Novo comentário
|
||||
ticket_comment: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Nova Atualização no Chamado
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado.
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
})}
|
||||
|
||||
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid ${COLORS.primary};">
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:0 0 8px 0;">
|
||||
${escapeHtml(data.authorName)} • ${formatDate(data.commentedAt as string)}
|
||||
</p>
|
||||
<p style="color:${COLORS.textPrimary};font-size:14px;line-height:1.6;margin:0;">
|
||||
${escapeHtml(data.commentBody)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Reset de senha
|
||||
password_reset: (data) => {
|
||||
const resetUrl = data.resetUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Redefinição de Senha
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center;margin:32px 0;">
|
||||
${button("Redefinir Senha", resetUrl)}
|
||||
</div>
|
||||
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
|
||||
Este link expira em 24 horas. Se você não solicitou a redefinição de senha,
|
||||
pode ignorar este e-mail com segurança.
|
||||
</p>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Verificação de e-mail
|
||||
email_verify: (data) => {
|
||||
const verifyUrl = data.verifyUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Confirme seu E-mail
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center;margin:32px 0;">
|
||||
${button("Confirmar E-mail", verifyUrl)}
|
||||
</div>
|
||||
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
|
||||
Se você não criou uma conta, pode ignorar este e-mail com segurança.
|
||||
</p>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Convite de usuário
|
||||
invite: (data) => {
|
||||
const inviteUrl = data.inviteUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Você foi convidado!
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven.
|
||||
</p>
|
||||
|
||||
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Função</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.roleName)}</td>
|
||||
</tr>
|
||||
${
|
||||
data.companyName
|
||||
? `
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Empresa</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.companyName)}</td>
|
||||
</tr>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin:32px 0;">
|
||||
${button("Aceitar Convite", inviteUrl)}
|
||||
</div>
|
||||
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
|
||||
Este convite expira em 7 dias. Se você não esperava este convite, pode ignorá-lo com segurança.
|
||||
</p>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Novo login detectado
|
||||
new_login: (data) => {
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.textPrimary};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
Novo Acesso Detectado
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail.
|
||||
</p>
|
||||
|
||||
<div style="background:${COLORS.background};border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Data/Hora</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${formatDate(data.loginAt as string)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Dispositivo</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.userAgent)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 8px 4px 0;">Endereço IP</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(data.ipAddress)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="color:${COLORS.textMuted};font-size:12px;margin:24px 0 0 0;">
|
||||
Se você não reconhece este acesso, recomendamos alterar sua senha imediatamente.
|
||||
</p>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Alerta de SLA em risco
|
||||
sla_warning: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.statusPaused};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
SLA em Risco
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
O chamado abaixo está próximo de violar o SLA. Ação necessária!
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
status: data.status as string,
|
||||
priority: data.priority as string,
|
||||
requesterName: data.requesterName as string,
|
||||
assigneeName: data.assigneeName as string,
|
||||
})}
|
||||
|
||||
<div style="background:${COLORS.statusPausedBg};border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="color:${COLORS.statusPaused};font-size:14px;font-weight:600;margin:0 0 8px 0;">
|
||||
Tempo restante: ${escapeHtml(data.timeRemaining)}
|
||||
</p>
|
||||
<p style="color:${COLORS.textSecondary};font-size:12px;margin:0;">
|
||||
Prazo: ${formatDate(data.dueAt as string)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
// Alerta de SLA violado
|
||||
sla_breached: (data) => {
|
||||
const viewUrl = data.viewUrl as string
|
||||
|
||||
return baseTemplate(
|
||||
`
|
||||
<h1 style="color:${COLORS.priorityUrgent};font-size:24px;font-weight:600;margin:0 0 8px 0;">
|
||||
SLA Violado
|
||||
</h1>
|
||||
<p style="color:${COLORS.textSecondary};font-size:14px;line-height:1.6;margin:0 0 24px 0;">
|
||||
O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária!
|
||||
</p>
|
||||
|
||||
${ticketInfoCard({
|
||||
reference: data.reference as number,
|
||||
subject: data.subject as string,
|
||||
status: data.status as string,
|
||||
priority: data.priority as string,
|
||||
requesterName: data.requesterName as string,
|
||||
assigneeName: data.assigneeName as string,
|
||||
})}
|
||||
|
||||
<div style="background:${COLORS.priorityUrgentBg};border-radius:8px;padding:16px;margin:16px 0;">
|
||||
<p style="color:${COLORS.priorityUrgent};font-size:14px;font-weight:600;margin:0 0 8px 0;">
|
||||
Tempo excedido: ${escapeHtml(data.timeExceeded)}
|
||||
</p>
|
||||
<p style="color:${COLORS.textSecondary};font-size:12px;margin:0;">
|
||||
Prazo era: ${formatDate(data.dueAt as string)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
${button("Ver Chamado", viewUrl)}
|
||||
</div>
|
||||
`,
|
||||
data
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Exportação
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Renderiza um template de e-mail com os dados fornecidos
|
||||
*/
|
||||
export function renderTemplate(name: TemplateName, data: TemplateData): string {
|
||||
const template = templates[name]
|
||||
if (!template) {
|
||||
throw new Error(`Template "${name}" não encontrado`)
|
||||
}
|
||||
return template(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a lista de templates disponíveis
|
||||
*/
|
||||
export function getAvailableTemplates(): TemplateName[] {
|
||||
return Object.keys(templates) as TemplateName[]
|
||||
}
|
||||
28
src/server/email/index.ts
Normal file
28
src/server/email/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Módulo de E-mail
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
export {
|
||||
sendEmail,
|
||||
sendTestEmail,
|
||||
isRequiredNotification,
|
||||
isStaffOnlyNotification,
|
||||
canCollaboratorDisable,
|
||||
getNotificationLabel,
|
||||
getAvailableNotificationTypes,
|
||||
getCollaboratorDisableableTypes,
|
||||
NOTIFICATION_TYPES,
|
||||
type NotificationType,
|
||||
type NotificationConfig,
|
||||
type EmailRecipient,
|
||||
type SendEmailOptions,
|
||||
type SendEmailResult,
|
||||
} from "./email-service"
|
||||
|
||||
export {
|
||||
renderTemplate,
|
||||
getAvailableTemplates,
|
||||
type TemplateName,
|
||||
type TemplateData,
|
||||
} from "./email-templates"
|
||||
33
src/server/notification/index.ts
Normal file
33
src/server/notification/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Módulo de Notificações
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
// Serviço de Notificações
|
||||
export {
|
||||
notifyTicketCreated,
|
||||
notifyTicketAssigned,
|
||||
notifyTicketResolved,
|
||||
notifyTicketStatusChanged,
|
||||
notifyPublicComment,
|
||||
notifyRequesterResponse,
|
||||
notifyPasswordReset,
|
||||
notifyEmailVerification,
|
||||
notifyUserInvite,
|
||||
notifyNewLogin,
|
||||
notifySlaAtRisk,
|
||||
notifySlaBreached,
|
||||
} from "./notification-service"
|
||||
|
||||
// Serviço de Tokens
|
||||
export {
|
||||
generateAccessToken,
|
||||
validateAccessToken,
|
||||
markTokenAsUsed,
|
||||
invalidateTicketTokens,
|
||||
invalidateUserTokens,
|
||||
cleanupExpiredTokens,
|
||||
hasScope,
|
||||
type GenerateTokenOptions,
|
||||
type ValidatedToken,
|
||||
} from "./token-service"
|
||||
635
src/server/notification/notification-service.ts
Normal file
635
src/server/notification/notification-service.ts
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
/**
|
||||
* Serviço de Notificações
|
||||
* Orquestrador de notificações por e-mail
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendEmail, type NotificationType } from "../email"
|
||||
import { generateAccessToken } from "./token-service"
|
||||
|
||||
// ============================================
|
||||
// Tipos
|
||||
// ============================================
|
||||
|
||||
interface TicketData {
|
||||
id: string
|
||||
tenantId: string
|
||||
reference: number
|
||||
subject: string
|
||||
status: string
|
||||
priority: string
|
||||
createdAt: Date
|
||||
resolvedAt?: Date | null
|
||||
requester: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
assignee?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
} | null
|
||||
company?: {
|
||||
id: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface CommentData {
|
||||
id: string
|
||||
body: string
|
||||
visibility: string
|
||||
createdAt: Date
|
||||
author: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
|
||||
|
||||
function getTicketViewUrl(ticketId: string, token?: string): string {
|
||||
if (token) {
|
||||
return `${APP_URL}/ticket-view/${token}`
|
||||
}
|
||||
return `${APP_URL}/portal/tickets/${ticketId}`
|
||||
}
|
||||
|
||||
function getRateUrl(token: string): string {
|
||||
return `${APP_URL}/rate/${token}`
|
||||
}
|
||||
|
||||
async function shouldSendNotification(
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
tenantId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const prefs = await prisma.notificationPreferences.findUnique({
|
||||
where: { userId },
|
||||
})
|
||||
|
||||
// Se não tem preferências, usa os defaults
|
||||
if (!prefs) return true
|
||||
|
||||
// Se e-mail está desabilitado globalmente
|
||||
if (!prefs.emailEnabled) return false
|
||||
|
||||
// Verifica preferências por tipo
|
||||
const typePrefs = prefs.typePreferences as Record<string, boolean>
|
||||
if (typePrefs && notificationType in typePrefs) {
|
||||
return typePrefs[notificationType] !== false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Em caso de erro, envia a notificação
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Notificações de Ciclo de Vida
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Notificação de abertura de chamado
|
||||
* Enviada para: Solicitante
|
||||
*/
|
||||
export async function notifyTicketCreated(ticket: TicketData): Promise<void> {
|
||||
const { requester } = ticket
|
||||
|
||||
// Gera token de acesso para visualização
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "view",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
const viewUrl = getTicketViewUrl(ticket.id, accessToken)
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: requester.email,
|
||||
name: requester.name,
|
||||
userId: requester.id,
|
||||
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Chamado Aberto - ${ticket.subject}`,
|
||||
template: "ticket_created",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
viewUrl,
|
||||
},
|
||||
notificationType: "ticket_created",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de atribuição de chamado
|
||||
* Enviada para: Solicitante e Agente atribuído
|
||||
*/
|
||||
export async function notifyTicketAssigned(ticket: TicketData): Promise<void> {
|
||||
if (!ticket.assignee) return
|
||||
|
||||
const { requester, assignee } = ticket
|
||||
|
||||
// Gera tokens de acesso
|
||||
const requesterToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "view",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
const assigneeToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: assignee.id,
|
||||
scope: "interact",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
// Notifica o solicitante
|
||||
if (await shouldSendNotification(requester.id, "ticket_assigned", ticket.tenantId)) {
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: requester.email,
|
||||
name: requester.name,
|
||||
userId: requester.id,
|
||||
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Agente Atribuído - ${ticket.subject}`,
|
||||
template: "ticket_assigned",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
requesterName: requester.name,
|
||||
assigneeName: assignee.name,
|
||||
isForRequester: true,
|
||||
viewUrl: getTicketViewUrl(ticket.id, requesterToken),
|
||||
},
|
||||
notificationType: "ticket_assigned",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
// Notifica o agente atribuído
|
||||
if (await shouldSendNotification(assignee.id, "ticket_assigned", ticket.tenantId)) {
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: assignee.email,
|
||||
name: assignee.name,
|
||||
userId: assignee.id,
|
||||
role: assignee.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Novo Chamado Atribuído - ${ticket.subject}`,
|
||||
template: "ticket_assigned",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
requesterName: requester.name,
|
||||
assigneeName: assignee.name,
|
||||
isForRequester: false,
|
||||
viewUrl: getTicketViewUrl(ticket.id, assigneeToken),
|
||||
},
|
||||
notificationType: "ticket_assigned",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de resolução de chamado
|
||||
* Enviada para: Solicitante (com link de avaliação)
|
||||
*/
|
||||
export async function notifyTicketResolved(
|
||||
ticket: TicketData,
|
||||
resolutionSummary?: string
|
||||
): Promise<void> {
|
||||
const { requester, assignee } = ticket
|
||||
|
||||
// Gera token de acesso para avaliação
|
||||
const rateToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "rate",
|
||||
expiresInDays: 30, // 30 dias para avaliar
|
||||
})
|
||||
|
||||
const viewToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "view",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: requester.email,
|
||||
name: requester.name,
|
||||
userId: requester.id,
|
||||
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Chamado Resolvido - ${ticket.subject}`,
|
||||
template: "ticket_resolved",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
assigneeName: assignee?.name ?? "Equipe de Suporte",
|
||||
resolutionSummary,
|
||||
viewUrl: getTicketViewUrl(ticket.id, viewToken),
|
||||
rateUrl: getRateUrl(rateToken),
|
||||
},
|
||||
notificationType: "ticket_resolved",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de mudança de status
|
||||
* Enviada para: Solicitante
|
||||
*/
|
||||
export async function notifyTicketStatusChanged(
|
||||
ticket: TicketData,
|
||||
oldStatus: string,
|
||||
newStatus: string
|
||||
): Promise<void> {
|
||||
const { requester } = ticket
|
||||
|
||||
if (!(await shouldSendNotification(requester.id, "ticket_status_changed", ticket.tenantId))) {
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "view",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: requester.email,
|
||||
name: requester.name,
|
||||
userId: requester.id,
|
||||
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Status Atualizado - ${ticket.subject}`,
|
||||
template: "ticket_status",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
viewUrl: getTicketViewUrl(ticket.id, accessToken),
|
||||
},
|
||||
notificationType: "ticket_status_changed",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de novo comentário público
|
||||
* Enviada para: Solicitante (quando agente comenta)
|
||||
*/
|
||||
export async function notifyPublicComment(
|
||||
ticket: TicketData,
|
||||
comment: CommentData
|
||||
): Promise<void> {
|
||||
// Só notifica comentários públicos
|
||||
if (comment.visibility !== "PUBLIC") return
|
||||
|
||||
// Não notifica se o autor é o próprio solicitante
|
||||
if (comment.author.id === ticket.requester.id) return
|
||||
|
||||
const { requester } = ticket
|
||||
|
||||
if (!(await shouldSendNotification(requester.id, "comment_public", ticket.tenantId))) {
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: requester.id,
|
||||
scope: "view",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: requester.email,
|
||||
name: requester.name,
|
||||
userId: requester.id,
|
||||
role: requester.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Nova Atualização - ${ticket.subject}`,
|
||||
template: "ticket_comment",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
authorName: comment.author.name,
|
||||
commentBody: comment.body,
|
||||
commentedAt: comment.createdAt.toISOString(),
|
||||
viewUrl: getTicketViewUrl(ticket.id, accessToken),
|
||||
},
|
||||
notificationType: "comment_public",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de resposta do solicitante
|
||||
* Enviada para: Agente atribuído
|
||||
*/
|
||||
export async function notifyRequesterResponse(
|
||||
ticket: TicketData,
|
||||
comment: CommentData
|
||||
): Promise<void> {
|
||||
// Só notifica se tem agente atribuído
|
||||
if (!ticket.assignee) return
|
||||
|
||||
// Só notifica se o autor é o solicitante
|
||||
if (comment.author.id !== ticket.requester.id) return
|
||||
|
||||
const { assignee } = ticket
|
||||
|
||||
if (!(await shouldSendNotification(assignee.id, "comment_response", ticket.tenantId))) {
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: assignee.id,
|
||||
scope: "interact",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: assignee.email,
|
||||
name: assignee.name,
|
||||
userId: assignee.id,
|
||||
role: assignee.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[#${ticket.reference}] Resposta do Solicitante - ${ticket.subject}`,
|
||||
template: "ticket_comment",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
authorName: comment.author.name,
|
||||
commentBody: comment.body,
|
||||
commentedAt: comment.createdAt.toISOString(),
|
||||
viewUrl: getTicketViewUrl(ticket.id, accessToken),
|
||||
},
|
||||
notificationType: "comment_response",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Notificações de Autenticação
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Notificação de reset de senha
|
||||
*/
|
||||
export async function notifyPasswordReset(
|
||||
email: string,
|
||||
name: string,
|
||||
resetUrl: string
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to: { email, name },
|
||||
subject: "Redefinição de Senha - Sistema de Chamados Raven",
|
||||
template: "password_reset",
|
||||
data: { resetUrl },
|
||||
notificationType: "security_password_reset",
|
||||
skipPreferenceCheck: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de verificação de e-mail
|
||||
*/
|
||||
export async function notifyEmailVerification(
|
||||
email: string,
|
||||
name: string,
|
||||
verifyUrl: string
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to: { email, name },
|
||||
subject: "Confirme seu E-mail - Sistema de Chamados Raven",
|
||||
template: "email_verify",
|
||||
data: { verifyUrl },
|
||||
notificationType: "security_email_verify",
|
||||
skipPreferenceCheck: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de convite de usuário
|
||||
*/
|
||||
export async function notifyUserInvite(
|
||||
email: string,
|
||||
name: string | null,
|
||||
inviterName: string,
|
||||
roleName: string,
|
||||
companyName: string | null,
|
||||
inviteUrl: string
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to: { email, name: name ?? undefined },
|
||||
subject: "Você foi convidado! - Sistema de Chamados Raven",
|
||||
template: "invite",
|
||||
data: {
|
||||
inviterName,
|
||||
roleName,
|
||||
companyName,
|
||||
inviteUrl,
|
||||
},
|
||||
notificationType: "security_invite",
|
||||
skipPreferenceCheck: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de novo login
|
||||
*/
|
||||
export async function notifyNewLogin(
|
||||
userId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
loginAt: Date,
|
||||
userAgent: string,
|
||||
ipAddress: string,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
if (!(await shouldSendNotification(userId, "security_new_login", tenantId))) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: { email, name, userId },
|
||||
subject: "Novo Acesso Detectado - Sistema de Chamados Raven",
|
||||
template: "new_login",
|
||||
data: {
|
||||
loginAt: loginAt.toISOString(),
|
||||
userAgent,
|
||||
ipAddress,
|
||||
},
|
||||
notificationType: "security_new_login",
|
||||
tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Notificações de SLA
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Notificação de SLA em risco
|
||||
* Enviada para: Agente atribuído e supervisor
|
||||
*/
|
||||
export async function notifySlaAtRisk(
|
||||
ticket: TicketData,
|
||||
dueAt: Date,
|
||||
timeRemaining: string
|
||||
): Promise<void> {
|
||||
const recipients: Array<{ email: string; name: string; userId: string; role: string }> = []
|
||||
|
||||
// Adiciona o agente atribuído
|
||||
if (ticket.assignee) {
|
||||
if (await shouldSendNotification(ticket.assignee.id, "sla_at_risk", ticket.tenantId)) {
|
||||
recipients.push({
|
||||
email: ticket.assignee.email,
|
||||
name: ticket.assignee.name,
|
||||
userId: ticket.assignee.id,
|
||||
role: ticket.assignee.role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.length === 0) return
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: recipient.userId,
|
||||
scope: "interact",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
userId: recipient.userId,
|
||||
role: recipient.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[ATENÇÃO] SLA em Risco - #${ticket.reference} ${ticket.subject}`,
|
||||
template: "sla_warning",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
requesterName: ticket.requester.name,
|
||||
assigneeName: ticket.assignee?.name,
|
||||
dueAt: dueAt.toISOString(),
|
||||
timeRemaining,
|
||||
viewUrl: getTicketViewUrl(ticket.id, accessToken),
|
||||
},
|
||||
notificationType: "sla_at_risk",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificação de SLA violado
|
||||
* Enviada para: Agente atribuído e administradores
|
||||
*/
|
||||
export async function notifySlaBreached(
|
||||
ticket: TicketData,
|
||||
dueAt: Date,
|
||||
timeExceeded: string
|
||||
): Promise<void> {
|
||||
const recipients: Array<{ email: string; name: string; userId: string; role: string }> = []
|
||||
|
||||
// Adiciona o agente atribuído
|
||||
if (ticket.assignee) {
|
||||
if (await shouldSendNotification(ticket.assignee.id, "sla_breached", ticket.tenantId)) {
|
||||
recipients.push({
|
||||
email: ticket.assignee.email,
|
||||
name: ticket.assignee.name,
|
||||
userId: ticket.assignee.id,
|
||||
role: ticket.assignee.role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.length === 0) return
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const accessToken = await generateAccessToken({
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
userId: recipient.userId,
|
||||
scope: "interact",
|
||||
expiresInDays: 7,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
userId: recipient.userId,
|
||||
role: recipient.role as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR",
|
||||
},
|
||||
subject: `[URGENTE] SLA Violado - #${ticket.reference} ${ticket.subject}`,
|
||||
template: "sla_breached",
|
||||
data: {
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
requesterName: ticket.requester.name,
|
||||
assigneeName: ticket.assignee?.name,
|
||||
dueAt: dueAt.toISOString(),
|
||||
timeExceeded,
|
||||
viewUrl: getTicketViewUrl(ticket.id, accessToken),
|
||||
},
|
||||
notificationType: "sla_breached",
|
||||
tenantId: ticket.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
171
src/server/notification/token-service.ts
Normal file
171
src/server/notification/token-service.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Serviço de Tokens de Acesso
|
||||
* Gera e valida tokens para acesso direto aos chamados
|
||||
* Sistema de Chamados Raven
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// ============================================
|
||||
// Tipos
|
||||
// ============================================
|
||||
|
||||
export interface GenerateTokenOptions {
|
||||
tenantId: string
|
||||
ticketId: string
|
||||
userId: string
|
||||
machineId?: string
|
||||
scope: "view" | "interact" | "rate"
|
||||
expiresInDays?: number
|
||||
}
|
||||
|
||||
export interface ValidatedToken {
|
||||
id: string
|
||||
tenantId: string
|
||||
ticketId: string
|
||||
userId: string
|
||||
machineId: string | null
|
||||
scope: string
|
||||
expiresAt: Date
|
||||
usedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Geração de Token
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Gera um token seguro de acesso ao chamado
|
||||
*/
|
||||
export async function generateAccessToken(options: GenerateTokenOptions): Promise<string> {
|
||||
const {
|
||||
tenantId,
|
||||
ticketId,
|
||||
userId,
|
||||
machineId,
|
||||
scope,
|
||||
expiresInDays = 7,
|
||||
} = options
|
||||
|
||||
// Gera um token aleatório de 32 bytes (64 caracteres hex)
|
||||
const token = randomBytes(32).toString("hex")
|
||||
|
||||
// Calcula a data de expiração
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays)
|
||||
|
||||
// Salva o token no banco
|
||||
await prisma.ticketAccessToken.create({
|
||||
data: {
|
||||
tenantId,
|
||||
token,
|
||||
ticketId,
|
||||
userId,
|
||||
machineId,
|
||||
scope,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um token de acesso
|
||||
*/
|
||||
export async function validateAccessToken(token: string): Promise<ValidatedToken | null> {
|
||||
const tokenRecord = await prisma.ticketAccessToken.findUnique({
|
||||
where: { token },
|
||||
})
|
||||
|
||||
if (!tokenRecord) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Verifica se o token expirou
|
||||
if (tokenRecord.expiresAt < new Date()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: tokenRecord.id,
|
||||
tenantId: tokenRecord.tenantId,
|
||||
ticketId: tokenRecord.ticketId,
|
||||
userId: tokenRecord.userId,
|
||||
machineId: tokenRecord.machineId,
|
||||
scope: tokenRecord.scope,
|
||||
expiresAt: tokenRecord.expiresAt,
|
||||
usedAt: tokenRecord.usedAt,
|
||||
createdAt: tokenRecord.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca um token como usado
|
||||
*/
|
||||
export async function markTokenAsUsed(token: string): Promise<void> {
|
||||
await prisma.ticketAccessToken.update({
|
||||
where: { token },
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida todos os tokens de um chamado
|
||||
*/
|
||||
export async function invalidateTicketTokens(ticketId: string): Promise<void> {
|
||||
await prisma.ticketAccessToken.updateMany({
|
||||
where: { ticketId },
|
||||
data: { expiresAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida todos os tokens de um usuário
|
||||
*/
|
||||
export async function invalidateUserTokens(userId: string): Promise<void> {
|
||||
await prisma.ticketAccessToken.updateMany({
|
||||
where: { userId },
|
||||
data: { expiresAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa tokens expirados (pode ser chamado periodicamente)
|
||||
*/
|
||||
export async function cleanupExpiredTokens(): Promise<number> {
|
||||
const result = await prisma.ticketAccessToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um token tem o escopo necessário
|
||||
*/
|
||||
export function hasScope(tokenScope: string, requiredScope: "view" | "interact" | "rate"): boolean {
|
||||
const scopeHierarchy: Record<string, number> = {
|
||||
view: 1,
|
||||
interact: 2,
|
||||
rate: 3,
|
||||
}
|
||||
|
||||
const tokenLevel = scopeHierarchy[tokenScope] ?? 0
|
||||
const requiredLevel = scopeHierarchy[requiredScope] ?? 0
|
||||
|
||||
// rate é especial, só pode avaliar
|
||||
if (requiredScope === "rate") {
|
||||
return tokenScope === "rate"
|
||||
}
|
||||
|
||||
// interact permite view
|
||||
// view só permite view
|
||||
return tokenLevel >= requiredLevel
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue