293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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(),
|
|
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 lastForCompaniesBySlugs = query({
|
|
args: { tenantId: v.string(), slugs: v.array(v.string()) },
|
|
handler: async (ctx, { tenantId, slugs }) => {
|
|
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
|
|
// Fetch all alerts once for the tenant
|
|
const alerts = await ctx.db
|
|
.query("alerts")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
for (const slug of slugs) {
|
|
const company = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
|
.first()
|
|
if (!company) {
|
|
result[slug] = null
|
|
continue
|
|
}
|
|
const matches = alerts.filter((a) => a.companyId === company._id)
|
|
if (matches.length === 0) {
|
|
result[slug] = null
|
|
continue
|
|
}
|
|
const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0]
|
|
result[slug] = { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold }
|
|
}
|
|
return result
|
|
},
|
|
})
|
|
|
|
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<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 }
|
|
},
|
|
})
|