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 } 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 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 | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null return value as Record } 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) : 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() const automationIds = new Set() 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 | null>() const automationDocs = new Map | 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>() 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> = {} const applied: Array<{ type: string; details?: Record }> = [] 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 = { 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 = { "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() 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 }