Hours by client: add search and CSV filtering; add alerts cron (BRT 08:00 guard) + alerts panel filters; admin companies shows last alert; PDF Inter font from public/fonts; fix Select empty value; type cleanups; tests for CSV/TZ; remove Knowledge Base nav

This commit is contained in:
Esdras Renan 2025-10-07 15:39:55 -03:00
parent 2cf399dcb1
commit 08cc8037d5
151 changed files with 1404 additions and 214 deletions

View file

@ -8,9 +8,11 @@
* @module
*/
import type * as alerts from "../alerts.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 fields from "../fields.js";
import type * as files from "../files.js";
import type * as invites from "../invites.js";
@ -39,9 +41,11 @@ import type {
* ```
*/
declare const fullApi: ApiFromModules<{
alerts: typeof alerts;
bootstrap: typeof bootstrap;
categories: typeof categories;
commentTemplates: typeof commentTemplates;
companies: typeof companies;
fields: typeof fields;
files: typeof files;
invites: typeof invites;

263
convex/alerts.ts Normal file
View file

@ -0,0 +1,263 @@
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 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 }
},
})

16
convex/companies.ts Normal file
View file

@ -0,0 +1,16 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
import { requireStaff } from "./rbac";
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId)
const companies = await ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return companies.map((c) => ({ id: c._id, name: c.name }))
},
})

9
convex/crons.ts Normal file
View file

@ -0,0 +1,9 @@
import { cronJobs } from "convex/server"
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)
export default crons

View file

@ -128,10 +128,11 @@ function formatDateKey(timestamp: number) {
}
export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId }) => {
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
handler: async (ctx, { tenantId, viewerId, companyId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
const queues = await fetchQueues(ctx, tenantId);
const now = Date.now();
@ -179,10 +180,11 @@ export const slaOverview = query({
});
export const csatOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId, range }) => {
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
const surveysAll = await collectCsatSurveys(ctx, tickets);
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date();
@ -415,7 +417,7 @@ export const hoursByClient = query({
// Accumulate by company
type Acc = {
companyId: string
companyId: Id<"companies">
name: string
isAvulso: boolean
internalMs: number
@ -428,7 +430,7 @@ export const hoursByClient = query({
for (const t of tickets) {
// only consider tickets updated in range as a proxy for recent work
if (t.updatedAt < startMs || t.updatedAt >= endMs) continue
const companyId = (t as any).companyId ?? null
const companyId = t.companyId ?? null
if (!companyId) continue
let acc = map.get(companyId)
@ -436,17 +438,82 @@ export const hoursByClient = query({
const company = await ctx.db.get(companyId)
acc = {
companyId,
name: (company as any)?.name ?? "Sem empresa",
isAvulso: Boolean((company as any)?.isAvulso ?? false),
name: company?.name ?? "Sem empresa",
isAvulso: Boolean(company?.isAvulso ?? false),
internalMs: 0,
externalMs: 0,
totalMs: 0,
contractedHoursPerMonth: (company as any)?.contractedHoursPerMonth ?? null,
contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null,
}
map.set(companyId, acc)
}
const internal = ((t as any).internalWorkedMs ?? 0) as number
const external = ((t as any).externalWorkedMs ?? 0) as number
const internal = t.internalWorkedMs ?? 0
const external = t.externalWorkedMs ?? 0
acc.internalMs += internal
acc.externalMs += external
acc.totalMs += internal + external
}
const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs)
return {
rangeDays: days,
items: items.map((i) => ({
companyId: i.companyId,
name: i.name,
isAvulso: i.isAvulso,
internalMs: i.internalMs,
externalMs: i.externalMs,
totalMs: i.totalMs,
contractedHoursPerMonth: i.contractedHoursPerMonth ?? null,
})),
}
},
})
// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant
export const hoursByClientInternal = query({
args: { tenantId: v.string(), range: v.optional(v.string()) },
handler: async (ctx, { tenantId, range }) => {
const tickets = await fetchTickets(ctx, tenantId)
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
const end = new Date()
end.setUTCHours(0, 0, 0, 0)
const endMs = end.getTime() + ONE_DAY_MS
const startMs = endMs - days * ONE_DAY_MS
type Acc = {
companyId: Id<"companies">
name: string
isAvulso: boolean
internalMs: number
externalMs: number
totalMs: number
contractedHoursPerMonth?: number | null
}
const map = new Map<string, Acc>()
for (const t of tickets) {
if (t.updatedAt < startMs || t.updatedAt >= endMs) continue
const companyId = t.companyId ?? null
if (!companyId) continue
let acc = map.get(companyId)
if (!acc) {
const company = await ctx.db.get(companyId)
acc = {
companyId,
name: company?.name ?? "Sem empresa",
isAvulso: Boolean(company?.isAvulso ?? false),
internalMs: 0,
externalMs: 0,
totalMs: 0,
contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null,
}
map.set(companyId, acc)
}
const internal = t.internalWorkedMs ?? 0
const external = t.externalWorkedMs ?? 0
acc.internalMs += internal
acc.externalMs += external
acc.totalMs += internal + external

View file

@ -33,6 +33,20 @@ export default defineSchema({
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]),
alerts: defineTable({
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()),
createdAt: v.number(),
deliveredCount: v.number(),
})
.index("by_tenant_created", ["tenantId", "createdAt"])
.index("by_tenant", ["tenantId"]),
queues: defineTable({
tenantId: v.string(),
name: v.string(),