convex: mover action de envio de alertas para arquivo Node ('alerts_actions.ts' com 'use node'); remover import de 'tls' do módulo de queries/mutations; ajustar cron para usar api.alerts_actions; remover tentativa de envio de e-mail no mutation addComment (evitar Node API em isolate).
This commit is contained in:
parent
3814203de3
commit
5b14ecbe0f
5 changed files with 165 additions and 243 deletions
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
155
convex/alerts.ts
155
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<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)
|
||||
})
|
||||
}
|
||||
|
||||
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<string, string>
|
||||
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 = `
|
||||
<p>Olá,</p>
|
||||
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${usagePct.toFixed(1)}%</strong>.</p>
|
||||
<ul>
|
||||
<li>Horas internas: <strong>${(item.internalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Horas externas: <strong>${(item.externalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Total: <strong>${(item.totalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Contratadas/mês: <strong>${item.contractedHoursPerMonth}</strong></li>
|
||||
</ul>
|
||||
<p>Reveja a alocação da equipe e, se necessário, ajuste o atendimento.</p>
|
||||
`
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
160
convex/alerts_actions.ts
Normal file
160
convex/alerts_actions.ts
Normal file
|
|
@ -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<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)
|
||||
})
|
||||
}
|
||||
|
||||
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<string, string>
|
||||
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 = `
|
||||
<p>Olá,</p>
|
||||
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${usagePct.toFixed(1)}%</strong>.</p>
|
||||
<ul>
|
||||
<li>Horas internas: <strong>${(item.internalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Horas externas: <strong>${(item.externalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Total: <strong>${(item.totalMs/3600000).toFixed(2)}</strong></li>
|
||||
<li>Contratadas/mês: <strong>${item.contractedHoursPerMonth}</strong></li>
|
||||
</ul>
|
||||
<p>Reveja a alocação da equipe e, se necessário, ajuste o atendimento.</p>
|
||||
`
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 /)
|
||||
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 = `
|
||||
<p>Olá,</p>
|
||||
<p>Seu chamado <strong>${ticketRef}</strong> recebeu um novo comentário:</p>
|
||||
<blockquote style="border-left: 3px solid #e5e7eb; margin: 8px 0; padding: 8px 12px; color: #374151;">${args.body}</blockquote>
|
||||
<p>Atenciosamente,<br/>Equipe de suporte</p>
|
||||
`
|
||||
await sendSmtpMail(smtp, to, subject, body)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send public comment notification", error)
|
||||
}
|
||||
}
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue