From 674c62208fe5868a6e27fb8ece1d0827f570be1d Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 18 Nov 2025 09:34:56 -0300 Subject: [PATCH] Remove hours usage cron/action --- convex/_generated/api.d.ts | 2 - convex/alerts_actions.ts | 160 ------------------ src/app/api/admin/alerts/hours-usage/route.ts | 134 --------------- .../admin/alerts/admin-alerts-manager.tsx | 4 - 4 files changed, 300 deletions(-) delete mode 100644 convex/alerts_actions.ts delete mode 100644 src/app/api/admin/alerts/hours-usage/route.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a511670..3c19a3a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,7 +9,6 @@ */ 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 categorySlas from "../categorySlas.js"; @@ -57,7 +56,6 @@ import type { */ declare const fullApi: ApiFromModules<{ alerts: typeof alerts; - alerts_actions: typeof alerts_actions; bootstrap: typeof bootstrap; categories: typeof categories; categorySlas: typeof categorySlas; diff --git a/convex/alerts_actions.ts b/convex/alerts_actions.ts deleted file mode 100644 index 6d40b14..0000000 --- a/convex/alerts_actions.ts +++ /dev/null @@ -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((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 } - }, -}) - diff --git a/src/app/api/admin/alerts/hours-usage/route.ts b/src/app/api/admin/alerts/hours-usage/route.ts deleted file mode 100644 index f69fed3..0000000 --- a/src/app/api/admin/alerts/hours-usage/route.ts +++ /dev/null @@ -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 = ` -

Olá,

-

O uso de horas contratadas para ${item.name} atingiu ${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%.

-
    -
  • Horas internas: ${fmtHours(item.internalMs)}
  • -
  • Horas externas: ${fmtHours(item.externalMs)}
  • -
  • Total: ${fmtHours(item.totalMs)}
  • -
  • 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( - { - 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 }) -} diff --git a/src/components/admin/alerts/admin-alerts-manager.tsx b/src/components/admin/alerts/admin-alerts-manager.tsx index ef921d9..22f448f 100644 --- a/src/components/admin/alerts/admin-alerts-manager.tsx +++ b/src/components/admin/alerts/admin-alerts-manager.tsx @@ -10,7 +10,6 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" 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" export function AdminAlertsManager() { @@ -90,9 +89,6 @@ export function AdminAlertsManager() { Todos -