feat(export,tickets,forms,emails):\n- Corrige scroll de Dialogs e melhora UI de seleção de colunas (ícones e separador)\n- Ajusta rota/params da exportação em massa e adiciona modal de exportação individual\n- Renomeia 'Chamado padrão' para 'Chamado' e garante visibilidade total para admin/agente\n- Adiciona toggles por empresa/usuário para habilitar Admissão/Desligamento\n- Exibe badge do tipo de solicitação na listagem e no cabeçalho do ticket\n- Prepara notificações por e-mail (comentário público e encerramento) via SMTP\n

This commit is contained in:
codex-bot 2025-11-04 13:41:32 -03:00
parent a8333c010f
commit 06deb99bcd
12 changed files with 543 additions and 17 deletions

View file

@ -31,6 +31,7 @@ import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as ticketFormSettings from "../ticketFormSettings.js";
import type * as ticketNotifications from "../ticketNotifications.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
@ -72,6 +73,7 @@ declare const fullApi: ApiFromModules<{
slas: typeof slas;
teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
ticketNotifications: typeof ticketNotifications;
tickets: typeof tickets;
users: typeof users;
}>;

View file

@ -0,0 +1,159 @@
"use node"
import tls from "tls"
import { action } from "./_generated/server"
import { v } from "convex/values"
function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) {
return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
let buffer = ""
const send = (line: string) => socket.write(line + "\r\n")
const wait = (expected: string | RegExp) =>
new Promise<void>((res) => {
const onData = (data: Buffer) => {
buffer += data.toString()
const lines = buffer.split(/\r?\n/)
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
socket.removeListener("data", onData)
res()
}
}
socket.on("data", onData)
socket.on("error", reject)
})
;(async () => {
await wait(/^220 /)
send(`EHLO ${cfg.host}`)
await wait(/^250-/)
await wait(/^250 /)
send("AUTH LOGIN")
await wait(/^334 /)
send(b64(cfg.username))
await wait(/^334 /)
send(b64(cfg.password))
await wait(/^235 /)
send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`)
await wait(/^250 /)
send(`RCPT TO:<${to}>`)
await wait(/^250 /)
send("DATA")
await wait(/^354 /)
const headers = [
`From: ${cfg.from}`,
`To: ${to}`,
`Subject: ${subject}`,
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
].join("\r\n")
send(headers + "\r\n\r\n" + html + "\r\n.")
await wait(/^250 /)
send("QUIT")
socket.end()
resolve()
})().catch(reject)
})
socket.on("error", reject)
})
}
function buildBaseUrl() {
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
}
function emailTemplate({ title, message, ctaLabel, ctaUrl }: { title: string; message: string; ctaLabel: string; ctaUrl: string }) {
return `
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8fafc;padding:24px 0;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;">
<tr>
<td style="text-align:left;">
<div style="display:flex;align-items:center;gap:12px;">
<img src="${buildBaseUrl()}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:8px;"/>
<span style="font-weight:700;font-size:18px;">Raven</span>
</div>
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;">${title}</h1>
<p style="font-size:14px;line-height:1.6;margin:0 0 16px 0;color:#334155;">${message}</p>
<a href="${ctaUrl}" style="display:inline-block;background:#111827;color:#fff;text-decoration:none;border-radius:10px;padding:10px 16px;font-weight:600;">${ctaLabel}</a>
<p style="font-size:12px;color:#64748b;margin-top:20px;">Se o botão não funcionar, copie e cole esta URL no navegador:<br/><a href="${ctaUrl}" style="color:#0ea5e9;text-decoration:none;">${ctaUrl}</a></p>
</td>
</tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px;">&copy; ${new Date().getFullYear()} Raven Rever Tecnologia</p>
</td>
</tr>
</table>`
}
export const sendPublicCommentEmail = action({
args: {
to: v.string(),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = {
host: process.env.SMTP_ADDRESS!,
port: Number(process.env.SMTP_PORT ?? 465),
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
}
if (!smtp.host || !smtp.username || !smtp.password) {
console.warn("SMTP not configured; skipping ticket comment email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
const html = emailTemplate({
title: `Nova atualização no seu chamado #${reference}`,
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
ctaLabel: "Abrir e responder",
ctaUrl: url,
})
await sendSmtpMail(smtp, to, mailSubject, html)
return { ok: true }
},
})
export const sendResolvedEmail = action({
args: {
to: v.string(),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
},
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = {
host: process.env.SMTP_ADDRESS!,
port: Number(process.env.SMTP_PORT ?? 465),
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
}
if (!smtp.host || !smtp.username || !smtp.password) {
console.warn("SMTP not configured; skipping ticket resolution email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
const html = emailTemplate({
title: `Chamado #${reference} encerrado`,
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
ctaLabel: "Ver detalhes",
ctaUrl: url,
})
await sendSmtpMail(smtp, to, mailSubject, html)
return { ok: true }
},
})

View file

@ -1,5 +1,6 @@
// CI touch: enable server-side assignee filtering and trigger redeploy
import { mutation, query } from "./_generated/server";
import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc, type DataModel } from "./_generated/dataModel";
@ -1235,6 +1236,7 @@ export const list = query({
priority: t.priority,
channel: t.channel,
queue: queueName,
formTemplate: t.formTemplate ?? null,
company: company
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
: t.companyId || t.companySnapshot
@ -1887,6 +1889,20 @@ export const addComment = mutation({
});
// bump ticket updatedAt
await ctx.db.patch(args.ticketId, { updatedAt: now });
// Notificação por e-mail: comentário público para o solicitante
try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail,
ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
})
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
}
return id;
},
});
@ -2122,6 +2138,22 @@ export async function resolveTicketHandler(
createdAt: now,
})
// Notificação por e-mail: encerramento do chamado
try {
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
if (email) {
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email,
ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
})
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
}
for (const rel of linkedTickets) {
const existing = new Set<string>((rel.relatedTicketIds ?? []).map((value) => String(value)))
existing.add(String(ticketId))
@ -2433,10 +2465,14 @@ export const listTicketForms = query({
}>
for (const template of TICKET_FORM_CONFIG) {
const enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
companyId: viewerCompanyId,
userId: viewer.user._id,
})
const viewerRole = (viewer.role ?? "").toUpperCase()
if (viewerRole === "ADMIN" || viewerRole === "AGENT") {
enabled = true
}
if (!enabled) {
continue
}