Remove hours usage cron/action
This commit is contained in:
parent
07a6f101b8
commit
674c62208f
4 changed files with 0 additions and 300 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -9,7 +9,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as alerts from "../alerts.js";
|
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 bootstrap from "../bootstrap.js";
|
||||||
import type * as categories from "../categories.js";
|
import type * as categories from "../categories.js";
|
||||||
import type * as categorySlas from "../categorySlas.js";
|
import type * as categorySlas from "../categorySlas.js";
|
||||||
|
|
@ -57,7 +56,6 @@ import type {
|
||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
alerts: typeof alerts;
|
alerts: typeof alerts;
|
||||||
alerts_actions: typeof alerts_actions;
|
|
||||||
bootstrap: typeof bootstrap;
|
bootstrap: typeof bootstrap;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
categorySlas: typeof categorySlas;
|
categorySlas: typeof categorySlas;
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"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 }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { NextResponse } from "next/server"
|
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
|
||||||
import { assertAdminSession } from "@/lib/auth-server"
|
|
||||||
import { env } from "@/lib/env"
|
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import { sendSmtpMail } from "@/server/email-smtp"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
|
|
||||||
function fmtHours(ms: number) {
|
|
||||||
const hours = ms / 3600000
|
|
||||||
if (hours > 0 && hours < 1) {
|
|
||||||
const mins = Math.round(hours * 60)
|
|
||||||
return `${mins} min`
|
|
||||||
}
|
|
||||||
return `${hours.toFixed(2)} h`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await assertAdminSession()
|
|
||||||
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
|
||||||
|
|
||||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
|
||||||
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
|
||||||
if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 })
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const range = searchParams.get("range") ?? "30d"
|
|
||||||
const threshold = Number(searchParams.get("threshold") ?? 90)
|
|
||||||
|
|
||||||
const client = new ConvexHttpClient(convexUrl)
|
|
||||||
const tenantId = session.user.tenantId ?? "tenant-atlas"
|
|
||||||
|
|
||||||
// Ensure user exists in Convex to obtain a typed viewerId
|
|
||||||
let viewerId: Id<"users"> | null = null
|
|
||||||
try {
|
|
||||||
const ensured = await client.mutation(api.users.ensureUser, {
|
|
||||||
tenantId,
|
|
||||||
name: session.user.name ?? session.user.email,
|
|
||||||
email: session.user.email,
|
|
||||||
avatarUrl: session.user.avatarUrl ?? undefined,
|
|
||||||
role: session.user.role.toUpperCase(),
|
|
||||||
})
|
|
||||||
viewerId = (ensured?._id ?? null) as Id<"users"> | null
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to synchronize user with Convex for alerts", error)
|
|
||||||
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
|
|
||||||
}
|
|
||||||
if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
|
|
||||||
|
|
||||||
const report = await client.query(api.reports.hoursByClient, {
|
|
||||||
tenantId,
|
|
||||||
viewerId,
|
|
||||||
range,
|
|
||||||
})
|
|
||||||
|
|
||||||
type HoursByClientItem = {
|
|
||||||
companyId: Id<"companies">
|
|
||||||
name: string
|
|
||||||
internalMs: number
|
|
||||||
externalMs: number
|
|
||||||
totalMs: number
|
|
||||||
contractedHoursPerMonth: number | null
|
|
||||||
}
|
|
||||||
const items = (report.items ?? []) as HoursByClientItem[]
|
|
||||||
const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold)
|
|
||||||
|
|
||||||
type ManagerUser = { email: string; name: string | null }
|
|
||||||
|
|
||||||
for (const item of alerts) {
|
|
||||||
// Find managers of the company in Prisma
|
|
||||||
const managers: ManagerUser[] = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
tenantId,
|
|
||||||
companyId: item.companyId,
|
|
||||||
role: "MANAGER",
|
|
||||||
},
|
|
||||||
select: { email: true, name: true },
|
|
||||||
})
|
|
||||||
if (managers.length === 0) continue
|
|
||||||
|
|
||||||
const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%`
|
|
||||||
const body = `
|
|
||||||
<p>Olá,</p>
|
|
||||||
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%</strong>.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Horas internas: <strong>${fmtHours(item.internalMs)}</strong></li>
|
|
||||||
<li>Horas externas: <strong>${fmtHours(item.externalMs)}</strong></li>
|
|
||||||
<li>Total: <strong>${fmtHours(item.totalMs)}</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(
|
|
||||||
{
|
|
||||||
host: env.SMTP!.host,
|
|
||||||
port: env.SMTP!.port,
|
|
||||||
username: env.SMTP!.username,
|
|
||||||
password: env.SMTP!.password,
|
|
||||||
from: env.SMTP!.from!,
|
|
||||||
},
|
|
||||||
m.email,
|
|
||||||
subject,
|
|
||||||
body
|
|
||||||
)
|
|
||||||
delivered += 1
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send alert to", m.email, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await client.mutation(api.alerts.log, {
|
|
||||||
tenantId,
|
|
||||||
companyId: item.companyId,
|
|
||||||
companyName: item.name,
|
|
||||||
usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100),
|
|
||||||
threshold,
|
|
||||||
range,
|
|
||||||
recipients: managers.map((m) => m.email),
|
|
||||||
deliveredCount: delivered,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to log alert in Convex", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ sent: alerts.length })
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
|
||||||
export function AdminAlertsManager() {
|
export function AdminAlertsManager() {
|
||||||
|
|
@ -90,9 +89,6 @@ export function AdminAlertsManager() {
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href="/api/admin/alerts/hours-usage?range=30d&threshold=90">Disparar manualmente</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue