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 alerts from "../alerts.js";
|
||||||
|
import type * as automations from "../automations.js";
|
||||||
import type * as bootstrap from "../bootstrap.js";
|
import type * as bootstrap from "../bootstrap.js";
|
||||||
import type * as categories from "../categories.js";
|
import type * as categories from "../categories.js";
|
||||||
import type * as categorySlas from "../categorySlas.js";
|
import type * as categorySlas from "../categorySlas.js";
|
||||||
|
|
@ -52,6 +53,7 @@ import type {
|
||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
alerts: typeof alerts;
|
alerts: typeof alerts;
|
||||||
|
automations: typeof automations;
|
||||||
bootstrap: typeof bootstrap;
|
bootstrap: typeof bootstrap;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
categorySlas: typeof categorySlas;
|
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({
|
export const summary = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
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 STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||||
const MANAGER_ROLE = "MANAGER"
|
const MANAGER_ROLE = "MANAGER"
|
||||||
|
const INTERNAL_ROLES = new Set(["ADMIN", "AGENT"])
|
||||||
|
|
||||||
type Ctx = QueryCtx | MutationCtx
|
type Ctx = QueryCtx | MutationCtx
|
||||||
|
|
||||||
|
|
@ -44,6 +45,14 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str
|
||||||
return result
|
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
|
// removed customer role; use requireCompanyManager or requireStaff as appropriate
|
||||||
|
|
||||||
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,41 @@ export default defineSchema({
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
}).index("by_ticket", ["ticketId"]),
|
}).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({
|
ticketChatMessages: defineTable({
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
authorId: v.id("users"),
|
authorId: v.id("users"),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
|
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
||||||
|
import { runTicketAutomationsForEvent } from "./automations";
|
||||||
import {
|
import {
|
||||||
OPTIONAL_ADMISSION_FIELD_KEYS,
|
OPTIONAL_ADMISSION_FIELD_KEYS,
|
||||||
TICKET_FORM_CONFIG,
|
TICKET_FORM_CONFIG,
|
||||||
|
|
@ -2412,6 +2413,12 @@ export const create = mutation({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await runTicketAutomationsForEvent(ctx, {
|
||||||
|
tenantId: args.tenantId,
|
||||||
|
ticketId: id,
|
||||||
|
eventType: "TICKET_CREATED",
|
||||||
|
})
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -2546,6 +2553,13 @@ export const addComment = mutation({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[tickets] Falha ao agendar e-mail de comentário", 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;
|
return id;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -2702,6 +2716,12 @@ export const updateStatus = mutation({
|
||||||
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
||||||
createdAt: now,
|
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 }
|
return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
85
src/app/api/machines/chat/messages/route.test.ts
Normal file
85
src/app/api/machines/chat/messages/route.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { describe, expect, it, beforeEach, vi } from "vitest"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
|
const mutationMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock("@/server/convex-client", () => ({
|
||||||
|
createConvexClient: () => ({
|
||||||
|
mutation: mutationMock,
|
||||||
|
query: vi.fn(),
|
||||||
|
}),
|
||||||
|
ConvexConfigurationError: class extends Error {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe("POST /api/machines/chat/messages", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mutationMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("aceita mensagem somente com anexo (body vazio) e encaminha ao Convex", async () => {
|
||||||
|
mutationMock.mockResolvedValue({ ok: true })
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: "send",
|
||||||
|
machineToken: "token-attach-only",
|
||||||
|
ticketId: "ticket_1",
|
||||||
|
attachments: [{ storageId: "storage_1", name: "arquivo.pdf", size: 123, type: "application/pdf" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { POST } = await import("./route")
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/machines/chat/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({ ok: true })
|
||||||
|
expect(mutationMock).toHaveBeenCalledWith(api.liveChat.postMachineMessage, {
|
||||||
|
machineToken: payload.machineToken,
|
||||||
|
ticketId: payload.ticketId,
|
||||||
|
body: "",
|
||||||
|
attachments: payload.attachments,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejeita mensagem vazia (sem body e sem anexos)", async () => {
|
||||||
|
const payload = { action: "send", machineToken: "token-empty", ticketId: "ticket_1" }
|
||||||
|
|
||||||
|
const { POST } = await import("./route")
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/machines/chat/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body).toHaveProperty("error", "Payload invalido")
|
||||||
|
expect(mutationMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejeita body somente com espaços quando não há anexos", async () => {
|
||||||
|
const payload = { action: "send", machineToken: "token-spaces", ticketId: "ticket_1", body: " " }
|
||||||
|
|
||||||
|
const { POST } = await import("./route")
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/machines/chat/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
const body = await response.json()
|
||||||
|
expect(body).toHaveProperty("error", "Payload invalido")
|
||||||
|
expect(mutationMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
26
src/app/automations/layout.tsx
Normal file
26
src/app/automations/layout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { ReactNode } from "react"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
import { isAgentOrAdmin, isPortalUser, isStaff } from "@/lib/authz"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
export default async function AutomationsLayout({ children }: { children: ReactNode }) {
|
||||||
|
const session = await requireAuthenticatedSession()
|
||||||
|
const role = session.user.role ?? "agent"
|
||||||
|
|
||||||
|
if (!isAgentOrAdmin(role)) {
|
||||||
|
if (isPortalUser(role)) {
|
||||||
|
redirect("/portal")
|
||||||
|
}
|
||||||
|
if (isStaff(role)) {
|
||||||
|
redirect("/dashboard")
|
||||||
|
}
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
21
src/app/automations/page.tsx
Normal file
21
src/app/automations/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { AutomationsManager } from "@/components/automations/automations-manager"
|
||||||
|
|
||||||
|
export default function AutomationsPage() {
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
title="Automações"
|
||||||
|
lead="Defina regras para executar ações automaticamente nos tickets."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 pb-12 lg:px-6">
|
||||||
|
<AutomationsManager />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
type NavRoleRequirement = "staff" | "admin"
|
type NavRoleRequirement = "staff" | "admin" | "agent"
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -85,6 +85,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" },
|
{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ title: "Automações", url: "/automations", icon: Waypoints, requiredRole: "agent" },
|
||||||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||||
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
||||||
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
||||||
|
|
@ -150,16 +151,17 @@ const navigation: NavigationGroup[] = [
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
const { session, isLoading, isAdmin, isStaff, role } = useAuth()
|
||||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||||
const canAccess = React.useCallback(
|
const canAccess = React.useCallback(
|
||||||
(requiredRole?: NavRoleRequirement) => {
|
(requiredRole?: NavRoleRequirement) => {
|
||||||
if (!requiredRole) return true
|
if (!requiredRole) return true
|
||||||
if (requiredRole === "admin") return isAdmin
|
if (requiredRole === "admin") return isAdmin
|
||||||
|
if (requiredRole === "agent") return isAdmin || role === "agent"
|
||||||
if (requiredRole === "staff") return isStaff
|
if (requiredRole === "staff") return isStaff
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
[isAdmin, isStaff]
|
[isAdmin, isStaff, role]
|
||||||
)
|
)
|
||||||
const initialExpanded = React.useMemo(() => {
|
const initialExpanded = React.useMemo(() => {
|
||||||
const open = new Set<string>()
|
const open = new Set<string>()
|
||||||
|
|
@ -377,4 +379,3 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
847
src/components/automations/automation-editor-dialog.tsx
Normal file
847
src/components/automations/automation-editor-dialog.tsx
Normal file
|
|
@ -0,0 +1,847 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
type AutomationRow = {
|
||||||
|
id: Id<"ticketAutomations">
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
trigger: string
|
||||||
|
timing: string
|
||||||
|
delayMs: number | null
|
||||||
|
conditions: unknown | null
|
||||||
|
actions: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionField =
|
||||||
|
| "companyId"
|
||||||
|
| "queueId"
|
||||||
|
| "categoryId"
|
||||||
|
| "subcategoryId"
|
||||||
|
| "priority"
|
||||||
|
| "status"
|
||||||
|
| "channel"
|
||||||
|
| "formTemplate"
|
||||||
|
| "chatEnabled"
|
||||||
|
|
||||||
|
type ConditionOp = "eq" | "neq" | "is_true" | "is_false"
|
||||||
|
|
||||||
|
type ConditionDraft = {
|
||||||
|
id: string
|
||||||
|
field: ConditionField
|
||||||
|
op: ConditionOp
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType =
|
||||||
|
| "SET_PRIORITY"
|
||||||
|
| "MOVE_QUEUE"
|
||||||
|
| "ASSIGN_TO"
|
||||||
|
| "SET_FORM_TEMPLATE"
|
||||||
|
| "SET_CHAT_ENABLED"
|
||||||
|
| "ADD_INTERNAL_COMMENT"
|
||||||
|
|
||||||
|
type ActionDraft =
|
||||||
|
| { id: string; type: "SET_PRIORITY"; priority: string }
|
||||||
|
| { id: string; type: "MOVE_QUEUE"; queueId: string }
|
||||||
|
| { id: string; type: "ASSIGN_TO"; assigneeId: string }
|
||||||
|
| { id: string; type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
|
||||||
|
| { id: string; type: "SET_CHAT_ENABLED"; enabled: boolean }
|
||||||
|
| { id: string; type: "ADD_INTERNAL_COMMENT"; body: string }
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: "LOW", label: "Baixa" },
|
||||||
|
{ value: "MEDIUM", label: "Média" },
|
||||||
|
{ value: "HIGH", label: "Alta" },
|
||||||
|
{ value: "URGENT", label: "Urgente" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ value: "PENDING", label: "Pendente" },
|
||||||
|
{ value: "AWAITING_ATTENDANCE", label: "Em andamento" },
|
||||||
|
{ value: "PAUSED", label: "Pausado" },
|
||||||
|
{ value: "RESOLVED", label: "Resolvido" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ value: "HELPDESK", label: "Helpdesk" },
|
||||||
|
{ value: "EMAIL", label: "E-mail" },
|
||||||
|
{ value: "PHONE", label: "Telefone" },
|
||||||
|
{ value: "WHATSAPP", label: "WhatsApp" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TRIGGERS = [
|
||||||
|
{ value: "TICKET_CREATED", label: "Abertura" },
|
||||||
|
{ value: "STATUS_CHANGED", label: "Alteração de status" },
|
||||||
|
{ value: "COMMENT_ADDED", label: "Inclusão de comentário" },
|
||||||
|
{ value: "TICKET_RESOLVED", label: "Finalização" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function msToMinutes(ms: number | null) {
|
||||||
|
if (!ms || ms <= 0) return 0
|
||||||
|
return Math.max(1, Math.round(ms / 60000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToMs(minutes: number) {
|
||||||
|
return Math.max(0, Math.round(minutes) * 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeString(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDraftConditions(raw: unknown | null): ConditionDraft[] {
|
||||||
|
const group = raw as { conditions?: unknown } | null
|
||||||
|
const list = Array.isArray(group?.conditions) ? group?.conditions : []
|
||||||
|
return list.map((c) => {
|
||||||
|
const condition = c as { field?: unknown; op?: unknown; value?: unknown }
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
field: (safeString(condition.field) as ConditionField) || "companyId",
|
||||||
|
op: (safeString(condition.op) as ConditionOp) || "eq",
|
||||||
|
value: safeString(condition.value),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDraftActions(raw: unknown[]): ActionDraft[] {
|
||||||
|
return raw.map((a) => {
|
||||||
|
const base = a as Record<string, unknown>
|
||||||
|
const type = safeString(base.type) as ActionType
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
if (type === "MOVE_QUEUE") return { id, type, queueId: safeString(base.queueId) }
|
||||||
|
if (type === "ASSIGN_TO") return { id, type, assigneeId: safeString(base.assigneeId) }
|
||||||
|
if (type === "SET_FORM_TEMPLATE") return { id, type, formTemplate: safeString(base.formTemplate) || null }
|
||||||
|
if (type === "SET_CHAT_ENABLED") return { id, type, enabled: Boolean(base.enabled) }
|
||||||
|
if (type === "ADD_INTERNAL_COMMENT") return { id, type, body: safeString(base.body) }
|
||||||
|
return { id, type: "SET_PRIORITY", priority: safeString(base.priority) || "MEDIUM" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationEditorDialog({
|
||||||
|
automation,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
automation: AutomationRow | null
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const createAutomation = useMutation(api.automations.create)
|
||||||
|
const updateAutomation = useMutation(api.automations.update)
|
||||||
|
|
||||||
|
const companies = useQuery(
|
||||||
|
api.companies.list,
|
||||||
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
const queues = useQuery(
|
||||||
|
api.queues.listForStaff,
|
||||||
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: Id<"queues">; name: string; slug: string }> | undefined
|
||||||
|
|
||||||
|
const categories = useQuery(
|
||||||
|
api.categories.list,
|
||||||
|
tenantId ? { tenantId } : "skip"
|
||||||
|
) as
|
||||||
|
| Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
secondary: Array<{ id: string; name: string; categoryId: string }>
|
||||||
|
}>
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
const agents = useQuery(
|
||||||
|
api.users.listAgents,
|
||||||
|
tenantId ? { tenantId } : "skip"
|
||||||
|
) as Array<{ _id: Id<"users">; name: string; email: string }> | undefined
|
||||||
|
|
||||||
|
const templates = useQuery(
|
||||||
|
api.ticketFormTemplates.listActive,
|
||||||
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: string; key: string; label: string }> | undefined
|
||||||
|
|
||||||
|
const initialState = useMemo(() => {
|
||||||
|
const rawOp = (automation?.conditions as { op?: unknown } | null)?.op
|
||||||
|
const conditionsOp = rawOp === "OR" ? ("OR" as const) : ("AND" as const)
|
||||||
|
return {
|
||||||
|
name: automation?.name ?? "",
|
||||||
|
enabled: automation?.enabled ?? true,
|
||||||
|
trigger: automation?.trigger ?? "TICKET_CREATED",
|
||||||
|
timing: automation?.timing ?? "IMMEDIATE",
|
||||||
|
delayMinutes: msToMinutes(automation?.delayMs ?? null),
|
||||||
|
conditionsOp,
|
||||||
|
conditions: automation ? toDraftConditions(automation.conditions) : ([] as ConditionDraft[]),
|
||||||
|
actions: automation
|
||||||
|
? toDraftActions(automation.actions)
|
||||||
|
: ([{ id: crypto.randomUUID(), type: "SET_PRIORITY", priority: "MEDIUM" }] as ActionDraft[]),
|
||||||
|
}
|
||||||
|
}, [automation])
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialState.name)
|
||||||
|
const [enabled, setEnabled] = useState(initialState.enabled)
|
||||||
|
const [trigger, setTrigger] = useState(initialState.trigger)
|
||||||
|
const [timing, setTiming] = useState(initialState.timing)
|
||||||
|
const [delayMinutes, setDelayMinutes] = useState(initialState.delayMinutes)
|
||||||
|
const [conditionsOp, setConditionsOp] = useState<"AND" | "OR">(initialState.conditionsOp)
|
||||||
|
const [conditions, setConditions] = useState<ConditionDraft[]>(initialState.conditions)
|
||||||
|
const [actions, setActions] = useState<ActionDraft[]>(initialState.actions)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(initialState.name)
|
||||||
|
setEnabled(initialState.enabled)
|
||||||
|
setTrigger(initialState.trigger)
|
||||||
|
setTiming(initialState.timing)
|
||||||
|
setDelayMinutes(initialState.delayMinutes)
|
||||||
|
setConditionsOp(initialState.conditionsOp)
|
||||||
|
setConditions(initialState.conditions)
|
||||||
|
setActions(initialState.actions)
|
||||||
|
setSaving(false)
|
||||||
|
}, [initialState])
|
||||||
|
|
||||||
|
const subcategoryOptions = useMemo(() => {
|
||||||
|
const list =
|
||||||
|
categories?.flatMap((cat) =>
|
||||||
|
cat.secondary.map((sub) => ({ id: sub.id, name: sub.name, categoryId: sub.categoryId }))
|
||||||
|
) ?? []
|
||||||
|
return list.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
|
const handleAddCondition = () => {
|
||||||
|
setConditions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), field: "companyId", op: "eq", value: "" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCondition = (id: string) => {
|
||||||
|
setConditions((prev) => prev.filter((c) => c.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddAction = () => {
|
||||||
|
setActions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), type: "SET_PRIORITY", priority: "MEDIUM" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAction = (id: string) => {
|
||||||
|
setActions((prev) => prev.filter((a) => a.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = () => {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) throw new Error("Informe um nome para a automação.")
|
||||||
|
if (actions.length === 0) throw new Error("Adicione pelo menos uma ação.")
|
||||||
|
|
||||||
|
const conditionsPayload =
|
||||||
|
conditions.length > 0
|
||||||
|
? {
|
||||||
|
op: conditionsOp,
|
||||||
|
conditions: conditions.map((c) => ({
|
||||||
|
field: c.field,
|
||||||
|
op: c.op,
|
||||||
|
value: c.op === "is_true" || c.op === "is_false" ? undefined : c.value,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const actionsPayload = actions.map((a) => {
|
||||||
|
if (a.type === "SET_PRIORITY") return { type: a.type, priority: a.priority }
|
||||||
|
if (a.type === "MOVE_QUEUE") return { type: a.type, queueId: a.queueId }
|
||||||
|
if (a.type === "ASSIGN_TO") return { type: a.type, assigneeId: a.assigneeId }
|
||||||
|
if (a.type === "SET_FORM_TEMPLATE") return { type: a.type, formTemplate: a.formTemplate }
|
||||||
|
if (a.type === "SET_CHAT_ENABLED") return { type: a.type, enabled: a.enabled }
|
||||||
|
return { type: a.type, body: a.body }
|
||||||
|
})
|
||||||
|
|
||||||
|
const delayMs = timing === "DELAYED" ? minutesToMs(delayMinutes) : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: trimmedName,
|
||||||
|
enabled: Boolean(enabled),
|
||||||
|
trigger,
|
||||||
|
timing,
|
||||||
|
delayMs,
|
||||||
|
conditions: conditionsPayload,
|
||||||
|
actions: actionsPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = buildPayload()
|
||||||
|
if (automation) {
|
||||||
|
await updateAutomation({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
automationId: automation.id,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
toast.success("Automação atualizada")
|
||||||
|
} else {
|
||||||
|
await createAutomation({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
toast.success("Automação criada")
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Falha ao salvar automação")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = Boolean(convexUserId) && name.trim().length > 0 && actions.length > 0 && !saving
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader className="gap-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<DialogTitle>{automation ? "Editar automação" : "Nova automação"}</DialogTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-neutral-600">Ativa</span>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="rounded-full">
|
||||||
|
{TRIGGERS.find((t) => t.value === trigger)?.label ?? "Quando"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-full">
|
||||||
|
{timing === "DELAYED" ? "Agendada" : "Imediata"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nome da automação</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ex.: Definir fila e responsável ao abrir ticket"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Quando</Label>
|
||||||
|
<Select value={trigger} onValueChange={setTrigger}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione o gatilho" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{TRIGGERS.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Execução</Label>
|
||||||
|
<Select value={timing} onValueChange={setTiming}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="IMMEDIATE">Imediato</SelectItem>
|
||||||
|
<SelectItem value="DELAYED">Agendado (delay)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Delay (minutos)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={delayMinutes}
|
||||||
|
onChange={(e) => setDelayMinutes(Number(e.target.value))}
|
||||||
|
disabled={timing !== "DELAYED"}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Quando agendada, a automação executa após o tempo informado.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">Condições</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Defina filtros (opcional) para decidir quando executar.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={conditionsOp} onValueChange={(v) => setConditionsOp(v as "AND" | "OR")}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="AND">E</SelectItem>
|
||||||
|
<SelectItem value="OR">OU</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddCondition} className="gap-2">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Nova condição
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conditions.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
|
||||||
|
Sem condições — executa sempre que o gatilho ocorrer.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{conditions.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="grid gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3 md:grid-cols-[1.1fr_0.9fr_1.4fr_auto]"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Campo</Label>
|
||||||
|
<Select
|
||||||
|
value={c.field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setConditions((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === c.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
field: value as ConditionField,
|
||||||
|
op: value === "chatEnabled" ? "is_true" : "eq",
|
||||||
|
value: "",
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="companyId">Empresa</SelectItem>
|
||||||
|
<SelectItem value="queueId">Fila</SelectItem>
|
||||||
|
<SelectItem value="categoryId">Categoria</SelectItem>
|
||||||
|
<SelectItem value="subcategoryId">Subcategoria</SelectItem>
|
||||||
|
<SelectItem value="priority">Prioridade</SelectItem>
|
||||||
|
<SelectItem value="status">Status</SelectItem>
|
||||||
|
<SelectItem value="channel">Canal</SelectItem>
|
||||||
|
<SelectItem value="formTemplate">Formulário</SelectItem>
|
||||||
|
<SelectItem value="chatEnabled">Chat habilitado</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Operador</Label>
|
||||||
|
<Select
|
||||||
|
value={c.op}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) =>
|
||||||
|
prev.map((item) => (item.id === c.id ? { ...item, op: value as ConditionOp } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{c.field === "chatEnabled" ? (
|
||||||
|
<>
|
||||||
|
<SelectItem value="is_true">é verdadeiro</SelectItem>
|
||||||
|
<SelectItem value="is_false">é falso</SelectItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SelectItem value="eq">igual</SelectItem>
|
||||||
|
<SelectItem value="neq">diferente</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Valor</Label>
|
||||||
|
{c.field === "chatEnabled" ? (
|
||||||
|
<Input value={c.op === "is_true" ? "Sim" : "Não"} disabled className="bg-white" />
|
||||||
|
) : c.field === "priority" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "status" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "channel" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{CHANNELS.map((ch) => (
|
||||||
|
<SelectItem key={ch.value} value={ch.value}>
|
||||||
|
{ch.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "companyId" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{(companies ?? []).map((company) => (
|
||||||
|
<SelectItem key={company.id} value={String(company.id)}>
|
||||||
|
{company.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "queueId" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{(queues ?? []).map((queue) => (
|
||||||
|
<SelectItem key={queue.id} value={String(queue.id)}>
|
||||||
|
{queue.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "categoryId" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{(categories ?? []).map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : c.field === "subcategoryId" ? (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{subcategoryOptions.map((sub) => (
|
||||||
|
<SelectItem key={sub.id} value={sub.id}>
|
||||||
|
{sub.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={c.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="">Nenhum</SelectItem>
|
||||||
|
{(templates ?? []).map((tpl) => (
|
||||||
|
<SelectItem key={tpl.key} value={tpl.key}>
|
||||||
|
{tpl.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveCondition(c.id)}
|
||||||
|
className="mt-6 h-8 w-8 text-slate-500 hover:bg-white"
|
||||||
|
title="Remover"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">Ações</p>
|
||||||
|
<p className="text-xs text-muted-foreground">O que deve acontecer quando a automação disparar.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddAction} className="gap-2">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Nova ação
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<div key={a.id} className="rounded-xl border border-slate-200 bg-white p-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1.1fr_1.7fr_auto]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Tipo</Label>
|
||||||
|
<Select
|
||||||
|
value={a.type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setActions((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.id !== a.id) return item
|
||||||
|
const next = value as ActionType
|
||||||
|
if (next === "MOVE_QUEUE") return { id: item.id, type: next, queueId: "" }
|
||||||
|
if (next === "ASSIGN_TO") return { id: item.id, type: next, assigneeId: "" }
|
||||||
|
if (next === "SET_FORM_TEMPLATE") return { id: item.id, type: next, formTemplate: null }
|
||||||
|
if (next === "SET_CHAT_ENABLED") return { id: item.id, type: next, enabled: true }
|
||||||
|
if (next === "ADD_INTERNAL_COMMENT") return { id: item.id, type: next, body: "" }
|
||||||
|
return { id: item.id, type: "SET_PRIORITY", priority: "MEDIUM" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="SET_PRIORITY">Alterar prioridade</SelectItem>
|
||||||
|
<SelectItem value="MOVE_QUEUE">Mover para fila</SelectItem>
|
||||||
|
<SelectItem value="ASSIGN_TO">Definir responsável</SelectItem>
|
||||||
|
<SelectItem value="SET_FORM_TEMPLATE">Aplicar formulário</SelectItem>
|
||||||
|
<SelectItem value="SET_CHAT_ENABLED">Habilitar/desabilitar chat</SelectItem>
|
||||||
|
<SelectItem value="ADD_INTERNAL_COMMENT">Adicionar comentário interno</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Configuração</Label>
|
||||||
|
{a.type === "SET_PRIORITY" ? (
|
||||||
|
<Select
|
||||||
|
value={a.priority}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, priority: value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : a.type === "MOVE_QUEUE" ? (
|
||||||
|
<Select
|
||||||
|
value={a.queueId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, queueId: value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{(queues ?? []).map((queue) => (
|
||||||
|
<SelectItem key={queue.id} value={String(queue.id)}>
|
||||||
|
{queue.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : a.type === "ASSIGN_TO" ? (
|
||||||
|
<Select
|
||||||
|
value={a.assigneeId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, assigneeId: value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
{(agents ?? []).map((u) => (
|
||||||
|
<SelectItem key={u._id} value={String(u._id)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : a.type === "SET_FORM_TEMPLATE" ? (
|
||||||
|
<Select
|
||||||
|
value={a.formTemplate ?? ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, formTemplate: value || null } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="">Nenhum</SelectItem>
|
||||||
|
{(templates ?? []).map((tpl) => (
|
||||||
|
<SelectItem key={tpl.key} value={tpl.key}>
|
||||||
|
{tpl.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : a.type === "SET_CHAT_ENABLED" ? (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2">
|
||||||
|
<span className="text-sm text-neutral-700">Chat habilitado</span>
|
||||||
|
<Switch
|
||||||
|
checked={a.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, enabled: checked } : item)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={a.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, body: e.target.value } : item)))
|
||||||
|
}
|
||||||
|
placeholder="Escreva o comentário interno..."
|
||||||
|
className="min-h-24"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveAction(a.id)}
|
||||||
|
className="mt-6 h-8 w-8 text-slate-500 hover:bg-slate-50"
|
||||||
|
title="Remover"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="button" onClick={handleSave} disabled={!canSave}>
|
||||||
|
{saving ? "Salvando..." : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
src/components/automations/automations-manager.tsx
Normal file
265
src/components/automations/automations-manager.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Dialog, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
|
||||||
|
import { AutomationEditorDialog } from "@/components/automations/automation-editor-dialog"
|
||||||
|
|
||||||
|
type AutomationRow = {
|
||||||
|
id: Id<"ticketAutomations">
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
trigger: string
|
||||||
|
timing: string
|
||||||
|
delayMs: number | null
|
||||||
|
conditions: unknown | null
|
||||||
|
actions: unknown[]
|
||||||
|
runCount: number
|
||||||
|
lastRunAt: number | null
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_LABELS: Record<string, string> = {
|
||||||
|
TICKET_CREATED: "Abertura",
|
||||||
|
STATUS_CHANGED: "Alteração de status",
|
||||||
|
COMMENT_ADDED: "Inclusão de comentário",
|
||||||
|
TICKET_RESOLVED: "Finalização",
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLabel(trigger: string) {
|
||||||
|
return TRIGGER_LABELS[trigger] ?? trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastRun(timestamp: number | null) {
|
||||||
|
if (!timestamp) return "—"
|
||||||
|
return new Date(timestamp).toLocaleString("pt-BR")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationsManager() {
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const [queryText, setQueryText] = useState("")
|
||||||
|
const [triggerFilter, setTriggerFilter] = useState<string>("all")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
|
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AutomationRow | null>(null)
|
||||||
|
|
||||||
|
const list = useQuery(
|
||||||
|
api.automations.list,
|
||||||
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
) as AutomationRow[] | undefined
|
||||||
|
|
||||||
|
const toggleEnabled = useMutation(api.automations.toggleEnabled)
|
||||||
|
const removeAutomation = useMutation(api.automations.remove)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const base = list ?? []
|
||||||
|
const q = queryText.trim().toLowerCase()
|
||||||
|
return base
|
||||||
|
.filter((item) => {
|
||||||
|
if (triggerFilter !== "all" && item.trigger !== triggerFilter) return false
|
||||||
|
if (statusFilter === "active" && !item.enabled) return false
|
||||||
|
if (statusFilter === "inactive" && item.enabled) return false
|
||||||
|
if (q && !item.name.toLowerCase().includes(q)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
}, [list, queryText, triggerFilter, statusFilter])
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: AutomationRow) => {
|
||||||
|
setEditing(row)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (row: AutomationRow, nextEnabled: boolean) => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
try {
|
||||||
|
await toggleEnabled({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
automationId: row.id,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
})
|
||||||
|
toast.success(nextEnabled ? "Automação ativada" : "Automação desativada")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Falha ao atualizar automação")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: AutomationRow) => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
const ok = confirm(`Excluir a automação "${row.name}"?`)
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await removeAutomation({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
automationId: row.id,
|
||||||
|
})
|
||||||
|
toast.success("Automação excluída")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Falha ao excluir automação")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Automações</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Crie gatilhos para executar ações automáticas (fila, prioridade, responsável, formulário, chat…).
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Input
|
||||||
|
value={queryText}
|
||||||
|
onChange={(e) => setQueryText(e.target.value)}
|
||||||
|
placeholder="Buscar automação..."
|
||||||
|
className="w-full sm:w-64"
|
||||||
|
/>
|
||||||
|
<Select value={triggerFilter} onValueChange={setTriggerFilter}>
|
||||||
|
<SelectTrigger className="w-full sm:w-44">
|
||||||
|
<SelectValue placeholder="Quando" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="TICKET_CREATED">Abertura</SelectItem>
|
||||||
|
<SelectItem value="STATUS_CHANGED">Alteração</SelectItem>
|
||||||
|
<SelectItem value="COMMENT_ADDED">Comentário</SelectItem>
|
||||||
|
<SelectItem value="TICKET_RESOLVED">Finalização</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="active">Ativas</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inativas</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={handleNew} className="gap-2">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Nova automação
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<AutomationEditorDialog
|
||||||
|
automation={editing}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!list ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Nenhuma automação cadastrada.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-separate border-spacing-y-2">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<th className="px-2 py-1">Nome</th>
|
||||||
|
<th className="px-2 py-1">Quando</th>
|
||||||
|
<th className="px-2 py-1">Ações</th>
|
||||||
|
<th className="px-2 py-1">Execuções</th>
|
||||||
|
<th className="px-2 py-1">Última</th>
|
||||||
|
<th className="px-2 py-1">Status</th>
|
||||||
|
<th className="px-2 py-1 text-right"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((row) => (
|
||||||
|
<tr key={row.id} className="rounded-xl border border-slate-200 bg-white">
|
||||||
|
<td className="px-2 py-2 text-sm font-medium text-neutral-900">{row.name}</td>
|
||||||
|
<td className="px-2 py-2 text-sm text-neutral-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="rounded-full">
|
||||||
|
{triggerLabel(row.trigger)}
|
||||||
|
</Badge>
|
||||||
|
{row.timing === "DELAYED" && row.delayMs ? (
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
+{Math.round(row.delayMs / 60000)}m
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-sm text-neutral-700">{row.actions?.length ?? 0}</td>
|
||||||
|
<td className="px-2 py-2 text-sm text-neutral-700">{row.runCount ?? 0}</td>
|
||||||
|
<td className="px-2 py-2 text-sm text-neutral-700">{formatLastRun(row.lastRunAt)}</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={row.enabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle(row, checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-neutral-600">{row.enabled ? "Ativa" : "Inativa"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(row)} className="gap-2">
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(row)}
|
||||||
|
className="gap-2 text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Excluir
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { isAgentOrAdmin } from "@/lib/authz"
|
||||||
|
|
||||||
// Verifica se a API do liveChat existe
|
// Verifica se a API do liveChat existe
|
||||||
function checkLiveChatApiExists() {
|
function checkLiveChatApiExists() {
|
||||||
|
|
@ -19,6 +21,11 @@ const ChatWidget = dynamic(
|
||||||
)
|
)
|
||||||
|
|
||||||
export function ChatWidgetProvider() {
|
export function ChatWidgetProvider() {
|
||||||
|
const { role, isLoading } = useAuth()
|
||||||
|
|
||||||
|
if (isLoading) return null
|
||||||
|
if (!isAgentOrAdmin(role)) return null
|
||||||
|
|
||||||
// Nao renderiza se a API nao existir (Convex nao sincronizado)
|
// Nao renderiza se a API nao existir (Convex nao sincronizado)
|
||||||
if (!checkLiveChatApiExists()) {
|
if (!checkLiveChatApiExists()) {
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ export function isAdmin(role?: string | null) {
|
||||||
return normalizeRole(role) === ADMIN_ROLE
|
return normalizeRole(role) === ADMIN_ROLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAgentOrAdmin(role?: string | null) {
|
||||||
|
const normalized = normalizeRole(role)
|
||||||
|
return normalized === "admin" || normalized === "agent"
|
||||||
|
}
|
||||||
|
|
||||||
export function isStaff(role?: string | null) {
|
export function isStaff(role?: string | null) {
|
||||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,6 @@ vi.mock("convex/browser", () => ({
|
||||||
ConvexHttpClient: vi.fn(() => ({ mutation: mutationMock })),
|
ConvexHttpClient: vi.fn(() => ({ mutation: mutationMock })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@/convex/_generated/api", () => ({
|
|
||||||
api: {
|
|
||||||
users: { ensureUser: "users:ensureUser" },
|
|
||||||
devices: {
|
|
||||||
heartbeat: "devices:heartbeat",
|
|
||||||
upsertInventory: "devices:upsertInventory",
|
|
||||||
findByAuthEmail: "devices:findByAuthEmail",
|
|
||||||
remove: "devices:remove",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("POST /api/admin/devices/rename", () => {
|
describe("POST /api/admin/devices/rename", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
|
|
@ -40,11 +28,12 @@ describe("POST /api/admin/devices/rename", () => {
|
||||||
},
|
},
|
||||||
session: { id: "sess", expiresAt: Date.now() + 1000 },
|
session: { id: "sess", expiresAt: Date.now() + 1000 },
|
||||||
})
|
})
|
||||||
mutationMock.mockImplementation((name: string) => {
|
mutationMock.mockImplementation((name: unknown) => {
|
||||||
if (name === "users:ensureUser") {
|
if (typeof name === "string") {
|
||||||
return Promise.resolve({ _id: "user-123" })
|
return Promise.resolve({ ok: true })
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: true })
|
|
||||||
|
return Promise.resolve({ _id: "user-123" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
202
tests/automations-engine.test.ts
Normal file
202
tests/automations-engine.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { describe, expect, it, vi } from "bun:test"
|
||||||
|
|
||||||
|
import { runTicketAutomationsForEvent } from "../convex/automations"
|
||||||
|
import type { AutomationConditionGroup, TicketForAutomation } from "../convex/automationsEngine"
|
||||||
|
import { evaluateAutomationConditions } from "../convex/automationsEngine"
|
||||||
|
|
||||||
|
function buildTicket(overrides: Partial<TicketForAutomation> = {}): TicketForAutomation {
|
||||||
|
const base: TicketForAutomation = {
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
channel: "EMAIL",
|
||||||
|
queueId: "queue-1" as never,
|
||||||
|
companyId: "company-1" as never,
|
||||||
|
categoryId: "cat-1" as never,
|
||||||
|
subcategoryId: "subcat-1" as never,
|
||||||
|
tags: ["vip", "windows"],
|
||||||
|
formTemplate: "DEFAULT",
|
||||||
|
chatEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("automationsEngine.evaluateAutomationConditions", () => {
|
||||||
|
it("retorna true quando não há condições", () => {
|
||||||
|
const ticket = buildTicket()
|
||||||
|
expect(evaluateAutomationConditions(ticket, null)).toBe(true)
|
||||||
|
expect(evaluateAutomationConditions(ticket, undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("aplica AND: todas devem bater", () => {
|
||||||
|
const ticket = buildTicket({ priority: "HIGH" })
|
||||||
|
const group: AutomationConditionGroup = {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [
|
||||||
|
{ field: "priority", op: "eq", value: "HIGH" },
|
||||||
|
{ field: "status", op: "eq", value: "AWAITING_ATTENDANCE" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(evaluateAutomationConditions(ticket, group)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("aplica OR: basta uma bater", () => {
|
||||||
|
const ticket = buildTicket({ priority: "LOW" })
|
||||||
|
const group: AutomationConditionGroup = {
|
||||||
|
op: "OR",
|
||||||
|
conditions: [
|
||||||
|
{ field: "priority", op: "eq", value: "HIGH" },
|
||||||
|
{ field: "priority", op: "eq", value: "LOW" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(evaluateAutomationConditions(ticket, group)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("suporta comparadores eq/neq/in/not_in para campos simples", () => {
|
||||||
|
const ticket = buildTicket()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "companyId", op: "eq", value: "company-1" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "queueId", op: "neq", value: "queue-2" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "priority", op: "in", value: ["LOW", "MEDIUM"] }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "status", op: "not_in", value: ["RESOLVED", "PAUSED"] }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("suporta chatEnabled (is_true/is_false/eq/neq)", () => {
|
||||||
|
const ticket = buildTicket({ chatEnabled: true })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "chatEnabled", op: "is_true" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "chatEnabled", op: "is_false" }],
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "chatEnabled", op: "eq", value: true }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "chatEnabled", op: "neq", value: false }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("suporta tag (contains/not_contains/eq/neq)", () => {
|
||||||
|
const ticket = buildTicket({ tags: ["vip", "linux"] })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "tag", op: "contains", value: "vip" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "tag", op: "not_contains", value: "windows" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "tag", op: "eq", value: "linux" }],
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
evaluateAutomationConditions(ticket, {
|
||||||
|
op: "AND",
|
||||||
|
conditions: [{ field: "tag", op: "neq", value: "vip" }],
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("automations.runTicketAutomationsForEvent", () => {
|
||||||
|
it("agenda automação DELAYED via scheduler.runAfter", async () => {
|
||||||
|
const runAfter = vi.fn(async () => {})
|
||||||
|
|
||||||
|
const ticketId = "ticket_1" as never
|
||||||
|
const automationId = "auto_1" as never
|
||||||
|
const ticket = buildTicket({ tenantId: "tenant-1" }) as unknown as { _id: unknown; tenantId: string }
|
||||||
|
const automations = [
|
||||||
|
{
|
||||||
|
_id: automationId,
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
name: "Auto",
|
||||||
|
enabled: true,
|
||||||
|
trigger: "TICKET_CREATED",
|
||||||
|
timing: "DELAYED",
|
||||||
|
delayMs: 60_000,
|
||||||
|
conditions: null,
|
||||||
|
actions: [{ type: "SET_CHAT_ENABLED", enabled: true }],
|
||||||
|
createdBy: "user_1",
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const take = vi.fn(async () => automations)
|
||||||
|
const filter = vi.fn(() => ({ take }))
|
||||||
|
const withIndex = vi.fn(() => ({ filter, take }))
|
||||||
|
const query = vi.fn(() => ({ withIndex }))
|
||||||
|
const get = vi.fn(async (id: unknown) => {
|
||||||
|
if (id === ticketId) return { ...ticket, _id: ticketId, tenantId: "tenant-1" }
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
await runTicketAutomationsForEvent(
|
||||||
|
{
|
||||||
|
db: { get, query } as unknown,
|
||||||
|
scheduler: { runAfter } as unknown,
|
||||||
|
} as never,
|
||||||
|
{ tenantId: "tenant-1", ticketId, eventType: "TICKET_CREATED" }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(runAfter).toHaveBeenCalledTimes(1)
|
||||||
|
expect(runAfter).toHaveBeenCalledWith(
|
||||||
|
60_000,
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ automationId, ticketId, eventType: "TICKET_CREATED" })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { JSDOM } from "jsdom"
|
||||||
process.env.BETTER_AUTH_SECRET ??= "test-secret"
|
process.env.BETTER_AUTH_SECRET ??= "test-secret"
|
||||||
process.env.NEXT_PUBLIC_APP_URL ??= "http://localhost:3000"
|
process.env.NEXT_PUBLIC_APP_URL ??= "http://localhost:3000"
|
||||||
process.env.BETTER_AUTH_URL ??= process.env.NEXT_PUBLIC_APP_URL
|
process.env.BETTER_AUTH_URL ??= process.env.NEXT_PUBLIC_APP_URL
|
||||||
|
process.env.NODE_ENV ??= "test"
|
||||||
|
|
||||||
const OriginalDate = Date
|
const OriginalDate = Date
|
||||||
let fixedTimestamp: number | null = null
|
let fixedTimestamp: number | null = null
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue