From 8ab510bfe9cfe23bec190d3204d9b4d72a3aa9cd Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 13 Dec 2025 10:30:29 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20automa=C3=A7=C3=B5es=20de=20tickets=20e?= =?UTF-8?q?=20testes=20de=20regress=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/_generated/api.d.ts | 2 + convex/automations.ts | 516 +++++++++++ convex/automationsEngine.ts | 133 +++ convex/queues.ts | 31 + convex/rbac.ts | 9 + convex/schema.ts | 35 + convex/tickets.ts | 26 + .../api/machines/chat/messages/route.test.ts | 85 ++ src/app/automations/layout.tsx | 26 + src/app/automations/page.tsx | 21 + src/components/app-sidebar.tsx | 9 +- .../automations/automation-editor-dialog.tsx | 847 ++++++++++++++++++ .../automations/automations-manager.tsx | 265 ++++++ src/components/chat/chat-widget-provider.tsx | 7 + src/lib/authz.ts | 5 + tests/api-admin-devices-rename.test.ts | 21 +- tests/automations-engine.test.ts | 202 +++++ tests/setup/bun-test-env.ts | 1 + 18 files changed, 2221 insertions(+), 20 deletions(-) create mode 100644 convex/automations.ts create mode 100644 convex/automationsEngine.ts create mode 100644 src/app/api/machines/chat/messages/route.test.ts create mode 100644 src/app/automations/layout.tsx create mode 100644 src/app/automations/page.tsx create mode 100644 src/components/automations/automation-editor-dialog.tsx create mode 100644 src/components/automations/automations-manager.tsx create mode 100644 tests/automations-engine.test.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 8e9d2d6..a812892 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as alerts from "../alerts.js"; +import type * as automations from "../automations.js"; import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; import type * as categorySlas from "../categorySlas.js"; @@ -52,6 +53,7 @@ import type { declare const fullApi: ApiFromModules<{ alerts: typeof alerts; + automations: typeof automations; bootstrap: typeof bootstrap; categories: typeof categories; categorySlas: typeof categorySlas; diff --git a/convex/automations.ts b/convex/automations.ts new file mode 100644 index 0000000..e85de17 --- /dev/null +++ b/convex/automations.ts @@ -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 + 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> = {} + const applied: Array<{ type: string; details?: Record }> = [] + + 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 = { 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 +} diff --git a/convex/automationsEngine.ts b/convex/automationsEngine.ts new file mode 100644 index 0000000..9b1d6cf --- /dev/null +++ b/convex/automationsEngine.ts @@ -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) +} + diff --git a/convex/queues.ts b/convex/queues.ts index 3b6ce8d..2f70228 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -105,6 +105,37 @@ export const list = query({ }, }); +export const listForStaff = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId) + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .take(50) + + const teams = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .take(50) + + return queues.map((queue) => { + const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null + return { + id: queue._id, + name: queue.name, + slug: queue.slug, + team: team + ? { + id: team._id, + name: team.name, + } + : null, + } + }) + }, +}) + export const summary = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { diff --git a/convex/rbac.ts b/convex/rbac.ts index f224e51..2928cf8 100644 --- a/convex/rbac.ts +++ b/convex/rbac.ts @@ -5,6 +5,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server" const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]) const MANAGER_ROLE = "MANAGER" +const INTERNAL_ROLES = new Set(["ADMIN", "AGENT"]) type Ctx = QueryCtx | MutationCtx @@ -44,6 +45,14 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str return result } +export async function requireInternal(ctx: Ctx, userId: Id<"users">, tenantId?: string) { + const result = await requireUser(ctx, userId, tenantId) + if (!result.role || !INTERNAL_ROLES.has(result.role)) { + throw new ConvexError("Acesso restrito a administradores e agentes") + } + return result +} + // removed customer role; use requireCompanyManager or requireStaff as appropriate export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) { diff --git a/convex/schema.ts b/convex/schema.ts index f4c7698..35fe78e 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -371,6 +371,41 @@ export default defineSchema({ createdAt: v.number(), }).index("by_ticket", ["ticketId"]), + ticketAutomations: defineTable({ + tenantId: v.string(), + name: v.string(), + enabled: v.boolean(), + trigger: v.string(), + timing: v.string(), // IMMEDIATE | DELAYED + delayMs: v.optional(v.number()), + conditions: v.optional(v.any()), + actions: v.any(), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + runCount: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_enabled", ["tenantId", "enabled"]) + .index("by_tenant_trigger", ["tenantId", "trigger"]), + + ticketAutomationRuns: defineTable({ + tenantId: v.string(), + automationId: v.id("ticketAutomations"), + ticketId: v.id("tickets"), + eventType: v.string(), + status: v.string(), // SUCCESS | SKIPPED | ERROR + matched: v.boolean(), + error: v.optional(v.string()), + actionsApplied: v.optional(v.any()), + createdAt: v.number(), + }) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_automation_created", ["automationId", "createdAt"]) + .index("by_ticket", ["ticketId"]), + ticketChatMessages: defineTable({ ticketId: v.id("tickets"), authorId: v.id("users"), diff --git a/convex/tickets.ts b/convex/tickets.ts index 9041558..af52c82 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -7,6 +7,7 @@ import { ConvexError, v } from "convex/values"; import { Id, type Doc, type TableNames } from "./_generated/dataModel"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; +import { runTicketAutomationsForEvent } from "./automations"; import { OPTIONAL_ADMISSION_FIELD_KEYS, TICKET_FORM_CONFIG, @@ -2412,6 +2413,12 @@ export const create = mutation({ }) } + await runTicketAutomationsForEvent(ctx, { + tenantId: args.tenantId, + ticketId: id, + eventType: "TICKET_CREATED", + }) + return id; }, }); @@ -2546,6 +2553,13 @@ export const addComment = mutation({ } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de comentário", e) } + + await runTicketAutomationsForEvent(ctx, { + tenantId: ticketDoc.tenantId, + ticketId: args.ticketId, + eventType: "COMMENT_ADDED", + }) + return id; }, }); @@ -2702,6 +2716,12 @@ export const updateStatus = mutation({ payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }); + + await runTicketAutomationsForEvent(ctx, { + tenantId: ticketDoc.tenantId, + ticketId, + eventType: "STATUS_CHANGED", + }) }, }); @@ -2843,6 +2863,12 @@ export async function resolveTicketHandler( }) } + await runTicketAutomationsForEvent(ctx, { + tenantId: ticketDoc.tenantId, + ticketId, + eventType: "TICKET_RESOLVED", + }) + return { ok: true, reopenDeadline, reopenWindowDays: reopenDays } } diff --git a/src/app/api/machines/chat/messages/route.test.ts b/src/app/api/machines/chat/messages/route.test.ts new file mode 100644 index 0000000..456270d --- /dev/null +++ b/src/app/api/machines/chat/messages/route.test.ts @@ -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() + }) +}) + diff --git a/src/app/automations/layout.tsx b/src/app/automations/layout.tsx new file mode 100644 index 0000000..c0fe8c1 --- /dev/null +++ b/src/app/automations/layout.tsx @@ -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} +} + diff --git a/src/app/automations/page.tsx b/src/app/automations/page.tsx new file mode 100644 index 0000000..500f6cb --- /dev/null +++ b/src/app/automations/page.tsx @@ -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 ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 9e1617c..2159727 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -52,7 +52,7 @@ import { cn } from "@/lib/utils" import type { LucideIcon } from "lucide-react" -type NavRoleRequirement = "staff" | "admin" +type NavRoleRequirement = "staff" | "admin" | "agent" type NavigationItem = { title: string @@ -85,6 +85,7 @@ const navigation: NavigationGroup[] = [ { 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: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" }, { title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" }, @@ -150,16 +151,17 @@ const navigation: NavigationGroup[] = [ export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() - const { session, isLoading, isAdmin, isStaff } = useAuth() + const { session, isLoading, isAdmin, isStaff, role } = useAuth() const [isHydrated, setIsHydrated] = React.useState(false) const canAccess = React.useCallback( (requiredRole?: NavRoleRequirement) => { if (!requiredRole) return true if (requiredRole === "admin") return isAdmin + if (requiredRole === "agent") return isAdmin || role === "agent" if (requiredRole === "staff") return isStaff return false }, - [isAdmin, isStaff] + [isAdmin, isStaff, role] ) const initialExpanded = React.useMemo(() => { const open = new Set() @@ -377,4 +379,3 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ) } - diff --git a/src/components/automations/automation-editor-dialog.tsx b/src/components/automations/automation-editor-dialog.tsx new file mode 100644 index 0000000..9d18ca4 --- /dev/null +++ b/src/components/automations/automation-editor-dialog.tsx @@ -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 + 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(initialState.conditions) + const [actions, setActions] = useState(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 ( + + +
+ {automation ? "Editar automação" : "Nova automação"} +
+ Ativa + +
+
+
+ + {TRIGGERS.find((t) => t.value === trigger)?.label ?? "Quando"} + + + {timing === "DELAYED" ? "Agendada" : "Imediata"} + +
+
+ +
+
+
+ + setName(e.target.value)} + placeholder="Ex.: Definir fila e responsável ao abrir ticket" + /> +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + setDelayMinutes(Number(e.target.value))} + disabled={timing !== "DELAYED"} + /> +

+ Quando agendada, a automação executa após o tempo informado. +

+
+
+ + + +
+
+
+

Condições

+

Defina filtros (opcional) para decidir quando executar.

+
+
+ + +
+
+ + {conditions.length === 0 ? ( +

+ Sem condições — executa sempre que o gatilho ocorrer. +

+ ) : ( +
+ {conditions.map((c) => ( +
+
+ + +
+ +
+ + +
+ +
+ + {c.field === "chatEnabled" ? ( + + ) : c.field === "priority" ? ( + + ) : c.field === "status" ? ( + + ) : c.field === "channel" ? ( + + ) : c.field === "companyId" ? ( + + ) : c.field === "queueId" ? ( + + ) : c.field === "categoryId" ? ( + + ) : c.field === "subcategoryId" ? ( + + ) : ( + + )} +
+ + +
+ ))} +
+ )} +
+ + + +
+
+
+

Ações

+

O que deve acontecer quando a automação disparar.

+
+ +
+ +
+ {actions.map((a) => ( +
+
+
+ + +
+ +
+ + {a.type === "SET_PRIORITY" ? ( + + ) : a.type === "MOVE_QUEUE" ? ( + + ) : a.type === "ASSIGN_TO" ? ( + + ) : a.type === "SET_FORM_TEMPLATE" ? ( + + ) : a.type === "SET_CHAT_ENABLED" ? ( +
+ Chat habilitado + + setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, enabled: checked } : item))) + } + /> +
+ ) : ( +