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(), companyId: v.optional(v.id("companies")), companyName: v.string(), usagePct: v.number(), threshold: v.number(), range: v.string(), recipients: v.array(v.string()), deliveredCount: v.number(), }, handler: async (ctx, args) => { const now = Date.now() await ctx.db.insert("alerts", { tenantId: args.tenantId, companyId: args.companyId, companyName: args.companyName, usagePct: args.usagePct, threshold: args.threshold, range: args.range, recipients: args.recipients, deliveredCount: args.deliveredCount, createdAt: now, }) }, }) export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), limit: v.optional(v.number()), companyId: v.optional(v.id("companies")), start: v.optional(v.number()), end: v.optional(v.number()), }, handler: async (ctx, { tenantId, viewerId, limit, companyId, start, end }) => { // Only admins can see the full alerts log const user = await ctx.db.get(viewerId) if (!user || user.tenantId !== tenantId || (user.role ?? "").toUpperCase() !== "ADMIN") { return [] } let items = await ctx.db .query("alerts") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() if (companyId) items = items.filter((a) => a.companyId === companyId) if (typeof start === "number") items = items.filter((a) => a.createdAt >= start) if (typeof end === "number") items = items.filter((a) => a.createdAt < end) return items .sort((a, b) => b.createdAt - a.createdAt) .slice(0, Math.max(1, Math.min(limit ?? 200, 500))) }, }) export const managersForCompany = query({ args: { tenantId: v.string(), companyId: v.id("companies") }, handler: async (ctx, { tenantId, companyId }) => { const users = await ctx.db .query("users") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .collect() return users.filter((u) => (u.role ?? "").toUpperCase() === "MANAGER") }, }) export const lastForCompanyBySlug = query({ args: { tenantId: v.string(), slug: v.string() }, handler: async (ctx, { tenantId, slug }) => { const company = await ctx.db .query("companies") .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) .first() if (!company) return null const items = await ctx.db .query("alerts") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() const matches = items.filter((a) => a.companyId === company._id) if (matches.length === 0) return null const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0] return { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold } }, }) export const tenantIds = query({ args: {}, handler: async (ctx) => { const companies = await ctx.db.query("companies").collect() return Array.from(new Set(companies.map((c) => c.tenantId))) }, }) export const existsForCompanyRange = query({ args: { tenantId: v.string(), companyId: v.id("companies"), start: v.number(), end: v.number() }, handler: async (ctx, { tenantId, companyId, start, end }) => { const items = await ctx.db .query("alerts") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() 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 } }, })