From 5b14ecbe0fbda102fe5a747cbf1f5a43f5f867bd Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 17:04:38 -0300 Subject: [PATCH] =?UTF-8?q?convex:=20mover=20action=20de=20envio=20de=20al?= =?UTF-8?q?ertas=20para=20arquivo=20Node=20('alerts=5Factions.ts'=20com=20?= =?UTF-8?q?'use=20node');=20remover=20import=20de=20'tls'=20do=20m=C3=B3du?= =?UTF-8?q?lo=20de=20queries/mutations;=20ajustar=20cron=20para=20usar=20a?= =?UTF-8?q?pi.alerts=5Factions;=20remover=20tentativa=20de=20envio=20de=20?= =?UTF-8?q?e-mail=20no=20mutation=20addComment=20(evitar=20Node=20API=20em?= =?UTF-8?q?=20isolate).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/_generated/api.d.ts | 4 + convex/alerts.ts | 155 ----------------------------------- convex/alerts_actions.ts | 160 +++++++++++++++++++++++++++++++++++++ convex/crons.ts | 2 +- convex/tickets.ts | 87 -------------------- 5 files changed, 165 insertions(+), 243 deletions(-) create mode 100644 convex/alerts_actions.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index be5228f..1723588 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,10 +9,12 @@ */ import type * as alerts from "../alerts.js"; +import type * as alerts_actions from "../alerts_actions.js"; import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; import type * as commentTemplates from "../commentTemplates.js"; import type * as companies from "../companies.js"; +import type * as crons from "../crons.js"; import type * as fields from "../fields.js"; import type * as files from "../files.js"; import type * as invites from "../invites.js"; @@ -42,10 +44,12 @@ import type { */ declare const fullApi: ApiFromModules<{ alerts: typeof alerts; + alerts_actions: typeof alerts_actions; bootstrap: typeof bootstrap; categories: typeof categories; commentTemplates: typeof commentTemplates; companies: typeof companies; + crons: typeof crons; fields: typeof fields; files: typeof files; invites: typeof invites; diff --git a/convex/alerts.ts b/convex/alerts.ts index 14ccc24..8b0b9ab 100644 --- a/convex/alerts.ts +++ b/convex/alerts.ts @@ -1,70 +1,8 @@ -import tls from "tls" - import { action, mutation, query } from "./_generated/server" import { api } from "./_generated/api" import { v } from "convex/values" import type { Id } from "./_generated/dataModel" -// Minimal SMTP client (AUTH LOGIN over implicit TLS) -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((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 /) - 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) - }) -} - export const log = mutation({ args: { tenantId: v.string(), @@ -198,96 +136,3 @@ export const existsForCompanyRange = query({ return items.some((a) => a.companyId === companyId && a.createdAt >= start && a.createdAt < end) }, }) - -export const sendHoursUsageAlerts = action({ - args: { range: v.optional(v.string()), threshold: v.optional(v.number()) }, - handler: async (ctx, { range, threshold }) => { - const R = (range ?? "30d") as string - const T = typeof threshold === "number" ? threshold : 90 - - 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) { - console.warn("SMTP not configured; skipping alerts send") - return { sent: 0 } - } - - const targetHour = Number(process.env.ALERTS_LOCAL_HOUR ?? 8) - const now = new Date() - const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) - const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value])) as Record - const hourSP = Number(parts.hour) - if (hourSP !== targetHour) { - return { skipped: true, reason: "hour_guard" } - } - - const dayKey = `${parts.year}-${parts.month}-${parts.day}` - // compute start/end of Sao Paulo day in UTC milliseconds - const startSP = new Date(`${dayKey}T00:00:00-03:00`).getTime() - const endSP = startSP + 24 * 60 * 60 * 1000 - - const tenants = await ctx.runQuery(api.alerts.tenantIds, {}) - let totalSent = 0 - - for (const tenantId of tenants) { - const report = await ctx.runQuery(api.reports.hoursByClientInternal, { tenantId, range: R }) - type Item = { - companyId: Id<"companies"> - name: string - internalMs: number - externalMs: number - totalMs: number - contractedHoursPerMonth: number | null - } - const items = (report.items ?? []) as Item[] - const candidates = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= T) - - for (const item of candidates) { - const already = await ctx.runQuery(api.alerts.existsForCompanyRange, { tenantId, companyId: item.companyId, start: startSP, end: endSP }) - if (already) continue - const managers = await ctx.runQuery(api.alerts.managersForCompany, { tenantId, companyId: item.companyId }) - if (managers.length === 0) continue - const usagePct = (((item.totalMs / 3600000) / (item.contractedHoursPerMonth || 1)) * 100) - const subject = `Alerta: uso de horas em ${item.name} acima de ${T}%` - const body = ` -

Olá,

-

O uso de horas contratadas para ${item.name} atingiu ${usagePct.toFixed(1)}%.

-
    -
  • Horas internas: ${(item.internalMs/3600000).toFixed(2)}
  • -
  • Horas externas: ${(item.externalMs/3600000).toFixed(2)}
  • -
  • Total: ${(item.totalMs/3600000).toFixed(2)}
  • -
  • Contratadas/mês: ${item.contractedHoursPerMonth}
  • -
-

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

- ` - let delivered = 0 - for (const m of managers) { - try { - await sendSmtpMail(smtp, m.email, subject, body) - delivered += 1 - } catch (error) { - console.error("Failed to send alert to", m.email, error) - } - } - totalSent += delivered - await ctx.runMutation(api.alerts.log, { - tenantId, - companyId: item.companyId, - companyName: item.name, - usagePct, - threshold: T, - range: R, - recipients: managers.map((m) => m.email), - deliveredCount: delivered, - }) - } - } - - return { sent: totalSent } - }, -}) diff --git a/convex/alerts_actions.ts b/convex/alerts_actions.ts new file mode 100644 index 0000000..6d40b14 --- /dev/null +++ b/convex/alerts_actions.ts @@ -0,0 +1,160 @@ +"use node" + +import tls from "tls" + +import { action } from "./_generated/server" +import { api } from "./_generated/api" +import { v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +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((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 /) + 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) + }) +} + +export const sendHoursUsageAlerts = action({ + args: { range: v.optional(v.string()), threshold: v.optional(v.number()) }, + handler: async (ctx, { range, threshold }) => { + const R = (range ?? "30d") as string + const T = typeof threshold === "number" ? threshold : 90 + + 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) { + console.warn("SMTP not configured; skipping alerts send") + return { sent: 0 } + } + + const targetHour = Number(process.env.ALERTS_LOCAL_HOUR ?? 8) + const now = new Date() + const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) + const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value])) as Record + const hourSP = Number(parts.hour) + if (hourSP !== targetHour) { + return { skipped: true, reason: "hour_guard" } + } + + const dayKey = `${parts.year}-${parts.month}-${parts.day}` + const startSP = new Date(`${dayKey}T00:00:00-03:00`).getTime() + const endSP = startSP + 24 * 60 * 60 * 1000 + + const tenants = await ctx.runQuery(api.alerts.tenantIds, {}) + let totalSent = 0 + + for (const tenantId of tenants) { + const report = await ctx.runQuery(api.reports.hoursByClientInternal, { tenantId, range: R }) + type Item = { + companyId: Id<"companies"> + name: string + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth: number | null + } + const items = (report.items ?? []) as Item[] + const candidates = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= T) + + for (const item of candidates) { + const already = await ctx.runQuery(api.alerts.existsForCompanyRange, { tenantId, companyId: item.companyId, start: startSP, end: endSP }) + if (already) continue + const managers = await ctx.runQuery(api.alerts.managersForCompany, { tenantId, companyId: item.companyId }) + if (managers.length === 0) continue + const usagePct = (((item.totalMs / 3600000) / (item.contractedHoursPerMonth || 1)) * 100) + const subject = `Alerta: uso de horas em ${item.name} acima de ${T}%` + const body = ` +

Olá,

+

O uso de horas contratadas para ${item.name} atingiu ${usagePct.toFixed(1)}%.

+
    +
  • Horas internas: ${(item.internalMs/3600000).toFixed(2)}
  • +
  • Horas externas: ${(item.externalMs/3600000).toFixed(2)}
  • +
  • Total: ${(item.totalMs/3600000).toFixed(2)}
  • +
  • Contratadas/mês: ${item.contractedHoursPerMonth}
  • +
+

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

+ ` + let delivered = 0 + for (const m of managers) { + try { + await sendSmtpMail(smtp, m.email, subject, body) + delivered += 1 + } catch (error) { + console.error("Failed to send alert to", m.email, error) + } + } + totalSent += delivered + await ctx.runMutation(api.alerts.log, { + tenantId, + companyId: item.companyId, + companyName: item.name, + usagePct, + threshold: T, + range: R, + recipients: managers.map((m) => m.email), + deliveredCount: delivered, + }) + } + } + + return { sent: totalSent } + }, +}) + diff --git a/convex/crons.ts b/convex/crons.ts index 8e3b829..38bacdb 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -4,6 +4,6 @@ import { api } from "./_generated/api" const crons = cronJobs() // Check hourly and the action will gate by America/Sao_Paulo hour -crons.interval("hours-usage-alerts-hourly", { hours: 1 }, api.alerts.sendHoursUsageAlerts) +crons.interval("hours-usage-alerts-hourly", { hours: 1 }, api.alerts_actions.sendHoursUsageAlerts) export default crons diff --git a/convex/tickets.ts b/convex/tickets.ts index 6844dbb..42ae8f8 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -738,93 +738,6 @@ 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; }, });