chore: sync staging

This commit is contained in:
Esdras Renan 2025-11-10 01:57:45 -03:00
parent c5ddd54a3e
commit 561b19cf66
610 changed files with 105285 additions and 1206 deletions

View file

@ -23,6 +23,7 @@ import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as incidents from "../incidents.js";
import type * as invites from "../invites.js";
import type * as machines from "../machines.js";
import type * as metrics from "../metrics.js";
@ -70,6 +71,7 @@ declare const fullApi: ApiFromModules<{
devices: typeof devices;
fields: typeof fields;
files: typeof files;
incidents: typeof incidents;
invites: typeof invites;
machines: typeof machines;
metrics: typeof metrics;

View file

@ -225,6 +225,8 @@ export const list = query({
slug: category.slug,
description: category.description,
order: category.order,
createdAt: category.createdAt,
updatedAt: category.updatedAt,
secondary: subcategories
.filter((item) => item.categoryId === category._id)
.sort((a, b) => a.order - b.order)
@ -233,6 +235,9 @@ export const list = query({
name: item.name,
slug: item.slug,
order: item.order,
categoryId: String(item.categoryId),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
})),
}))
},

View file

@ -1,5 +1,13 @@
import { cronJobs } from "convex/server"
import { api } from "./_generated/api"
const crons = cronJobs()
crons.interval(
"report-export-runner",
{ minutes: 15 },
api.reports.triggerScheduledExports,
{}
)
export default crons

178
convex/incidents.ts Normal file
View file

@ -0,0 +1,178 @@
import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import type { Id } from "./_generated/dataModel"
import { requireStaff } from "./rbac"
const DEFAULT_STATUS = "investigating"
function timelineId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId)
const incidents = await ctx.db
.query("incidents")
.withIndex("by_tenant_updated", (q) => q.eq("tenantId", tenantId))
.order("desc")
.collect()
return incidents
},
})
export const createIncident = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
title: v.string(),
severity: v.string(),
impactSummary: v.optional(v.string()),
affectedQueues: v.optional(v.array(v.string())),
initialUpdate: v.optional(v.string()),
},
handler: async (ctx, { tenantId, actorId, title, severity, impactSummary, affectedQueues, initialUpdate }) => {
const viewer = await requireStaff(ctx, actorId, tenantId)
const normalizedTitle = title.trim()
if (normalizedTitle.length < 3) {
throw new ConvexError("Informe um título válido para o incidente")
}
const now = Date.now()
const timelineEntry = {
id: timelineId(),
authorId: actorId,
authorName: viewer.user.name ?? viewer.user.email ?? "Equipe",
message: initialUpdate?.trim().length ? initialUpdate.trim() : "Incidente registrado.",
type: "created",
createdAt: now,
}
const id = await ctx.db.insert("incidents", {
tenantId,
title: normalizedTitle,
status: DEFAULT_STATUS,
severity,
impactSummary: impactSummary?.trim() || undefined,
affectedQueues: affectedQueues ?? [],
ownerId: actorId,
ownerName: viewer.user.name ?? undefined,
ownerEmail: viewer.user.email ?? undefined,
startedAt: now,
updatedAt: now,
resolvedAt: undefined,
timeline: [timelineEntry],
})
return id
},
})
export const updateIncidentStatus = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
incidentId: v.id("incidents"),
status: v.string(),
},
handler: async (ctx, { tenantId, actorId, incidentId, status }) => {
const viewer = await requireStaff(ctx, actorId, tenantId)
const incident = await ctx.db.get(incidentId)
if (!incident || incident.tenantId !== tenantId) {
throw new ConvexError("Incidente não encontrado")
}
const now = Date.now()
const timeline = [
...(incident.timeline ?? []),
{
id: timelineId(),
authorId: actorId,
authorName: viewer.user.name ?? viewer.user.email ?? "Equipe",
message: `Status atualizado para ${status}`,
type: "status",
createdAt: now,
},
]
await ctx.db.patch(incidentId, {
status,
updatedAt: now,
resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined,
timeline,
})
},
})
export const bulkUpdateIncidentStatus = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
incidentIds: v.array(v.id("incidents")),
status: v.string(),
},
handler: async (ctx, { tenantId, actorId, incidentIds, status }) => {
const viewer = await requireStaff(ctx, actorId, tenantId)
const now = Date.now()
for (const incidentId of incidentIds) {
const incident = await ctx.db.get(incidentId)
if (!incident || incident.tenantId !== tenantId) continue
const timeline = [
...(incident.timeline ?? []),
{
id: timelineId(),
authorId: actorId,
authorName: viewer.user.name ?? viewer.user.email ?? "Equipe",
message: `Status atualizado em massa para ${status}`,
type: "status",
createdAt: now,
},
]
await ctx.db.patch(incidentId, {
status,
updatedAt: now,
resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined,
timeline,
})
}
},
})
export const addIncidentUpdate = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
incidentId: v.id("incidents"),
message: v.string(),
status: v.optional(v.string()),
},
handler: async (ctx, { tenantId, actorId, incidentId, message, status }) => {
const viewer = await requireStaff(ctx, actorId, tenantId)
const incident = await ctx.db.get(incidentId)
if (!incident || incident.tenantId !== tenantId) {
throw new ConvexError("Incidente não encontrado")
}
const trimmed = message.trim()
if (trimmed.length < 3) {
throw new ConvexError("Descreva a atualização do incidente")
}
const now = Date.now()
const timeline = [
...(incident.timeline ?? []),
{
id: timelineId(),
authorId: actorId,
authorName: viewer.user.name ?? viewer.user.email ?? "Equipe",
message: trimmed,
type: "update",
createdAt: now,
},
]
await ctx.db.patch(incidentId, {
timeline,
status: status ?? incident.status,
updatedAt: now,
resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined,
})
},
})

View file

@ -1,4 +1,4 @@
import { query } from "./_generated/server";
import { action, query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
@ -503,6 +503,41 @@ export const slaOverview = query({
handler: slaOverviewHandler,
});
export const triggerScheduledExports = action({
args: {
tenantId: v.optional(v.string()),
},
handler: async (_ctx, args) => {
const secret = process.env.REPORTS_CRON_SECRET
const baseUrl =
process.env.REPORTS_CRON_BASE_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
process.env.BETTER_AUTH_URL
if (!secret || !baseUrl) {
console.warn("[reports] cron skip: missing REPORTS_CRON_SECRET or base URL")
return { skipped: true }
}
const endpoint = `${baseUrl.replace(/\/$/, "")}/api/reports/schedules/run`
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${secret}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tenantId: args.tenantId }),
})
if (!response.ok) {
const detail = await response.text().catch(() => response.statusText)
throw new ConvexError(`Falha ao disparar agendamentos: ${response.status} ${detail}`)
}
return response.json()
},
})
export async function csatOverviewHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
@ -716,6 +751,101 @@ export const backlogOverview = query({
handler: backlogOverviewHandler,
});
type QueueTrendPoint = { date: string; opened: number; resolved: number }
type QueueTrendEntry = {
id: string
name: string
openedTotal: number
resolvedTotal: number
series: Map<string, QueueTrendPoint>
}
export async function queueLoadTrendHandler(
ctx: QueryCtx,
{
tenantId,
viewerId,
range,
limit,
}: { tenantId: string; viewerId: Id<"users">; range?: string; limit?: number }
) {
const viewer = await requireStaff(ctx, viewerId, tenantId)
const days = range === "90d" ? 90 : range === "30d" ? 30 : 14
const end = new Date()
end.setUTCHours(0, 0, 0, 0)
const endMs = end.getTime() + ONE_DAY_MS
const startMs = endMs - days * ONE_DAY_MS
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
const queues = await fetchQueues(ctx, tenantId)
const queueNames = new Map<string, string>()
queues.forEach((queue) => queueNames.set(String(queue._id), queue.name))
queueNames.set("unassigned", "Sem fila")
const dayKeys: string[] = []
for (let i = days - 1; i >= 0; i--) {
const key = formatDateKey(endMs - (i + 1) * ONE_DAY_MS)
dayKeys.push(key)
}
const stats = new Map<string, QueueTrendEntry>()
const ensureEntry = (queueId: string) => {
if (!stats.has(queueId)) {
const series = new Map<string, QueueTrendPoint>()
dayKeys.forEach((key) => {
series.set(key, { date: key, opened: 0, resolved: 0 })
})
stats.set(queueId, {
id: queueId,
name: queueNames.get(queueId) ?? "Sem fila",
openedTotal: 0,
resolvedTotal: 0,
series,
})
}
return stats.get(queueId)!
}
for (const ticket of tickets) {
const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned"
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
const entry = ensureEntry(queueId)
const bucket = entry.series.get(formatDateKey(ticket.createdAt))
if (bucket) {
bucket.opened += 1
}
entry.openedTotal += 1
}
if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) {
const entry = ensureEntry(queueId)
const bucket = entry.series.get(formatDateKey(ticket.resolvedAt))
if (bucket) {
bucket.resolved += 1
}
entry.resolvedTotal += 1
}
}
const maxEntries = Math.max(1, Math.min(limit ?? 3, 6))
const queuesTrend = Array.from(stats.values())
.sort((a, b) => b.openedTotal - a.openedTotal)
.slice(0, maxEntries)
.map((entry) => ({
id: entry.id,
name: entry.name,
openedTotal: entry.openedTotal,
resolvedTotal: entry.resolvedTotal,
series: dayKeys.map((key) => entry.series.get(key)!),
}))
return { rangeDays: days, queues: queuesTrend }
}
export const queueLoadTrend = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), limit: v.optional(v.number()) },
handler: queueLoadTrendHandler,
})
// Touch to ensure CI convex_deploy runs and that agentProductivity is deployed
export async function agentProductivityHandler(
ctx: QueryCtx,

View file

@ -436,6 +436,34 @@ export default defineSchema({
.index("by_ticket_agent", ["ticketId", "agentId"])
.index("by_agent", ["agentId"]),
incidents: defineTable({
tenantId: v.string(),
title: v.string(),
status: v.string(),
severity: v.string(),
impactSummary: v.optional(v.string()),
affectedQueues: v.array(v.string()),
ownerId: v.optional(v.id("users")),
ownerName: v.optional(v.string()),
ownerEmail: v.optional(v.string()),
startedAt: v.number(),
updatedAt: v.number(),
resolvedAt: v.optional(v.number()),
timeline: v.array(
v.object({
id: v.string(),
authorId: v.id("users"),
authorName: v.optional(v.string()),
message: v.string(),
type: v.optional(v.string()),
createdAt: v.number(),
})
),
})
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_updated", ["tenantId", "updatedAt"])
.index("by_tenant", ["tenantId"]),
ticketCategories: defineTable({
tenantId: v.string(),
name: v.string(),