sistema-de-chamados/convex/automations.ts

516 lines
17 KiB
TypeScript

import { ConvexError, v } from "convex/values"
import { api } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx } from "./_generated/server"
import { mutation, query } from "./_generated/server"
import { requireInternal } from "./rbac"
import {
evaluateAutomationConditions,
type AutomationConditionGroup,
type AutomationTrigger,
type TicketForAutomation,
} from "./automationsEngine"
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
type AutomationAction =
| { type: "SET_PRIORITY"; priority: string }
| { type: "MOVE_QUEUE"; queueId: Id<"queues"> }
| { type: "ASSIGN_TO"; assigneeId: Id<"users"> }
| { type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
| { type: "ADD_INTERNAL_COMMENT"; body: string }
const TRIGGERS: AutomationTrigger[] = ["TICKET_CREATED", "STATUS_CHANGED", "COMMENT_ADDED", "TICKET_RESOLVED"]
const TIMINGS = ["IMMEDIATE", "DELAYED"] as const
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
function normalizeTrigger(input: string): AutomationTrigger {
const normalized = input.trim().toUpperCase()
const trigger = TRIGGERS.find((t) => t === normalized)
if (!trigger) {
throw new ConvexError("Gatilho inválido")
}
return trigger
}
function normalizeTiming(input: string): (typeof TIMINGS)[number] {
const normalized = input.trim().toUpperCase()
if (!TIMINGS.includes(normalized as (typeof TIMINGS)[number])) {
throw new ConvexError("Agendamento inválido")
}
return normalized as (typeof TIMINGS)[number]
}
function parseConditions(raw: unknown): AutomationConditionGroup | null {
if (!raw) return null
if (typeof raw !== "object" || Array.isArray(raw)) {
throw new ConvexError("Condições inválidas")
}
const group = raw as Partial<AutomationConditionGroup>
if (!group.op || (group.op !== "AND" && group.op !== "OR")) {
throw new ConvexError("Operador de condição inválido")
}
if (!Array.isArray(group.conditions)) {
throw new ConvexError("Condições inválidas")
}
return {
op: group.op,
conditions: group.conditions as AutomationConditionGroup["conditions"],
}
}
function parseActions(raw: unknown): AutomationAction[] {
if (!Array.isArray(raw)) {
throw new ConvexError("Ações inválidas")
}
if (raw.length === 0) {
throw new ConvexError("Adicione pelo menos uma ação")
}
return raw as AutomationAction[]
}
function mapAutomation(doc: Doc<"ticketAutomations">) {
return {
id: doc._id,
name: doc.name,
enabled: doc.enabled,
trigger: doc.trigger,
timing: doc.timing,
delayMs: doc.delayMs ?? null,
conditions: (doc.conditions ?? null) as AutomationConditionGroup | null,
actions: doc.actions as AutomationAction[],
runCount: doc.runCount ?? 0,
lastRunAt: doc.lastRunAt ?? null,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireInternal(ctx, viewerId, tenantId)
const docs = await ctx.db
.query("ticketAutomations")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(200)
return docs.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).map(mapAutomation)
},
})
export const getById = query({
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
handler: async (ctx, { tenantId, viewerId, automationId }) => {
await requireInternal(ctx, viewerId, tenantId)
const doc = await ctx.db.get(automationId)
if (!doc || doc.tenantId !== tenantId) return null
return mapAutomation(doc)
},
})
export const create = mutation({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
name: v.string(),
enabled: v.boolean(),
trigger: v.string(),
timing: v.string(),
delayMs: v.optional(v.number()),
conditions: v.optional(v.any()),
actions: v.any(),
},
handler: async (ctx, args) => {
await requireInternal(ctx, args.viewerId, args.tenantId)
const name = args.name.trim()
if (!name) throw new ConvexError("Nome inválido")
const trigger = normalizeTrigger(args.trigger)
const timing = normalizeTiming(args.timing)
const delayMs = timing === "DELAYED" ? Math.max(0, args.delayMs ?? 0) : 0
if (timing === "DELAYED" && delayMs < 1_000) {
throw new ConvexError("Agendamento precisa ser de pelo menos 1 segundo")
}
const conditions = parseConditions(args.conditions)
const actions = parseActions(args.actions)
const now = Date.now()
const id = await ctx.db.insert("ticketAutomations", {
tenantId: args.tenantId,
name,
enabled: Boolean(args.enabled),
trigger,
timing,
delayMs: timing === "DELAYED" ? delayMs : undefined,
conditions: conditions ?? undefined,
actions,
createdBy: args.viewerId,
updatedBy: undefined,
createdAt: now,
updatedAt: now,
runCount: 0,
lastRunAt: undefined,
})
return { id }
},
})
export const update = mutation({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
automationId: v.id("ticketAutomations"),
name: v.string(),
enabled: v.boolean(),
trigger: v.string(),
timing: v.string(),
delayMs: v.optional(v.number()),
conditions: v.optional(v.any()),
actions: v.any(),
},
handler: async (ctx, args) => {
await requireInternal(ctx, args.viewerId, args.tenantId)
const existing = await ctx.db.get(args.automationId)
if (!existing || existing.tenantId !== args.tenantId) {
throw new ConvexError("Automação não encontrada")
}
const name = args.name.trim()
if (!name) throw new ConvexError("Nome inválido")
const trigger = normalizeTrigger(args.trigger)
const timing = normalizeTiming(args.timing)
const delayMs = timing === "DELAYED" ? Math.max(0, args.delayMs ?? 0) : 0
if (timing === "DELAYED" && delayMs < 1_000) {
throw new ConvexError("Agendamento precisa ser de pelo menos 1 segundo")
}
const conditions = parseConditions(args.conditions)
const actions = parseActions(args.actions)
const now = Date.now()
await ctx.db.patch(args.automationId, {
name,
enabled: Boolean(args.enabled),
trigger,
timing,
delayMs: timing === "DELAYED" ? delayMs : undefined,
conditions: conditions ?? undefined,
actions,
updatedBy: args.viewerId,
updatedAt: now,
})
return { ok: true }
},
})
export const remove = mutation({
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
handler: async (ctx, { tenantId, viewerId, automationId }) => {
await requireInternal(ctx, viewerId, tenantId)
const existing = await ctx.db.get(automationId)
if (!existing || existing.tenantId !== tenantId) {
throw new ConvexError("Automação não encontrada")
}
await ctx.db.delete(automationId)
return { ok: true }
},
})
export const toggleEnabled = mutation({
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations"), enabled: v.boolean() },
handler: async (ctx, { tenantId, viewerId, automationId, enabled }) => {
await requireInternal(ctx, viewerId, tenantId)
const existing = await ctx.db.get(automationId)
if (!existing || existing.tenantId !== tenantId) {
throw new ConvexError("Automação não encontrada")
}
await ctx.db.patch(automationId, { enabled: Boolean(enabled), updatedBy: viewerId, updatedAt: Date.now() })
return { ok: true }
},
})
export async function runTicketAutomationsForEvent(
ctx: MutationCtx,
params: { tenantId: string; ticketId: Id<"tickets">; eventType: AutomationTrigger }
) {
try {
const { tenantId, ticketId, eventType } = params
const ticket = await ctx.db.get(ticketId)
if (!ticket || ticket.tenantId !== tenantId) return
const automations = await ctx.db
.query("ticketAutomations")
.withIndex("by_tenant_trigger", (q) => q.eq("tenantId", tenantId).eq("trigger", eventType))
.filter((q) => q.eq(q.field("enabled"), true))
.take(200)
if (automations.length === 0) return
for (const automation of automations) {
const conditions = (automation.conditions ?? null) as AutomationConditionGroup | null
const matches = evaluateAutomationConditions(ticket as unknown as TicketForAutomation, conditions)
if (!matches) continue
const timing = (automation.timing ?? "IMMEDIATE").toUpperCase()
const delayMs = timing === "DELAYED" ? Math.max(0, automation.delayMs ?? 0) : 0
const schedulerRunAfter = ctx.scheduler?.runAfter
if (timing === "DELAYED" && delayMs > 0 && typeof schedulerRunAfter === "function") {
await schedulerRunAfter(delayMs, api.automations.execute, {
automationId: automation._id,
ticketId,
eventType,
})
continue
}
await executeAutomationInternal(ctx, automation, ticket, eventType)
}
} catch (error) {
if (process.env.NODE_ENV !== "test") {
console.error("[automations] Falha ao processar automações", error)
}
}
}
export const execute = mutation({
args: {
automationId: v.id("ticketAutomations"),
ticketId: v.id("tickets"),
eventType: v.string(),
},
handler: async (ctx, { automationId, ticketId, eventType }) => {
const automation = await ctx.db.get(automationId)
if (!automation || automation.enabled !== true) return { ok: true, skipped: true }
const ticket = await ctx.db.get(ticketId)
if (!ticket || ticket.tenantId !== automation.tenantId) return { ok: true, skipped: true }
await executeAutomationInternal(ctx, automation, ticket, eventType)
return { ok: true }
},
})
async function executeAutomationInternal(
ctx: MutationCtx,
automation: Doc<"ticketAutomations">,
ticket: Doc<"tickets">,
eventType: string
) {
const now = Date.now()
const conditions = (automation.conditions ?? null) as AutomationConditionGroup | null
const matched = evaluateAutomationConditions(ticket as unknown as TicketForAutomation, conditions)
if (!matched) {
await ctx.db.insert("ticketAutomationRuns", {
tenantId: automation.tenantId,
automationId: automation._id,
ticketId: ticket._id,
eventType,
status: "SKIPPED",
matched: false,
createdAt: now,
})
return
}
try {
const applied = await applyActions(ctx, automation, ticket, now)
await ctx.db.patch(automation._id, {
runCount: (automation.runCount ?? 0) + 1,
lastRunAt: now,
updatedAt: now,
})
await ctx.db.insert("ticketAutomationRuns", {
tenantId: automation.tenantId,
automationId: automation._id,
ticketId: ticket._id,
eventType,
status: "SUCCESS",
matched: true,
actionsApplied: applied,
createdAt: now,
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await ctx.db.insert("ticketAutomationRuns", {
tenantId: automation.tenantId,
automationId: automation._id,
ticketId: ticket._id,
eventType,
status: "ERROR",
matched: true,
error: message,
createdAt: now,
})
}
}
async function applyActions(
ctx: MutationCtx,
automation: Doc<"ticketAutomations">,
ticket: Doc<"tickets">,
now: number
) {
const actions = parseActions(automation.actions)
const patch: Partial<Doc<"tickets">> = {}
const applied: Array<{ type: string; details?: Record<string, unknown> }> = []
for (const action of actions) {
if (action.type === "SET_PRIORITY") {
const next = action.priority.trim().toUpperCase()
if (!next) continue
if (ticket.priority !== next) {
patch.priority = next
applied.push({ type: action.type, details: { priority: next } })
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" }
await ctx.db.insert("ticketEvents", {
ticketId: ticket._id,
type: "PRIORITY_CHANGED",
payload: { to: next, toLabel: pt[next] ?? next },
createdAt: now,
})
}
continue
}
if (action.type === "MOVE_QUEUE") {
const queue = (await ctx.db.get(action.queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticket.tenantId) {
throw new ConvexError("Fila inválida na automação")
}
if (ticket.queueId !== action.queueId) {
patch.queueId = action.queueId
const queueName = queue.name ?? ""
const normalizedQueueLabel = queueName.toLowerCase()
const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword))
if (!isVisitQueueTarget) {
patch.dueAt = ticket.slaSolutionDueAt ?? undefined
}
applied.push({ type: action.type, details: { queueId: String(action.queueId), queueName } })
await ctx.db.insert("ticketEvents", {
ticketId: ticket._id,
type: "QUEUE_CHANGED",
payload: { queueId: action.queueId, queueName },
createdAt: now,
})
}
continue
}
if (action.type === "ASSIGN_TO") {
const assignee = (await ctx.db.get(action.assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticket.tenantId) {
throw new ConvexError("Responsável inválido na automação")
}
if (ticket.assigneeId !== action.assigneeId) {
patch.assigneeId = action.assigneeId
patch.assigneeSnapshot = {
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl ?? undefined,
teams: assignee.teams ?? undefined,
}
applied.push({ type: action.type, details: { assigneeId: String(action.assigneeId), assigneeName: assignee.name } })
const previousAssigneeName =
((ticket.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído"
await ctx.db.insert("ticketEvents", {
ticketId: ticket._id,
type: "ASSIGNEE_CHANGED",
payload: {
assigneeId: action.assigneeId,
assigneeName: assignee.name,
previousAssigneeId: ticket.assigneeId ?? null,
previousAssigneeName,
viaAutomation: true,
automationId: automation._id,
automationName: automation.name,
},
createdAt: now,
})
}
continue
}
if (action.type === "SET_FORM_TEMPLATE") {
const normalizedKey = normalizeFormTemplateKey(action.formTemplate ?? null)
let label: string | null = null
if (normalizedKey) {
const templateDoc = await getTemplateByKey(ctx, ticket.tenantId, normalizedKey)
if (templateDoc && templateDoc.isArchived !== true) {
label = templateDoc.label
} else {
const fallback = TICKET_FORM_CONFIG.find((tpl) => tpl.key === normalizedKey) ?? null
if (fallback) label = fallback.label
}
}
if ((ticket.formTemplate ?? null) !== normalizedKey || (ticket.formTemplateLabel ?? null) !== label) {
patch.formTemplate = normalizedKey ?? undefined
patch.formTemplateLabel = label ?? undefined
applied.push({ type: action.type, details: { formTemplate: normalizedKey, formTemplateLabel: label } })
}
continue
}
if (action.type === "SET_CHAT_ENABLED") {
const next = Boolean(action.enabled)
if (Boolean(ticket.chatEnabled) !== next) {
patch.chatEnabled = next
applied.push({ type: action.type, details: { enabled: next } })
}
continue
}
if (action.type === "ADD_INTERNAL_COMMENT") {
const body = action.body.replace(/\r\n/g, "\n").trim()
if (!body) continue
if (body.length > 4000) {
throw new ConvexError("Comentário interno muito longo (máx. 4000 caracteres)")
}
const author = (await ctx.db.get(automation.createdBy)) as Doc<"users"> | null
if (!author || author.tenantId !== ticket.tenantId) {
throw new ConvexError("Autor da automação inválido")
}
const authorSnapshot = {
name: author.name,
email: author.email,
avatarUrl: author.avatarUrl ?? undefined,
teams: author.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId: ticket._id,
authorId: automation.createdBy,
visibility: "INTERNAL",
body,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId: ticket._id,
type: "COMMENT_ADDED",
payload: { authorId: automation.createdBy, authorName: author.name, viaAutomation: true, automationId: automation._id },
createdAt: now,
})
applied.push({ type: action.type })
continue
}
}
if (Object.keys(patch).length > 0) {
patch.updatedAt = now
await ctx.db.patch(ticket._id, patch)
}
return applied
}