feat: automações com envio de e-mail

This commit is contained in:
esdrasrenan 2025-12-13 12:38:08 -03:00
parent 469608a10b
commit 58a1ed6b36
6 changed files with 958 additions and 49 deletions

View file

@ -14,6 +14,15 @@ import {
} 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 }
@ -22,6 +31,14 @@ type AutomationAction =
| { 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"
@ -123,6 +140,59 @@ function parseAction(value: unknown): AutomationAction | 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
}
@ -593,6 +663,13 @@ async function applyActions(
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") {
@ -735,6 +812,18 @@ async function applyActions(
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) {
@ -742,5 +831,140 @@ async function applyActions(
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
}