feat: automações de tickets e testes de regressão
This commit is contained in:
parent
9f1a6a7401
commit
8ab510bfe9
18 changed files with 2221 additions and 20 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import type * as alerts from "../alerts.js";
|
||||
import type * as automations from "../automations.js";
|
||||
import type * as bootstrap from "../bootstrap.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as categorySlas from "../categorySlas.js";
|
||||
|
|
@ -52,6 +53,7 @@ import type {
|
|||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
alerts: typeof alerts;
|
||||
automations: typeof automations;
|
||||
bootstrap: typeof bootstrap;
|
||||
categories: typeof categories;
|
||||
categorySlas: typeof categorySlas;
|
||||
|
|
|
|||
516
convex/automations.ts
Normal file
516
convex/automations.ts
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
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
|
||||
}
|
||||
133
convex/automationsEngine.ts
Normal file
133
convex/automationsEngine.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { Doc } from "./_generated/dataModel"
|
||||
|
||||
export type AutomationTrigger = "TICKET_CREATED" | "STATUS_CHANGED" | "COMMENT_ADDED" | "TICKET_RESOLVED"
|
||||
|
||||
export type AutomationConditionOperator = "AND" | "OR"
|
||||
|
||||
export type AutomationConditionField =
|
||||
| "companyId"
|
||||
| "queueId"
|
||||
| "categoryId"
|
||||
| "subcategoryId"
|
||||
| "priority"
|
||||
| "status"
|
||||
| "channel"
|
||||
| "formTemplate"
|
||||
| "chatEnabled"
|
||||
| "tag"
|
||||
|
||||
export type AutomationConditionComparator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "contains"
|
||||
| "not_contains"
|
||||
| "is_true"
|
||||
| "is_false"
|
||||
|
||||
export type AutomationCondition = {
|
||||
field: AutomationConditionField
|
||||
op: AutomationConditionComparator
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type AutomationConditionGroup = {
|
||||
op: AutomationConditionOperator
|
||||
conditions: AutomationCondition[]
|
||||
}
|
||||
|
||||
export type TicketForAutomation = Pick<
|
||||
Doc<"tickets">,
|
||||
| "tenantId"
|
||||
| "status"
|
||||
| "priority"
|
||||
| "channel"
|
||||
| "queueId"
|
||||
| "companyId"
|
||||
| "categoryId"
|
||||
| "subcategoryId"
|
||||
| "tags"
|
||||
| "formTemplate"
|
||||
| "chatEnabled"
|
||||
>
|
||||
|
||||
function normalizeId(value: unknown): string | null {
|
||||
if (!value) return null
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map((item) => normalizeString(item)).filter((item): item is string => Boolean(item))
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value
|
||||
return null
|
||||
}
|
||||
|
||||
function compareValue(actual: string | null, op: AutomationConditionComparator, expected: unknown): boolean {
|
||||
if (op === "eq") return actual === normalizeId(expected)
|
||||
if (op === "neq") return actual !== normalizeId(expected)
|
||||
if (op === "in") {
|
||||
const list = normalizeStringArray(expected).map((v) => v)
|
||||
return actual !== null && list.includes(actual)
|
||||
}
|
||||
if (op === "not_in") {
|
||||
const list = normalizeStringArray(expected).map((v) => v)
|
||||
return actual === null || !list.includes(actual)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function evaluateAutomationConditions(
|
||||
ticket: TicketForAutomation,
|
||||
group: AutomationConditionGroup | null | undefined
|
||||
): boolean {
|
||||
if (!group || !Array.isArray(group.conditions) || group.conditions.length === 0) return true
|
||||
const op = group.op === "OR" ? "OR" : "AND"
|
||||
|
||||
const results = group.conditions.map((condition) => {
|
||||
const field = condition.field
|
||||
const operator = condition.op
|
||||
|
||||
if (field === "companyId") return compareValue(normalizeId(ticket.companyId), operator, condition.value)
|
||||
if (field === "queueId") return compareValue(normalizeId(ticket.queueId), operator, condition.value)
|
||||
if (field === "categoryId") return compareValue(normalizeId(ticket.categoryId), operator, condition.value)
|
||||
if (field === "subcategoryId") return compareValue(normalizeId(ticket.subcategoryId), operator, condition.value)
|
||||
if (field === "priority") return compareValue(normalizeString(ticket.priority), operator, condition.value)
|
||||
if (field === "status") return compareValue(normalizeString(ticket.status), operator, condition.value)
|
||||
if (field === "channel") return compareValue(normalizeString(ticket.channel), operator, condition.value)
|
||||
if (field === "formTemplate") return compareValue(normalizeString(ticket.formTemplate), operator, condition.value)
|
||||
|
||||
if (field === "chatEnabled") {
|
||||
const expectedBool = normalizeBoolean(condition.value)
|
||||
if (operator === "is_true") return ticket.chatEnabled === true
|
||||
if (operator === "is_false") return ticket.chatEnabled !== true
|
||||
if (operator === "eq") return expectedBool !== null ? Boolean(ticket.chatEnabled) === expectedBool : false
|
||||
if (operator === "neq") return expectedBool !== null ? Boolean(ticket.chatEnabled) !== expectedBool : false
|
||||
return false
|
||||
}
|
||||
|
||||
if (field === "tag") {
|
||||
const tag = normalizeString(condition.value)
|
||||
if (!tag) return false
|
||||
const has = (ticket.tags ?? []).includes(tag)
|
||||
if (operator === "contains" || operator === "eq") return has
|
||||
if (operator === "not_contains" || operator === "neq") return !has
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return op === "OR" ? results.some(Boolean) : results.every(Boolean)
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +105,37 @@ export const list = query({
|
|||
},
|
||||
});
|
||||
|
||||
export const listForStaff = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
await requireStaff(ctx, viewerId, tenantId)
|
||||
const queues = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.take(50)
|
||||
|
||||
const teams = await ctx.db
|
||||
.query("teams")
|
||||
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
||||
.take(50)
|
||||
|
||||
return queues.map((queue) => {
|
||||
const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null
|
||||
return {
|
||||
id: queue._id,
|
||||
name: queue.name,
|
||||
slug: queue.slug,
|
||||
team: team
|
||||
? {
|
||||
id: team._id,
|
||||
name: team.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const summary = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
|
|||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
const MANAGER_ROLE = "MANAGER"
|
||||
const INTERNAL_ROLES = new Set(["ADMIN", "AGENT"])
|
||||
|
||||
type Ctx = QueryCtx | MutationCtx
|
||||
|
||||
|
|
@ -44,6 +45,14 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str
|
|||
return result
|
||||
}
|
||||
|
||||
export async function requireInternal(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (!result.role || !INTERNAL_ROLES.has(result.role)) {
|
||||
throw new ConvexError("Acesso restrito a administradores e agentes")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// removed customer role; use requireCompanyManager or requireStaff as appropriate
|
||||
|
||||
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
|
|
|
|||
|
|
@ -371,6 +371,41 @@ export default defineSchema({
|
|||
createdAt: v.number(),
|
||||
}).index("by_ticket", ["ticketId"]),
|
||||
|
||||
ticketAutomations: defineTable({
|
||||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
enabled: v.boolean(),
|
||||
trigger: v.string(),
|
||||
timing: v.string(), // IMMEDIATE | DELAYED
|
||||
delayMs: v.optional(v.number()),
|
||||
conditions: v.optional(v.any()),
|
||||
actions: v.any(),
|
||||
createdBy: v.id("users"),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
runCount: v.optional(v.number()),
|
||||
lastRunAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_enabled", ["tenantId", "enabled"])
|
||||
.index("by_tenant_trigger", ["tenantId", "trigger"]),
|
||||
|
||||
ticketAutomationRuns: defineTable({
|
||||
tenantId: v.string(),
|
||||
automationId: v.id("ticketAutomations"),
|
||||
ticketId: v.id("tickets"),
|
||||
eventType: v.string(),
|
||||
status: v.string(), // SUCCESS | SKIPPED | ERROR
|
||||
matched: v.boolean(),
|
||||
error: v.optional(v.string()),
|
||||
actionsApplied: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"])
|
||||
.index("by_automation_created", ["automationId", "createdAt"])
|
||||
.index("by_ticket", ["ticketId"]),
|
||||
|
||||
ticketChatMessages: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
authorId: v.id("users"),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ConvexError, v } from "convex/values";
|
|||
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
|
||||
|
||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
||||
import { runTicketAutomationsForEvent } from "./automations";
|
||||
import {
|
||||
OPTIONAL_ADMISSION_FIELD_KEYS,
|
||||
TICKET_FORM_CONFIG,
|
||||
|
|
@ -2412,6 +2413,12 @@ export const create = mutation({
|
|||
})
|
||||
}
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: args.tenantId,
|
||||
ticketId: id,
|
||||
eventType: "TICKET_CREATED",
|
||||
})
|
||||
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
|
@ -2546,6 +2553,13 @@ export const addComment = mutation({
|
|||
} catch (e) {
|
||||
console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
|
||||
}
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: ticketDoc.tenantId,
|
||||
ticketId: args.ticketId,
|
||||
eventType: "COMMENT_ADDED",
|
||||
})
|
||||
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
|
@ -2702,6 +2716,12 @@ export const updateStatus = mutation({
|
|||
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: ticketDoc.tenantId,
|
||||
ticketId,
|
||||
eventType: "STATUS_CHANGED",
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -2843,6 +2863,12 @@ export async function resolveTicketHandler(
|
|||
})
|
||||
}
|
||||
|
||||
await runTicketAutomationsForEvent(ctx, {
|
||||
tenantId: ticketDoc.tenantId,
|
||||
ticketId,
|
||||
eventType: "TICKET_RESOLVED",
|
||||
})
|
||||
|
||||
return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue