fix: automações (gatilhos, histórico) e chat desktop

This commit is contained in:
esdrasrenan 2025-12-13 11:26:42 -03:00
parent 8ab510bfe9
commit e4d0c95791
7 changed files with 670 additions and 53 deletions

View file

@ -1,4 +1,5 @@
import { ConvexError, v } from "convex/values"
import { paginationOptsValidator } from "convex/server"
import { api } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel"
@ -22,7 +23,18 @@ type AutomationAction =
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
| { type: "ADD_INTERNAL_COMMENT"; body: string }
const TRIGGERS: AutomationTrigger[] = ["TICKET_CREATED", "STATUS_CHANGED", "COMMENT_ADDED", "TICKET_RESOLVED"]
type AutomationRunStatus = "SUCCESS" | "SKIPPED" | "ERROR"
type AppliedAction = { type: string; details?: Record<string, unknown> }
const TRIGGERS: AutomationTrigger[] = [
"TICKET_CREATED",
"STATUS_CHANGED",
"PRIORITY_CHANGED",
"QUEUE_CHANGED",
"COMMENT_ADDED",
"TICKET_RESOLVED",
]
const TIMINGS = ["IMMEDIATE", "DELAYED"] as const
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
@ -62,6 +74,58 @@ function parseConditions(raw: unknown): AutomationConditionGroup | null {
}
}
function parseRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseAction(value: unknown): AutomationAction | null {
const record = parseRecord(value)
if (!record) return null
const type = typeof record.type === "string" ? record.type.trim().toUpperCase() : ""
if (type === "SET_PRIORITY") {
const priority = typeof record.priority === "string" ? record.priority.trim().toUpperCase() : ""
if (!priority) return null
return { type: "SET_PRIORITY", priority }
}
if (type === "MOVE_QUEUE") {
const queueId = typeof record.queueId === "string" ? record.queueId : ""
if (!queueId) return null
return { type: "MOVE_QUEUE", queueId: queueId as Id<"queues"> }
}
if (type === "ASSIGN_TO") {
const assigneeId = typeof record.assigneeId === "string" ? record.assigneeId : ""
if (!assigneeId) return null
return { type: "ASSIGN_TO", assigneeId: assigneeId as Id<"users"> }
}
if (type === "SET_FORM_TEMPLATE") {
const formTemplateRaw = record.formTemplate
if (formTemplateRaw === null || formTemplateRaw === undefined) {
return { type: "SET_FORM_TEMPLATE", formTemplate: null }
}
if (typeof formTemplateRaw !== "string") return null
const formTemplate = formTemplateRaw.trim()
return { type: "SET_FORM_TEMPLATE", formTemplate: formTemplate.length > 0 ? formTemplate : null }
}
if (type === "SET_CHAT_ENABLED") {
if (typeof record.enabled !== "boolean") return null
return { type: "SET_CHAT_ENABLED", enabled: record.enabled }
}
if (type === "ADD_INTERNAL_COMMENT") {
const body = typeof record.body === "string" ? record.body : ""
if (!body.trim()) return null
return { type: "ADD_INTERNAL_COMMENT", body }
}
return null
}
function parseActions(raw: unknown): AutomationAction[] {
if (!Array.isArray(raw)) {
throw new ConvexError("Ações inválidas")
@ -69,7 +133,11 @@ function parseActions(raw: unknown): AutomationAction[] {
if (raw.length === 0) {
throw new ConvexError("Adicione pelo menos uma ação")
}
return raw as AutomationAction[]
const parsed = raw.map(parseAction).filter((item): item is AutomationAction => Boolean(item))
if (parsed.length === 0) {
throw new ConvexError("Ações inválidas")
}
return parsed
}
function mapAutomation(doc: Doc<"ticketAutomations">) {
@ -89,6 +157,30 @@ function mapAutomation(doc: Doc<"ticketAutomations">) {
}
}
function normalizeRunStatus(value: unknown): AutomationRunStatus {
const status = typeof value === "string" ? value.toUpperCase() : ""
if (status === "SUCCESS" || status === "SKIPPED" || status === "ERROR") return status
return "ERROR"
}
function normalizeAppliedActions(value: unknown): AppliedAction[] | null {
if (!Array.isArray(value)) return null
const parsed: AppliedAction[] = []
for (const entry of value) {
const record = parseRecord(entry)
if (!record) continue
const type = typeof record.type === "string" ? record.type : null
if (!type) continue
const detailsRaw = record.details
const details =
detailsRaw && typeof detailsRaw === "object" && !Array.isArray(detailsRaw)
? (detailsRaw as Record<string, unknown>)
: undefined
parsed.push({ type, details })
}
return parsed.length > 0 ? parsed : []
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
@ -101,6 +193,104 @@ export const list = query({
},
})
export const listRunsPaginated = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
automationId: v.optional(v.id("ticketAutomations")),
status: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
await requireInternal(ctx, args.viewerId, args.tenantId)
const statusFilter = args.status ? args.status.trim().toUpperCase() : null
const automationId = args.automationId ?? null
let baseQuery
if (automationId) {
const automation = await ctx.db.get(automationId)
if (!automation || automation.tenantId !== args.tenantId) {
throw new ConvexError("Automação não encontrada")
}
baseQuery = ctx.db
.query("ticketAutomationRuns")
.withIndex("by_automation_created", (q) => q.eq("automationId", automationId))
} else {
baseQuery = ctx.db
.query("ticketAutomationRuns")
.withIndex("by_tenant_created", (q) => q.eq("tenantId", args.tenantId))
}
const filteredQuery =
statusFilter && statusFilter.length > 0
? baseQuery.filter((q) => q.eq(q.field("status"), statusFilter))
: baseQuery
const paginationResult = await filteredQuery.order("desc").paginate(args.paginationOpts)
const ticketIds = new Set<string>()
const automationIds = new Set<string>()
for (const run of paginationResult.page) {
// Hardening: em caso de drift, garante que só expomos o tenant correto.
if (run.tenantId !== args.tenantId) continue
ticketIds.add(String(run.ticketId))
automationIds.add(String(run.automationId))
}
const ticketDocs = new Map<string, Doc<"tickets"> | null>()
const automationDocs = new Map<string, Doc<"ticketAutomations"> | null>()
await Promise.all(
Array.from(ticketIds).map(async (id) => {
const doc = (await ctx.db.get(id as Id<"tickets">)) as Doc<"tickets"> | null
ticketDocs.set(id, doc)
})
)
await Promise.all(
Array.from(automationIds).map(async (id) => {
const doc = (await ctx.db.get(id as Id<"ticketAutomations">)) as Doc<"ticketAutomations"> | null
automationDocs.set(id, doc)
})
)
const page = paginationResult.page
.filter((run) => run.tenantId === args.tenantId)
.map((run) => {
const ticket = ticketDocs.get(String(run.ticketId)) ?? null
const automation = automationDocs.get(String(run.automationId)) ?? null
return {
id: run._id,
createdAt: run.createdAt,
status: normalizeRunStatus(run.status),
matched: Boolean(run.matched),
eventType: run.eventType,
error: run.error ?? null,
actionsApplied: normalizeAppliedActions(run.actionsApplied ?? null),
ticket: ticket
? {
id: ticket._id,
reference: ticket.reference ?? 0,
subject: ticket.subject ?? "",
}
: null,
automation: automation
? {
id: automation._id,
name: automation.name,
}
: null,
}
})
return {
page,
isDone: paginationResult.isDone,
continueCursor: paginationResult.continueCursor,
}
},
})
export const getById = query({
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
handler: async (ctx, { tenantId, viewerId, automationId }) => {
@ -318,6 +508,7 @@ async function executeAutomationInternal(
matched: false,
createdAt: now,
})
await enforceAutomationRunsRetention(ctx, automation._id, now)
return
}
@ -338,6 +529,7 @@ async function executeAutomationInternal(
actionsApplied: applied,
createdAt: now,
})
await enforceAutomationRunsRetention(ctx, automation._id, now)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await ctx.db.insert("ticketAutomationRuns", {
@ -350,6 +542,44 @@ async function executeAutomationInternal(
error: message,
createdAt: now,
})
await enforceAutomationRunsRetention(ctx, automation._id, now)
}
}
const RUNS_RETENTION_MAX_PER_AUTOMATION = 2000
const RUNS_RETENTION_MAX_AGE_DAYS = 90
const RUNS_RETENTION_DELETE_BATCH = 250
async function enforceAutomationRunsRetention(ctx: MutationCtx, automationId: Id<"ticketAutomations">, now: number) {
const cutoff = now - RUNS_RETENTION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000
const runs = await ctx.db
.query("ticketAutomationRuns")
.withIndex("by_automation_created", (q) => q.eq("automationId", automationId))
.order("desc")
.take(RUNS_RETENTION_MAX_PER_AUTOMATION + RUNS_RETENTION_DELETE_BATCH)
if (runs.length <= RUNS_RETENTION_MAX_PER_AUTOMATION) {
// Mesmo com baixo volume, ainda remove registros antigos.
const expired = runs.filter((run) => (run.createdAt ?? 0) < cutoff).slice(0, RUNS_RETENTION_DELETE_BATCH)
for (const run of expired) {
await ctx.db.delete(run._id)
}
return
}
const candidates = runs
.slice(RUNS_RETENTION_MAX_PER_AUTOMATION)
.concat(runs.filter((run) => (run.createdAt ?? 0) < cutoff))
const unique = new Map<string, Id<"ticketAutomationRuns">>()
for (const run of candidates) {
unique.set(String(run._id), run._id)
if (unique.size >= RUNS_RETENTION_DELETE_BATCH) break
}
for (const id of unique.values()) {
await ctx.db.delete(id)
}
}