970 lines
33 KiB
TypeScript
970 lines
33 KiB
TypeScript
import { ConvexError, v } from "convex/values"
|
|
import { paginationOptsValidator } from "convex/server"
|
|
|
|
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"
|
|
import { buildBaseUrl, renderAutomationEmail, type EmailTicketSummary } from "./emailTemplates"
|
|
|
|
type AutomationEmailTarget = "AUTO" | "PORTAL" | "STAFF"
|
|
|
|
type AutomationEmailRecipient =
|
|
| { type: "REQUESTER" }
|
|
| { type: "ASSIGNEE" }
|
|
| { type: "USER"; userId: Id<"users"> }
|
|
| { type: "EMAIL"; email: string }
|
|
|
|
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 }
|
|
| {
|
|
type: "SEND_EMAIL"
|
|
recipients: AutomationEmailRecipient[]
|
|
subject: string
|
|
message: string
|
|
ctaTarget?: AutomationEmailTarget
|
|
ctaLabel?: string
|
|
}
|
|
|
|
type AutomationRunStatus = "SUCCESS" | "SKIPPED" | "ERROR"
|
|
|
|
type AppliedAction = { type: string; details?: Record<string, unknown> }
|
|
|
|
const TRIGGERS: AutomationTrigger[] = [
|
|
"TICKET_CREATED",
|
|
"STATUS_CHANGED",
|
|
"PRIORITY_CHANGED",
|
|
"QUEUE_CHANGED",
|
|
"COMMENT_ADDED",
|
|
"TICKET_RESOLVED",
|
|
]
|
|
const TIMINGS = ["IMMEDIATE", "DELAYED"] as const
|
|
|
|
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
|
|
|
|
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 parseRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
function parseAction(value: unknown): AutomationAction | null {
|
|
const record = parseRecord(value)
|
|
if (!record) return null
|
|
const type = typeof record.type === "string" ? record.type.trim().toUpperCase() : ""
|
|
|
|
if (type === "SET_PRIORITY") {
|
|
const priority = typeof record.priority === "string" ? record.priority.trim().toUpperCase() : ""
|
|
if (!priority) return null
|
|
return { type: "SET_PRIORITY", priority }
|
|
}
|
|
|
|
if (type === "MOVE_QUEUE") {
|
|
const queueId = typeof record.queueId === "string" ? record.queueId : ""
|
|
if (!queueId) return null
|
|
return { type: "MOVE_QUEUE", queueId: queueId as Id<"queues"> }
|
|
}
|
|
|
|
if (type === "ASSIGN_TO") {
|
|
const assigneeId = typeof record.assigneeId === "string" ? record.assigneeId : ""
|
|
if (!assigneeId) return null
|
|
return { type: "ASSIGN_TO", assigneeId: assigneeId as Id<"users"> }
|
|
}
|
|
|
|
if (type === "SET_FORM_TEMPLATE") {
|
|
const formTemplateRaw = record.formTemplate
|
|
if (formTemplateRaw === null || formTemplateRaw === undefined) {
|
|
return { type: "SET_FORM_TEMPLATE", formTemplate: null }
|
|
}
|
|
if (typeof formTemplateRaw !== "string") return null
|
|
const formTemplate = formTemplateRaw.trim()
|
|
return { type: "SET_FORM_TEMPLATE", formTemplate: formTemplate.length > 0 ? formTemplate : null }
|
|
}
|
|
|
|
if (type === "SET_CHAT_ENABLED") {
|
|
if (typeof record.enabled !== "boolean") return null
|
|
return { type: "SET_CHAT_ENABLED", enabled: record.enabled }
|
|
}
|
|
|
|
if (type === "ADD_INTERNAL_COMMENT") {
|
|
const body = typeof record.body === "string" ? record.body : ""
|
|
if (!body.trim()) return null
|
|
return { type: "ADD_INTERNAL_COMMENT", body }
|
|
}
|
|
|
|
if (type === "SEND_EMAIL") {
|
|
const subject = typeof record.subject === "string" ? record.subject.trim() : ""
|
|
const message = typeof record.message === "string" ? record.message : ""
|
|
const recipientsRaw = record.recipients
|
|
|
|
if (!subject) return null
|
|
if (!message.trim()) return null
|
|
if (!Array.isArray(recipientsRaw) || recipientsRaw.length === 0) return null
|
|
|
|
const recipients: AutomationEmailRecipient[] = []
|
|
for (const entry of recipientsRaw) {
|
|
const rec = parseRecord(entry)
|
|
if (!rec) continue
|
|
const recType = typeof rec.type === "string" ? rec.type.trim().toUpperCase() : ""
|
|
if (recType === "REQUESTER") {
|
|
recipients.push({ type: "REQUESTER" })
|
|
continue
|
|
}
|
|
if (recType === "ASSIGNEE") {
|
|
recipients.push({ type: "ASSIGNEE" })
|
|
continue
|
|
}
|
|
if (recType === "USER") {
|
|
const userId = typeof rec.userId === "string" ? rec.userId : ""
|
|
if (!userId) continue
|
|
recipients.push({ type: "USER", userId: userId as Id<"users"> })
|
|
continue
|
|
}
|
|
if (recType === "EMAIL") {
|
|
const email = typeof rec.email === "string" ? rec.email.trim() : ""
|
|
if (!email) continue
|
|
recipients.push({ type: "EMAIL", email })
|
|
}
|
|
}
|
|
|
|
if (recipients.length === 0) return null
|
|
|
|
const ctaTargetRaw = typeof record.ctaTarget === "string" ? record.ctaTarget.trim().toUpperCase() : ""
|
|
const ctaTarget: AutomationEmailTarget =
|
|
ctaTargetRaw === "PORTAL" || ctaTargetRaw === "STAFF" ? (ctaTargetRaw as AutomationEmailTarget) : "AUTO"
|
|
|
|
const ctaLabel = typeof record.ctaLabel === "string" ? record.ctaLabel.trim() : ""
|
|
|
|
return {
|
|
type: "SEND_EMAIL",
|
|
recipients,
|
|
subject,
|
|
message,
|
|
ctaTarget,
|
|
ctaLabel: ctaLabel.length > 0 ? ctaLabel : undefined,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
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")
|
|
}
|
|
const parsed = raw.map(parseAction).filter((item): item is AutomationAction => Boolean(item))
|
|
if (parsed.length === 0) {
|
|
throw new ConvexError("Ações inválidas")
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function mapAutomation(doc: Doc<"ticketAutomations">) {
|
|
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,
|
|
}
|
|
}
|
|
|
|
function normalizeRunStatus(value: unknown): AutomationRunStatus {
|
|
const status = typeof value === "string" ? value.toUpperCase() : ""
|
|
if (status === "SUCCESS" || status === "SKIPPED" || status === "ERROR") return status
|
|
return "ERROR"
|
|
}
|
|
|
|
function normalizeAppliedActions(value: unknown): AppliedAction[] | null {
|
|
if (!Array.isArray(value)) return null
|
|
const parsed: AppliedAction[] = []
|
|
for (const entry of value) {
|
|
const record = parseRecord(entry)
|
|
if (!record) continue
|
|
const type = typeof record.type === "string" ? record.type : null
|
|
if (!type) continue
|
|
const detailsRaw = record.details
|
|
const details =
|
|
detailsRaw && typeof detailsRaw === "object" && !Array.isArray(detailsRaw)
|
|
? (detailsRaw as Record<string, unknown>)
|
|
: undefined
|
|
parsed.push({ type, details })
|
|
}
|
|
return parsed.length > 0 ? parsed : []
|
|
}
|
|
|
|
export const list = query({
|
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
|
handler: async (ctx, { tenantId, viewerId }) => {
|
|
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 listRunsPaginated = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
automationId: v.optional(v.id("ticketAutomations")),
|
|
status: v.optional(v.string()),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireInternal(ctx, args.viewerId, args.tenantId)
|
|
|
|
const statusFilter = args.status ? args.status.trim().toUpperCase() : null
|
|
const automationId = args.automationId ?? null
|
|
|
|
let baseQuery
|
|
if (automationId) {
|
|
const automation = await ctx.db.get(automationId)
|
|
if (!automation || automation.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Automação não encontrada")
|
|
}
|
|
baseQuery = ctx.db
|
|
.query("ticketAutomationRuns")
|
|
.withIndex("by_automation_created", (q) => q.eq("automationId", automationId))
|
|
} else {
|
|
baseQuery = ctx.db
|
|
.query("ticketAutomationRuns")
|
|
.withIndex("by_tenant_created", (q) => q.eq("tenantId", args.tenantId))
|
|
}
|
|
|
|
const filteredQuery =
|
|
statusFilter && statusFilter.length > 0
|
|
? baseQuery.filter((q) => q.eq(q.field("status"), statusFilter))
|
|
: baseQuery
|
|
|
|
const paginationResult = await filteredQuery.order("desc").paginate(args.paginationOpts)
|
|
|
|
const ticketIds = new Set<string>()
|
|
const automationIds = new Set<string>()
|
|
for (const run of paginationResult.page) {
|
|
// Hardening: em caso de drift, garante que só expomos o tenant correto.
|
|
if (run.tenantId !== args.tenantId) continue
|
|
ticketIds.add(String(run.ticketId))
|
|
automationIds.add(String(run.automationId))
|
|
}
|
|
|
|
const ticketDocs = new Map<string, Doc<"tickets"> | null>()
|
|
const automationDocs = new Map<string, Doc<"ticketAutomations"> | null>()
|
|
|
|
await Promise.all(
|
|
Array.from(ticketIds).map(async (id) => {
|
|
const doc = (await ctx.db.get(id as Id<"tickets">)) as Doc<"tickets"> | null
|
|
ticketDocs.set(id, doc)
|
|
})
|
|
)
|
|
await Promise.all(
|
|
Array.from(automationIds).map(async (id) => {
|
|
const doc = (await ctx.db.get(id as Id<"ticketAutomations">)) as Doc<"ticketAutomations"> | null
|
|
automationDocs.set(id, doc)
|
|
})
|
|
)
|
|
|
|
const page = paginationResult.page
|
|
.filter((run) => run.tenantId === args.tenantId)
|
|
.map((run) => {
|
|
const ticket = ticketDocs.get(String(run.ticketId)) ?? null
|
|
const automation = automationDocs.get(String(run.automationId)) ?? null
|
|
return {
|
|
id: run._id,
|
|
createdAt: run.createdAt,
|
|
status: normalizeRunStatus(run.status),
|
|
matched: Boolean(run.matched),
|
|
eventType: run.eventType,
|
|
error: run.error ?? null,
|
|
actionsApplied: normalizeAppliedActions(run.actionsApplied ?? null),
|
|
ticket: ticket
|
|
? {
|
|
id: ticket._id,
|
|
reference: ticket.reference ?? 0,
|
|
subject: ticket.subject ?? "",
|
|
}
|
|
: null,
|
|
automation: automation
|
|
? {
|
|
id: automation._id,
|
|
name: automation.name,
|
|
}
|
|
: null,
|
|
}
|
|
})
|
|
|
|
return {
|
|
page,
|
|
isDone: paginationResult.isDone,
|
|
continueCursor: paginationResult.continueCursor,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const getById = query({
|
|
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
|
|
handler: async (ctx, { tenantId, viewerId, automationId }) => {
|
|
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,
|
|
})
|
|
await enforceAutomationRunsRetention(ctx, automation._id, 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,
|
|
})
|
|
await enforceAutomationRunsRetention(ctx, automation._id, 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,
|
|
})
|
|
await enforceAutomationRunsRetention(ctx, automation._id, now)
|
|
}
|
|
}
|
|
|
|
const RUNS_RETENTION_MAX_PER_AUTOMATION = 2000
|
|
const RUNS_RETENTION_MAX_AGE_DAYS = 90
|
|
const RUNS_RETENTION_DELETE_BATCH = 250
|
|
|
|
async function enforceAutomationRunsRetention(ctx: MutationCtx, automationId: Id<"ticketAutomations">, now: number) {
|
|
const cutoff = now - RUNS_RETENTION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000
|
|
|
|
const runs = await ctx.db
|
|
.query("ticketAutomationRuns")
|
|
.withIndex("by_automation_created", (q) => q.eq("automationId", automationId))
|
|
.order("desc")
|
|
.take(RUNS_RETENTION_MAX_PER_AUTOMATION + RUNS_RETENTION_DELETE_BATCH)
|
|
|
|
if (runs.length <= RUNS_RETENTION_MAX_PER_AUTOMATION) {
|
|
// Mesmo com baixo volume, ainda remove registros antigos.
|
|
const expired = runs.filter((run) => (run.createdAt ?? 0) < cutoff).slice(0, RUNS_RETENTION_DELETE_BATCH)
|
|
for (const run of expired) {
|
|
await ctx.db.delete(run._id)
|
|
}
|
|
return
|
|
}
|
|
|
|
const candidates = runs
|
|
.slice(RUNS_RETENTION_MAX_PER_AUTOMATION)
|
|
.concat(runs.filter((run) => (run.createdAt ?? 0) < cutoff))
|
|
|
|
const unique = new Map<string, Id<"ticketAutomationRuns">>()
|
|
for (const run of candidates) {
|
|
unique.set(String(run._id), run._id)
|
|
if (unique.size >= RUNS_RETENTION_DELETE_BATCH) break
|
|
}
|
|
|
|
for (const id of unique.values()) {
|
|
await ctx.db.delete(id)
|
|
}
|
|
}
|
|
|
|
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> }> = []
|
|
const pendingEmails: Array<{
|
|
recipients: AutomationEmailRecipient[]
|
|
subject: string
|
|
message: string
|
|
ctaTarget: AutomationEmailTarget
|
|
ctaLabel: string
|
|
}> = []
|
|
|
|
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 (action.type === "SEND_EMAIL") {
|
|
const subject = action.subject.trim()
|
|
const message = action.message.replace(/\r\n/g, "\n").trim()
|
|
if (!subject || !message) continue
|
|
|
|
const ctaTarget = action.ctaTarget ?? "AUTO"
|
|
const ctaLabel = (action.ctaLabel ?? "Abrir chamado").trim() || "Abrir chamado"
|
|
|
|
pendingEmails.push({ recipients: action.recipients, subject, message, ctaTarget, ctaLabel })
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (Object.keys(patch).length > 0) {
|
|
patch.updatedAt = now
|
|
await ctx.db.patch(ticket._id, patch)
|
|
}
|
|
|
|
if (pendingEmails.length > 0) {
|
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
|
if (typeof schedulerRunAfter !== "function") {
|
|
throw new ConvexError("Scheduler indisponível para envio de e-mail")
|
|
}
|
|
|
|
const nextTicket = { ...ticket, ...patch } as Doc<"tickets">
|
|
|
|
const baseUrl = buildBaseUrl()
|
|
const portalUrl = `${baseUrl}/portal/tickets/${nextTicket._id}`
|
|
const staffUrl = `${baseUrl}/tickets/${nextTicket._id}`
|
|
|
|
const tokens: Record<string, string> = {
|
|
"automation.name": automation.name,
|
|
"ticket.id": String(nextTicket._id),
|
|
"ticket.reference": String(nextTicket.reference ?? ""),
|
|
"ticket.subject": nextTicket.subject ?? "",
|
|
"ticket.status": nextTicket.status ?? "",
|
|
"ticket.priority": nextTicket.priority ?? "",
|
|
"ticket.url.portal": portalUrl,
|
|
"ticket.url.staff": staffUrl,
|
|
"company.name": (nextTicket.companySnapshot as { name?: string } | undefined)?.name ?? "",
|
|
"requester.name": (nextTicket.requesterSnapshot as { name?: string } | undefined)?.name ?? "",
|
|
"assignee.name": ((nextTicket.assigneeSnapshot as { name?: string } | undefined)?.name as string | undefined) ?? "",
|
|
}
|
|
|
|
const interpolate = (input: string) =>
|
|
input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => tokens[key] ?? "")
|
|
|
|
const normalizeEmail = (email: string) => email.trim().toLowerCase()
|
|
const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
|
|
for (const emailAction of pendingEmails) {
|
|
const subject = interpolate(emailAction.subject).trim()
|
|
const message = interpolate(emailAction.message).trim()
|
|
const ctaLabel = interpolate(emailAction.ctaLabel).trim() || "Abrir chamado"
|
|
|
|
const effectiveTarget: AutomationEmailTarget =
|
|
emailAction.ctaTarget === "AUTO"
|
|
? emailAction.recipients.some((r) => r.type === "ASSIGNEE" || r.type === "USER")
|
|
? "STAFF"
|
|
: "PORTAL"
|
|
: emailAction.ctaTarget
|
|
|
|
const ctaUrl = effectiveTarget === "PORTAL" ? portalUrl : staffUrl
|
|
|
|
const recipientEmails = new Set<string>()
|
|
|
|
for (const recipient of emailAction.recipients) {
|
|
if (recipient.type === "REQUESTER") {
|
|
const snapshotEmail =
|
|
((nextTicket.requesterSnapshot as { email?: string } | undefined)?.email as string | undefined) ?? null
|
|
const email = normalizeEmail(snapshotEmail ?? "")
|
|
if (email && isValidEmail(email)) {
|
|
recipientEmails.add(email)
|
|
continue
|
|
}
|
|
const requester = (await ctx.db.get(nextTicket.requesterId)) as Doc<"users"> | null
|
|
if (requester && requester.tenantId === nextTicket.tenantId) {
|
|
const fallback = normalizeEmail(requester.email ?? "")
|
|
if (fallback && isValidEmail(fallback)) recipientEmails.add(fallback)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (recipient.type === "ASSIGNEE") {
|
|
if (!nextTicket.assigneeId) continue
|
|
const snapshotEmail =
|
|
((nextTicket.assigneeSnapshot as { email?: string } | undefined)?.email as string | undefined) ?? null
|
|
const email = normalizeEmail(snapshotEmail ?? "")
|
|
if (email && isValidEmail(email)) {
|
|
recipientEmails.add(email)
|
|
continue
|
|
}
|
|
const assignee = (await ctx.db.get(nextTicket.assigneeId)) as Doc<"users"> | null
|
|
if (assignee && assignee.tenantId === nextTicket.tenantId) {
|
|
const fallback = normalizeEmail(assignee.email ?? "")
|
|
if (fallback && isValidEmail(fallback)) recipientEmails.add(fallback)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (recipient.type === "USER") {
|
|
const user = (await ctx.db.get(recipient.userId)) as Doc<"users"> | null
|
|
if (user && user.tenantId === nextTicket.tenantId) {
|
|
const email = normalizeEmail(user.email ?? "")
|
|
if (email && isValidEmail(email)) recipientEmails.add(email)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (recipient.type === "EMAIL") {
|
|
const email = normalizeEmail(recipient.email)
|
|
if (email && isValidEmail(email)) recipientEmails.add(email)
|
|
continue
|
|
}
|
|
}
|
|
|
|
const to = Array.from(recipientEmails).slice(0, 50)
|
|
if (to.length === 0) continue
|
|
|
|
const ticketSummary: EmailTicketSummary = {
|
|
reference: nextTicket.reference ?? 0,
|
|
subject: nextTicket.subject ?? "",
|
|
status: nextTicket.status ?? null,
|
|
priority: nextTicket.priority ?? null,
|
|
companyName: (nextTicket.companySnapshot as { name?: string } | undefined)?.name ?? null,
|
|
requesterName: (nextTicket.requesterSnapshot as { name?: string } | undefined)?.name ?? null,
|
|
assigneeName: ((nextTicket.assigneeSnapshot as { name?: string } | undefined)?.name as string | undefined) ?? null,
|
|
}
|
|
|
|
const html = renderAutomationEmail({
|
|
title: subject,
|
|
message,
|
|
ticket: ticketSummary,
|
|
ctaLabel,
|
|
ctaUrl,
|
|
})
|
|
|
|
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
|
|
to,
|
|
subject,
|
|
html,
|
|
})
|
|
|
|
applied.push({
|
|
type: "SEND_EMAIL",
|
|
details: {
|
|
toCount: to.length,
|
|
ctaTarget: effectiveTarget,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return applied
|
|
}
|