"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 } }, })