From d2c19132210d76e7a1d3f7cbb5b8850b74894fac Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 16:53:24 -0300 Subject: [PATCH] =?UTF-8?q?dashboard:=20substituir=20'Abrir=20ticket'=20po?= =?UTF-8?q?r=20bot=C3=A3o=20'Novo=20ticket'=20com=20modal=20(mesmo=20layou?= =?UTF-8?q?t=20e=20funcionalidade=20da=20tela=20de=20tickets)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents.md | 1 + convex/tickets.ts | 87 ++++++++++++++++++++++++++++++++++++++ src/app/dashboard/page.tsx | 35 +++++++++------ 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/agents.md b/agents.md index 341fdc8..412e509 100644 --- a/agents.md +++ b/agents.md @@ -42,6 +42,7 @@ - Relatórios, dashboards e páginas administrativas utilizam `AppShell`, garantindo header/sidebar consistentes. - Use `SiteHeader` no `header` do `AppShell` para título/lead e ações. - O conteúdo deve ficar dentro de `
`. + - Persistir filtro global de empresa com `usePersistentCompanyFilter` (localStorage) para manter consistência entre relatórios. ## Entregas recentes - Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período. diff --git a/convex/tickets.ts b/convex/tickets.ts index 42ae8f8..6844dbb 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -738,6 +738,93 @@ export const addComment = mutation({ }); // bump ticket updatedAt await ctx.db.patch(args.ticketId, { updatedAt: now }); + + // If public comment, attempt to notify requester by e-mail (best-effort) + if (args.visibility === "PUBLIC") { + try { + 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 || "no-reply@example.com", + } + if (smtp.host && smtp.username && smtp.password) { + const requester = await ctx.db.get(ticketDoc.requesterId) + const to = (requester as any)?.email as string | undefined + if (to) { + // Minimal SMTP sender (AUTH LOGIN over implicit TLS) + const tls = await import("tls") + const b64 = (input: string) => Buffer.from(input, "utf8").toString("base64") + const sendSmtpMail = async (cfg: { host: string; port: number; username: string; password: string; from: string }, toAddr: string, subject: string, html: string) => { + return new Promise((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((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 /) + const fromAddr = (cfg.from.match(/<\s*([^>\s]+)\s*>/)?.[1] ?? cfg.from) + send(`MAIL FROM:<${fromAddr}>`) + await wait(/^250 /) + send(`RCPT TO:<${toAddr}>`) + await wait(/^250 /) + send("DATA") + await wait(/^354 /) + const headers = [ + `From: ${cfg.from}`, + `To: ${toAddr}`, + `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) + }) + } + const ticketRef = (ticketDoc as any).reference ? `#${(ticketDoc as any).reference}` : "Chamado" + const subject = `${ticketRef} atualizado: novo comentário público` + const body = ` +

Olá,

+

Seu chamado ${ticketRef} recebeu um novo comentário:

+
${args.body}
+

Atenciosamente,
Equipe de suporte

+ ` + await sendSmtpMail(smtp, to, subject, body) + } + } + } catch (error) { + console.error("Failed to send public comment notification", error) + } + } return id; }, }); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9f35c60..69ce147 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,22 +1,31 @@ +import dynamic from "next/dynamic" import { AppShell } from "@/components/app-shell" import { SectionCards } from "@/components/section-cards" import { SiteHeader } from "@/components/site-header" import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { ChartAreaInteractive } from "@/components/chart-area-interactive" - -export default function Dashboard() { - return ( - Abrir ticket} - primaryAction={Modo play} - /> - } - > + +const NewTicketDialog = dynamic( + () => + import("@/components/tickets/new-ticket-dialog").then((module) => ({ + default: module.NewTicketDialog, + })), + { ssr: false } +) + +export default function Dashboard() { + return ( + Modo play} + primaryAction={} + /> + } + >