chore: sync staging
This commit is contained in:
parent
c5ddd54a3e
commit
561b19cf66
610 changed files with 105285 additions and 1206 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
178
convex/incidents.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue