fix: automações (gatilhos, histórico) e chat desktop
This commit is contained in:
parent
8ab510bfe9
commit
e4d0c95791
7 changed files with 670 additions and 53 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import type { Doc } from "./_generated/dataModel"
|
||||
|
||||
export type AutomationTrigger = "TICKET_CREATED" | "STATUS_CHANGED" | "COMMENT_ADDED" | "TICKET_RESOLVED"
|
||||
export type AutomationTrigger =
|
||||
| "TICKET_CREATED"
|
||||
| "STATUS_CHANGED"
|
||||
| "PRIORITY_CHANGED"
|
||||
| "QUEUE_CHANGED"
|
||||
| "COMMENT_ADDED"
|
||||
| "TICKET_RESOLVED"
|
||||
|
||||
export type AutomationConditionOperator = "AND" | "OR"
|
||||
|
||||
|
|
@ -130,4 +136,3 @@ export function evaluateAutomationConditions(
|
|||
|
||||
return op === "OR" ? results.some(Boolean) : results.every(Boolean)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3930,6 +3930,12 @@ export const changeQueue = mutation({
|
|||
payload: { queueId, queueName, actorId },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: ticketDoc.tenantId,
|
||||
ticketId,
|
||||
eventType: "QUEUE_CHANGED",
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -4081,6 +4087,12 @@ export const updatePriority = mutation({
|
|||
payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId,
|
||||
eventType: "PRIORITY_CHANGED",
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue