feat: automações com envio de e-mail
This commit is contained in:
parent
469608a10b
commit
58a1ed6b36
6 changed files with 958 additions and 49 deletions
|
|
@ -14,6 +14,15 @@ import {
|
||||||
} from "./automationsEngine"
|
} from "./automationsEngine"
|
||||||
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
||||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
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 AutomationAction =
|
||||||
| { type: "SET_PRIORITY"; priority: string }
|
| { type: "SET_PRIORITY"; priority: string }
|
||||||
|
|
@ -22,6 +31,14 @@ type AutomationAction =
|
||||||
| { type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
|
| { type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
|
||||||
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
|
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
|
||||||
| { type: "ADD_INTERNAL_COMMENT"; body: string }
|
| { 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 AutomationRunStatus = "SUCCESS" | "SKIPPED" | "ERROR"
|
||||||
|
|
||||||
|
|
@ -123,6 +140,59 @@ function parseAction(value: unknown): AutomationAction | null {
|
||||||
return { type: "ADD_INTERNAL_COMMENT", body }
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,6 +663,13 @@ async function applyActions(
|
||||||
|
|
||||||
const patch: Partial<Doc<"tickets">> = {}
|
const patch: Partial<Doc<"tickets">> = {}
|
||||||
const applied: Array<{ type: string; details?: Record<string, unknown> }> = []
|
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) {
|
for (const action of actions) {
|
||||||
if (action.type === "SET_PRIORITY") {
|
if (action.type === "SET_PRIORITY") {
|
||||||
|
|
@ -735,6 +812,18 @@ async function applyActions(
|
||||||
applied.push({ type: action.type })
|
applied.push({ type: action.type })
|
||||||
continue
|
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) {
|
if (Object.keys(patch).length > 0) {
|
||||||
|
|
@ -742,5 +831,140 @@ async function applyActions(
|
||||||
await ctx.db.patch(ticket._id, patch)
|
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
|
return applied
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
convex/emailTemplates.ts
Normal file
268
convex/emailTemplates.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
const COLORS = {
|
||||||
|
primary: "#00e8ff",
|
||||||
|
primaryDark: "#00c4d6",
|
||||||
|
primaryForeground: "#020617",
|
||||||
|
background: "#f8fafc",
|
||||||
|
card: "#ffffff",
|
||||||
|
border: "#e2e8f0",
|
||||||
|
textPrimary: "#0f172a",
|
||||||
|
textSecondary: "#334155",
|
||||||
|
textMuted: "#64748b",
|
||||||
|
statusPending: "#64748b",
|
||||||
|
statusPendingBg: "#f1f5f9",
|
||||||
|
statusProgress: "#0ea5e9",
|
||||||
|
statusProgressBg: "#e0f2fe",
|
||||||
|
statusPaused: "#f59e0b",
|
||||||
|
statusPausedBg: "#fef3c7",
|
||||||
|
statusResolved: "#10b981",
|
||||||
|
statusResolvedBg: "#d1fae5",
|
||||||
|
priorityLow: "#64748b",
|
||||||
|
priorityLowBg: "#f1f5f9",
|
||||||
|
priorityMedium: "#0a4760",
|
||||||
|
priorityMediumBg: "#dff1fb",
|
||||||
|
priorityHigh: "#7d3b05",
|
||||||
|
priorityHighBg: "#fde8d1",
|
||||||
|
priorityUrgent: "#8b0f1c",
|
||||||
|
priorityUrgentBg: "#fbd9dd",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailTicketSummary = {
|
||||||
|
reference: number
|
||||||
|
subject: string
|
||||||
|
status?: string | null
|
||||||
|
priority?: string | null
|
||||||
|
companyName?: string | null
|
||||||
|
requesterName?: string | null
|
||||||
|
assigneeName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ""
|
||||||
|
const s = String(value)
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtmlParagraphs(text: string): string {
|
||||||
|
const trimmed = text.replace(/\r\n/g, "\n").trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
const paragraphs = trimmed.split(/\n{2,}/g)
|
||||||
|
return paragraphs
|
||||||
|
.map((p) => `<p style="font-size:14px;line-height:1.65;margin:0 0 12px 0;color:${COLORS.textSecondary};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(label: string, bg: string, color: string) {
|
||||||
|
return `<span style="display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;background:${bg};color:${color};white-space:nowrap;">${escapeHtml(label)}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(statusRaw: string) {
|
||||||
|
const status = statusRaw.trim().toUpperCase()
|
||||||
|
const map: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
PENDING: { label: "Pendente", bg: COLORS.statusPendingBg, color: COLORS.statusPending },
|
||||||
|
AWAITING_ATTENDANCE: { label: "Em andamento", bg: COLORS.statusProgressBg, color: COLORS.statusProgress },
|
||||||
|
PAUSED: { label: "Pausado", bg: COLORS.statusPausedBg, color: COLORS.statusPaused },
|
||||||
|
RESOLVED: { label: "Resolvido", bg: COLORS.statusResolvedBg, color: COLORS.statusResolved },
|
||||||
|
}
|
||||||
|
const entry = map[status] ?? { label: statusRaw, bg: COLORS.statusPendingBg, color: COLORS.statusPending }
|
||||||
|
return badge(entry.label, entry.bg, entry.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityBadge(priorityRaw: string) {
|
||||||
|
const priority = priorityRaw.trim().toUpperCase()
|
||||||
|
const map: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
LOW: { label: "Baixa", bg: COLORS.priorityLowBg, color: COLORS.priorityLow },
|
||||||
|
MEDIUM: { label: "Média", bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium },
|
||||||
|
HIGH: { label: "Alta", bg: COLORS.priorityHighBg, color: COLORS.priorityHigh },
|
||||||
|
URGENT: { label: "Urgente", bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent },
|
||||||
|
}
|
||||||
|
const entry = map[priority] ?? { label: priorityRaw, bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium }
|
||||||
|
return badge(entry.label, entry.bg, entry.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBaseUrl() {
|
||||||
|
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketInfoCard(ticket: EmailTicketSummary) {
|
||||||
|
const rows: string[] = []
|
||||||
|
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Chamado</td>
|
||||||
|
<td style="color:${COLORS.textPrimary};font-size:14px;font-weight:700;padding:4px 0;">#${escapeHtml(ticket.reference)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Assunto</td>
|
||||||
|
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.subject)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
|
||||||
|
if (ticket.companyName) {
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Empresa</td>
|
||||||
|
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.companyName)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status) {
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Status</td>
|
||||||
|
<td style="padding:4px 0;">${statusBadge(ticket.status)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.priority) {
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Prioridade</td>
|
||||||
|
<td style="padding:4px 0;">${priorityBadge(ticket.priority)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.requesterName) {
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Solicitante</td>
|
||||||
|
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.requesterName)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.assigneeName) {
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Responsável</td>
|
||||||
|
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.assigneeName)}</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};border-radius:10px;margin:18px 0 0 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
${rows.join("")}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label: string, url: string) {
|
||||||
|
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${COLORS.primary};color:${COLORS.primaryForeground};text-decoration:none;border-radius:12px;padding:12px 18px;font-weight:800;font-size:14px;border:1px solid ${COLORS.primaryDark};">${escapeHtml(label)}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAutomationEmail(params: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
ticket: EmailTicketSummary
|
||||||
|
ctaLabel: string
|
||||||
|
ctaUrl: string
|
||||||
|
}) {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<table cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;padding-left:12px;">
|
||||||
|
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
|
||||||
|
${textToHtmlParagraphs(params.message)}
|
||||||
|
${ticketInfoCard(params.ticket)}
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
${button(params.ctaLabel, params.ctaUrl)}
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
|
||||||
|
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
|
||||||
|
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:left;">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSimpleNotificationEmail(params: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
ctaLabel: string
|
||||||
|
ctaUrl: string
|
||||||
|
}) {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<table cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;padding-left:12px;">
|
||||||
|
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
|
||||||
|
${textToHtmlParagraphs(params.message)}
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
${button(params.ctaLabel, params.ctaUrl)}
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
|
||||||
|
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
|
||||||
|
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:left;">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,31 @@ import tls from "tls"
|
||||||
import { action } from "./_generated/server"
|
import { action } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
|
|
||||||
|
import { buildBaseUrl, renderSimpleNotificationEmail } from "./emailTemplates"
|
||||||
|
|
||||||
function b64(input: string) {
|
function b64(input: string) {
|
||||||
return Buffer.from(input, "utf8").toString("base64")
|
return Buffer.from(input, "utf8").toString("base64")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) {
|
type SmtpConfig = { host: string; port: number; username: string; password: string; from: string }
|
||||||
|
|
||||||
|
function buildSmtpConfig(): SmtpConfig | null {
|
||||||
|
const host = process.env.SMTP_ADDRESS || process.env.SMTP_HOST
|
||||||
|
const port = Number(process.env.SMTP_PORT ?? 465)
|
||||||
|
const username = process.env.SMTP_USERNAME || process.env.SMTP_USER
|
||||||
|
const password = process.env.SMTP_PASSWORD || process.env.SMTP_PASS
|
||||||
|
|
||||||
|
const legacyFrom = process.env.MAILER_SENDER_EMAIL
|
||||||
|
const fromEmail = process.env.SMTP_FROM_EMAIL
|
||||||
|
const fromName = process.env.SMTP_FROM_NAME || "Raven"
|
||||||
|
const from = legacyFrom || (fromEmail ? `"${fromName}" <${fromEmail}>` : "Raven <no-reply@example.com>")
|
||||||
|
|
||||||
|
if (!host || !username || !password) return null
|
||||||
|
|
||||||
|
return { host, port, username, password, from }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
|
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
|
|
@ -63,35 +83,6 @@ async function sendSmtpMail(cfg: { host: string; port: number; username: string;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseUrl() {
|
|
||||||
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailTemplate({ title, message, ctaLabel, ctaUrl }: { title: string; message: string; ctaLabel: string; ctaUrl: string }) {
|
|
||||||
return `
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8fafc;padding:24px 0;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="text-align:left;">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;">
|
|
||||||
<img src="${buildBaseUrl()}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:8px;"/>
|
|
||||||
<span style="font-weight:700;font-size:18px;">Raven</span>
|
|
||||||
</div>
|
|
||||||
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;">${title}</h1>
|
|
||||||
<p style="font-size:14px;line-height:1.6;margin:0 0 16px 0;color:#334155;">${message}</p>
|
|
||||||
<a href="${ctaUrl}" style="display:inline-block;background:#111827;color:#fff;text-decoration:none;border-radius:10px;padding:10px 16px;font-weight:600;">${ctaLabel}</a>
|
|
||||||
<p style="font-size:12px;color:#64748b;margin-top:20px;">Se o botão não funcionar, copie e cole esta URL no navegador:<br/><a href="${ctaUrl}" style="color:#0ea5e9;text-decoration:none;">${ctaUrl}</a></p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sendPublicCommentEmail = action({
|
export const sendPublicCommentEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
|
@ -100,21 +91,15 @@ export const sendPublicCommentEmail = action({
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||||
const smtp = {
|
const smtp = buildSmtpConfig()
|
||||||
host: process.env.SMTP_ADDRESS!,
|
if (!smtp) {
|
||||||
port: Number(process.env.SMTP_PORT ?? 465),
|
|
||||||
username: process.env.SMTP_USERNAME!,
|
|
||||||
password: process.env.SMTP_PASSWORD!,
|
|
||||||
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
|
|
||||||
}
|
|
||||||
if (!smtp.host || !smtp.username || !smtp.password) {
|
|
||||||
console.warn("SMTP not configured; skipping ticket comment email")
|
console.warn("SMTP not configured; skipping ticket comment email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
const baseUrl = buildBaseUrl()
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||||
const html = emailTemplate({
|
const html = renderSimpleNotificationEmail({
|
||||||
title: `Nova atualização no seu chamado #${reference}`,
|
title: `Nova atualização no seu chamado #${reference}`,
|
||||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||||
ctaLabel: "Abrir e responder",
|
ctaLabel: "Abrir e responder",
|
||||||
|
|
@ -133,21 +118,15 @@ export const sendResolvedEmail = action({
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||||
const smtp = {
|
const smtp = buildSmtpConfig()
|
||||||
host: process.env.SMTP_ADDRESS!,
|
if (!smtp) {
|
||||||
port: Number(process.env.SMTP_PORT ?? 465),
|
|
||||||
username: process.env.SMTP_USERNAME!,
|
|
||||||
password: process.env.SMTP_PASSWORD!,
|
|
||||||
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
|
|
||||||
}
|
|
||||||
if (!smtp.host || !smtp.username || !smtp.password) {
|
|
||||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
const baseUrl = buildBaseUrl()
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||||
const html = emailTemplate({
|
const html = renderSimpleNotificationEmail({
|
||||||
title: `Chamado #${reference} encerrado`,
|
title: `Chamado #${reference} encerrado`,
|
||||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||||
ctaLabel: "Ver detalhes",
|
ctaLabel: "Ver detalhes",
|
||||||
|
|
@ -157,3 +136,33 @@ export const sendResolvedEmail = action({
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const sendAutomationEmail = action({
|
||||||
|
args: {
|
||||||
|
to: v.array(v.string()),
|
||||||
|
subject: v.string(),
|
||||||
|
html: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (_ctx, { to, subject, html }) => {
|
||||||
|
const smtp = buildSmtpConfig()
|
||||||
|
if (!smtp) {
|
||||||
|
console.warn("SMTP not configured; skipping automation email")
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = to
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 50)
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
return { skipped: true, reason: "no_recipients" }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await sendSmtpMail(smtp, recipient, subject, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sent: recipients.length }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
79
docs/AUTOMATIONS_EMAIL.md
Normal file
79
docs/AUTOMATIONS_EMAIL.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Automações ▸ Envio de e-mails (guia de manutenção)
|
||||||
|
|
||||||
|
## Visão geral
|
||||||
|
O envio de e-mails via automações funciona como uma **ação** dentro do motor de automações de tickets.
|
||||||
|
|
||||||
|
Por motivos de compatibilidade e segurança:
|
||||||
|
- O HTML do e-mail é gerado com **tabelas + CSS inline** (compatível com Gmail/Outlook/Apple Mail).
|
||||||
|
- O envio (SMTP) acontece em **Convex Action** (`"use node"`), porque mutações Convex não devem fazer I/O de rede.
|
||||||
|
|
||||||
|
## Onde as automações disparam
|
||||||
|
Os eventos de ticket chamam o motor de automações em `convex/tickets.ts`:
|
||||||
|
- Criação do ticket (`TICKET_CREATED`)
|
||||||
|
- Alteração de status (`STATUS_CHANGED`)
|
||||||
|
- Alteração de prioridade (`PRIORITY_CHANGED`)
|
||||||
|
- Alteração de fila (`QUEUE_CHANGED`)
|
||||||
|
- Inclusão de comentário (`COMMENT_ADDED`)
|
||||||
|
- Finalização/resolução (`TICKET_RESOLVED`)
|
||||||
|
|
||||||
|
## Onde a ação é validada e aplicada
|
||||||
|
Arquivo: `convex/automations.ts`
|
||||||
|
|
||||||
|
Pontos principais:
|
||||||
|
- **Validação/parse** da ação `SEND_EMAIL` em `parseAction(...)`.
|
||||||
|
- **Execução** em `applyActions(...)`:
|
||||||
|
- Resolve destinatários (solicitante, responsável, usuário interno e e-mails livres).
|
||||||
|
- Faz interpolação de variáveis `{{...}}` em assunto/mensagem.
|
||||||
|
- Gera o HTML com `renderAutomationEmail(...)` (`convex/emailTemplates.ts`).
|
||||||
|
- Agenda o envio via `ctx.scheduler.runAfter(1, api.ticketNotifications.sendAutomationEmail, ...)`.
|
||||||
|
|
||||||
|
## Onde o e-mail é enviado de fato (SMTP)
|
||||||
|
Arquivo: `convex/ticketNotifications.ts`
|
||||||
|
|
||||||
|
- A action `sendAutomationEmail` faz o envio via SMTP e aceita:
|
||||||
|
- `to`: lista de destinatários
|
||||||
|
- `subject`: assunto
|
||||||
|
- `html`: HTML já renderizado
|
||||||
|
|
||||||
|
Observação: para não “vazar” destinatários entre si, o envio é feito **um-a-um** (um e-mail por destinatário).
|
||||||
|
|
||||||
|
## Templates de e-mail
|
||||||
|
Arquivo: `convex/emailTemplates.ts`
|
||||||
|
|
||||||
|
Templates adicionados:
|
||||||
|
- `renderAutomationEmail(...)`: usado pela ação `SEND_EMAIL` (inclui cartão com dados do ticket + CTA).
|
||||||
|
- `renderSimpleNotificationEmail(...)`: utilitário reaproveitado por notificações simples (comentário público / encerramento).
|
||||||
|
|
||||||
|
## Variáveis suportadas (interpolação)
|
||||||
|
Você pode usar estas variáveis em **Assunto** e **Mensagem**:
|
||||||
|
- `{{ticket.reference}}`
|
||||||
|
- `{{ticket.subject}}`
|
||||||
|
- `{{ticket.status}}`
|
||||||
|
- `{{ticket.priority}}`
|
||||||
|
- `{{company.name}}`
|
||||||
|
- `{{requester.name}}`
|
||||||
|
- `{{assignee.name}}`
|
||||||
|
- `{{ticket.url.portal}}`
|
||||||
|
- `{{ticket.url.staff}}`
|
||||||
|
- `{{automation.name}}`
|
||||||
|
|
||||||
|
## Link do botão (CTA)
|
||||||
|
A UI permite escolher:
|
||||||
|
- `Auto` (padrão): se houver destinatário interno (responsável/usuário) usa **Painel**; caso contrário usa **Portal**.
|
||||||
|
- `Portal (cliente)`: `/portal/tickets/:id`
|
||||||
|
- `Painel (agente)`: `/tickets/:id`
|
||||||
|
|
||||||
|
Se você precisar enviar para cliente **e** agente no mesmo evento, prefira criar **duas ações SEND_EMAIL** (uma com link Portal e outra com link Painel).
|
||||||
|
|
||||||
|
## Variáveis de ambiente (SMTP)
|
||||||
|
O envio no Convex tenta usar:
|
||||||
|
- `SMTP_ADDRESS` ou `SMTP_HOST`
|
||||||
|
- `SMTP_USERNAME` ou `SMTP_USER`
|
||||||
|
- `SMTP_PASSWORD` ou `SMTP_PASS`
|
||||||
|
- `SMTP_PORT` (default `465`)
|
||||||
|
- `MAILER_SENDER_EMAIL` (legacy) ou `SMTP_FROM_EMAIL` + `SMTP_FROM_NAME`
|
||||||
|
|
||||||
|
## Testes de regressão
|
||||||
|
Arquivo: `tests/automations-engine.test.ts`
|
||||||
|
- Teste adiciona um cenário onde a ação `SEND_EMAIL` está presente e valida que o envio é agendado via `scheduler.runAfter`.
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -57,6 +58,9 @@ type ActionType =
|
||||||
| "SET_FORM_TEMPLATE"
|
| "SET_FORM_TEMPLATE"
|
||||||
| "SET_CHAT_ENABLED"
|
| "SET_CHAT_ENABLED"
|
||||||
| "ADD_INTERNAL_COMMENT"
|
| "ADD_INTERNAL_COMMENT"
|
||||||
|
| "SEND_EMAIL"
|
||||||
|
|
||||||
|
type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF"
|
||||||
|
|
||||||
type ActionDraft =
|
type ActionDraft =
|
||||||
| { id: string; type: "SET_PRIORITY"; priority: string }
|
| { id: string; type: "SET_PRIORITY"; priority: string }
|
||||||
|
|
@ -65,6 +69,18 @@ type ActionDraft =
|
||||||
| { id: string; type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
|
| { id: string; type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
|
||||||
| { id: string; type: "SET_CHAT_ENABLED"; enabled: boolean }
|
| { id: string; type: "SET_CHAT_ENABLED"; enabled: boolean }
|
||||||
| { id: string; type: "ADD_INTERNAL_COMMENT"; body: string }
|
| { id: string; type: "ADD_INTERNAL_COMMENT"; body: string }
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "SEND_EMAIL"
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
toRequester: boolean
|
||||||
|
toAssignee: boolean
|
||||||
|
toUserId: string
|
||||||
|
toEmails: string
|
||||||
|
ctaTarget: EmailCtaTarget
|
||||||
|
ctaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
const PRIORITIES = [
|
const PRIORITIES = [
|
||||||
{ value: "LOW", label: "Baixa" },
|
{ value: "LOW", label: "Baixa" },
|
||||||
|
|
@ -109,6 +125,17 @@ function safeString(value: unknown) {
|
||||||
return typeof value === "string" ? value : ""
|
return typeof value === "string" ? value : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseEmailAddresses(raw: string) {
|
||||||
|
return raw
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(email: string) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
function toDraftConditions(raw: unknown | null): ConditionDraft[] {
|
function toDraftConditions(raw: unknown | null): ConditionDraft[] {
|
||||||
const group = raw as { conditions?: unknown } | null
|
const group = raw as { conditions?: unknown } | null
|
||||||
const list = Array.isArray(group?.conditions) ? group?.conditions : []
|
const list = Array.isArray(group?.conditions) ? group?.conditions : []
|
||||||
|
|
@ -128,6 +155,43 @@ function toDraftActions(raw: unknown[]): ActionDraft[] {
|
||||||
const base = a as Record<string, unknown>
|
const base = a as Record<string, unknown>
|
||||||
const type = safeString(base.type) as ActionType
|
const type = safeString(base.type) as ActionType
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
|
if (type === "SEND_EMAIL") {
|
||||||
|
const recipientsRaw = Array.isArray(base.recipients) ? (base.recipients as unknown[]) : []
|
||||||
|
const recipientTypes = recipientsRaw
|
||||||
|
.map((r) => (r && typeof r === "object" ? (r as { type?: unknown }).type : null))
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map((t) => t.trim().toUpperCase())
|
||||||
|
const toRequester = recipientTypes.includes("REQUESTER")
|
||||||
|
const toAssignee = recipientTypes.includes("ASSIGNEE")
|
||||||
|
const userEntry = recipientsRaw.find((r) => {
|
||||||
|
const rec = r as { type?: unknown; userId?: unknown }
|
||||||
|
return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "USER" && typeof rec.userId === "string"
|
||||||
|
}) as { userId?: string } | undefined
|
||||||
|
const toUserId = userEntry?.userId ?? ""
|
||||||
|
const emails = recipientsRaw
|
||||||
|
.filter((r) => {
|
||||||
|
const rec = r as { type?: unknown; email?: unknown }
|
||||||
|
return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "EMAIL" && typeof rec.email === "string"
|
||||||
|
})
|
||||||
|
.map((r) => (r as { email?: string }).email ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
const ctaTargetRaw = safeString(base.ctaTarget).toUpperCase()
|
||||||
|
const ctaTarget: EmailCtaTarget =
|
||||||
|
ctaTargetRaw === "PORTAL" || ctaTargetRaw === "STAFF" ? (ctaTargetRaw as EmailCtaTarget) : "AUTO"
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
subject: safeString(base.subject),
|
||||||
|
message: safeString(base.message),
|
||||||
|
toRequester,
|
||||||
|
toAssignee,
|
||||||
|
toUserId,
|
||||||
|
toEmails: emails.join(", "),
|
||||||
|
ctaTarget,
|
||||||
|
ctaLabel: safeString(base.ctaLabel) || "Abrir chamado",
|
||||||
|
}
|
||||||
|
}
|
||||||
if (type === "MOVE_QUEUE") return { id, type, queueId: safeString(base.queueId) }
|
if (type === "MOVE_QUEUE") return { id, type, queueId: safeString(base.queueId) }
|
||||||
if (type === "ASSIGN_TO") return { id, type, assigneeId: safeString(base.assigneeId) }
|
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_FORM_TEMPLATE") return { id, type, formTemplate: safeString(base.formTemplate) || null }
|
||||||
|
|
@ -273,6 +337,39 @@ export function AutomationEditorDialog({
|
||||||
if (a.type === "ASSIGN_TO") return { type: a.type, assigneeId: a.assigneeId }
|
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_FORM_TEMPLATE") return { type: a.type, formTemplate: a.formTemplate }
|
||||||
if (a.type === "SET_CHAT_ENABLED") return { type: a.type, enabled: a.enabled }
|
if (a.type === "SET_CHAT_ENABLED") return { type: a.type, enabled: a.enabled }
|
||||||
|
if (a.type === "SEND_EMAIL") {
|
||||||
|
const subject = a.subject.trim()
|
||||||
|
const message = a.message.trim()
|
||||||
|
if (!subject) throw new Error("Informe o assunto do e-mail.")
|
||||||
|
if (!message) throw new Error("Informe a mensagem do e-mail.")
|
||||||
|
|
||||||
|
const recipients: Array<Record<string, unknown>> = []
|
||||||
|
if (a.toRequester) recipients.push({ type: "REQUESTER" })
|
||||||
|
if (a.toAssignee) recipients.push({ type: "ASSIGNEE" })
|
||||||
|
if (a.toUserId.trim()) recipients.push({ type: "USER", userId: a.toUserId.trim() })
|
||||||
|
|
||||||
|
const emails = parseEmailAddresses(a.toEmails)
|
||||||
|
const invalid = emails.filter((email) => !isValidEmail(email))
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
throw new Error(`E-mail(s) inválido(s): ${invalid.join(", ")}`)
|
||||||
|
}
|
||||||
|
emails.forEach((email) => recipients.push({ type: "EMAIL", email }))
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
throw new Error("Selecione pelo menos um destinatário para o e-mail.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctaLabel = a.ctaLabel.trim() || "Abrir chamado"
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "SEND_EMAIL",
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
recipients,
|
||||||
|
ctaTarget: a.ctaTarget,
|
||||||
|
ctaLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
return { type: a.type, body: a.body }
|
return { type: a.type, body: a.body }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -703,6 +800,20 @@ export function AutomationEditorDialog({
|
||||||
if (next === "SET_FORM_TEMPLATE") return { id: item.id, type: next, formTemplate: null }
|
if (next === "SET_FORM_TEMPLATE") return { id: item.id, type: next, formTemplate: null }
|
||||||
if (next === "SET_CHAT_ENABLED") return { id: item.id, type: next, enabled: true }
|
if (next === "SET_CHAT_ENABLED") return { id: item.id, type: next, enabled: true }
|
||||||
if (next === "ADD_INTERNAL_COMMENT") return { id: item.id, type: next, body: "" }
|
if (next === "ADD_INTERNAL_COMMENT") return { id: item.id, type: next, body: "" }
|
||||||
|
if (next === "SEND_EMAIL") {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: next,
|
||||||
|
subject: "",
|
||||||
|
message: "",
|
||||||
|
toRequester: true,
|
||||||
|
toAssignee: false,
|
||||||
|
toUserId: "",
|
||||||
|
toEmails: "",
|
||||||
|
ctaTarget: "AUTO",
|
||||||
|
ctaLabel: "Abrir chamado",
|
||||||
|
}
|
||||||
|
}
|
||||||
return { id: item.id, type: "SET_PRIORITY", priority: "MEDIUM" }
|
return { id: item.id, type: "SET_PRIORITY", priority: "MEDIUM" }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -718,6 +829,7 @@ export function AutomationEditorDialog({
|
||||||
<SelectItem value="SET_FORM_TEMPLATE">Aplicar formulário</SelectItem>
|
<SelectItem value="SET_FORM_TEMPLATE">Aplicar formulário</SelectItem>
|
||||||
<SelectItem value="SET_CHAT_ENABLED">Habilitar/desabilitar chat</SelectItem>
|
<SelectItem value="SET_CHAT_ENABLED">Habilitar/desabilitar chat</SelectItem>
|
||||||
<SelectItem value="ADD_INTERNAL_COMMENT">Adicionar comentário interno</SelectItem>
|
<SelectItem value="ADD_INTERNAL_COMMENT">Adicionar comentário interno</SelectItem>
|
||||||
|
<SelectItem value="SEND_EMAIL">Enviar e-mail</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -808,6 +920,131 @@ export function AutomationEditorDialog({
|
||||||
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : a.type === "SEND_EMAIL" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Assunto</Label>
|
||||||
|
<Input
|
||||||
|
value={a.subject}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, subject: e.target.value } : item)))
|
||||||
|
}
|
||||||
|
placeholder="Ex.: Atualização do chamado #{{ticket.reference}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Mensagem</Label>
|
||||||
|
<Textarea
|
||||||
|
value={a.message}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, message: e.target.value } : item)))
|
||||||
|
}
|
||||||
|
placeholder="Escreva a mensagem do e-mail..."
|
||||||
|
className="min-h-24"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Variáveis: <code>{"{{ticket.reference}}"}</code>, <code>{"{{ticket.subject}}"}</code>,{" "}
|
||||||
|
<code>{"{{ticket.status}}"}</code>, <code>{"{{ticket.priority}}"}</code>,{" "}
|
||||||
|
<code>{"{{company.name}}"}</code>, <code>{"{{requester.name}}"}</code>,{" "}
|
||||||
|
<code>{"{{assignee.name}}"}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Destinatários</Label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<Checkbox
|
||||||
|
checked={a.toRequester}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setActions((prev) =>
|
||||||
|
prev.map((item) => (item.id === a.id ? { ...item, toRequester: Boolean(checked) } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Solicitante do ticket
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<Checkbox
|
||||||
|
checked={a.toAssignee}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setActions((prev) =>
|
||||||
|
prev.map((item) => (item.id === a.id ? { ...item, toAssignee: Boolean(checked) } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Responsável do ticket
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Agente específico (opcional)</Label>
|
||||||
|
<Select
|
||||||
|
value={a.toUserId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, toUserId: value } : item)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="">Nenhum</SelectItem>
|
||||||
|
{(agents ?? []).map((u) => (
|
||||||
|
<SelectItem key={u._id} value={String(u._id)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">E-mails adicionais</Label>
|
||||||
|
<Input
|
||||||
|
value={a.toEmails}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, toEmails: e.target.value } : item)))
|
||||||
|
}
|
||||||
|
placeholder="ex.: cliente@empresa.com, outro@dominio.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Link do botão</Label>
|
||||||
|
<Select
|
||||||
|
value={a.ctaTarget}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActions((prev) =>
|
||||||
|
prev.map((item) => (item.id === a.id ? { ...item, ctaTarget: value as EmailCtaTarget } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="AUTO">Auto</SelectItem>
|
||||||
|
<SelectItem value="PORTAL">Portal (cliente)</SelectItem>
|
||||||
|
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Texto do botão</Label>
|
||||||
|
<Input
|
||||||
|
value={a.ctaLabel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, ctaLabel: e.target.value } : item)))
|
||||||
|
}
|
||||||
|
placeholder="Abrir chamado"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={a.body}
|
value={a.body}
|
||||||
|
|
|
||||||
|
|
@ -198,5 +198,97 @@ describe("automations.runTicketAutomationsForEvent", () => {
|
||||||
expect.objectContaining({ automationId, ticketId, eventType: "TICKET_CREATED" })
|
expect.objectContaining({ automationId, ticketId, eventType: "TICKET_CREATED" })
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
|
it("agenda envio de e-mail quando ação SEND_EMAIL é aplicada", async () => {
|
||||||
|
const runAfter = vi.fn(async () => {})
|
||||||
|
|
||||||
|
const ticketId = "ticket_1" as never
|
||||||
|
const automationId = "auto_1" as never
|
||||||
|
|
||||||
|
const ticket = {
|
||||||
|
_id: ticketId,
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
reference: 123,
|
||||||
|
subject: "Teste de automação",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
channel: "EMAIL",
|
||||||
|
requesterId: "user_req_1" as never,
|
||||||
|
requesterSnapshot: { name: "Renan", email: "cliente@empresa.com" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const automations = [
|
||||||
|
{
|
||||||
|
_id: automationId,
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
name: "Auto e-mail",
|
||||||
|
enabled: true,
|
||||||
|
trigger: "TICKET_CREATED",
|
||||||
|
timing: "IMMEDIATE",
|
||||||
|
delayMs: undefined,
|
||||||
|
conditions: null,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "SEND_EMAIL",
|
||||||
|
subject: "Atualização do chamado #{{ticket.reference}}",
|
||||||
|
message: "Olá {{requester.name}}, recebemos seu chamado: {{ticket.subject}}",
|
||||||
|
ctaTarget: "PORTAL",
|
||||||
|
ctaLabel: "Abrir chamado",
|
||||||
|
recipients: [{ type: "REQUESTER" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdBy: "user_1",
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const takeAutomations = vi.fn(async () => automations)
|
||||||
|
const filterAutomations = vi.fn(() => ({ take: takeAutomations }))
|
||||||
|
const withIndexAutomations = vi.fn(() => ({ filter: filterAutomations, take: takeAutomations }))
|
||||||
|
|
||||||
|
const takeRuns = vi.fn(async () => [])
|
||||||
|
const orderRuns = vi.fn(() => ({ take: takeRuns }))
|
||||||
|
const withIndexRuns = vi.fn(() => ({ order: orderRuns, take: takeRuns }))
|
||||||
|
|
||||||
|
const query = vi.fn((table: string) => {
|
||||||
|
if (table === "ticketAutomations") {
|
||||||
|
return { withIndex: withIndexAutomations }
|
||||||
|
}
|
||||||
|
if (table === "ticketAutomationRuns") {
|
||||||
|
return { withIndex: withIndexRuns }
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected table: ${table}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = vi.fn(async (id: unknown) => {
|
||||||
|
if (id === ticketId) return ticket
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const insert = vi.fn(async () => "run_1" as never)
|
||||||
|
const patch = vi.fn(async () => {})
|
||||||
|
const del = vi.fn(async () => {})
|
||||||
|
|
||||||
|
await runTicketAutomationsForEvent(
|
||||||
|
{
|
||||||
|
db: { get, query, insert, patch, delete: del } as unknown,
|
||||||
|
scheduler: { runAfter } as unknown,
|
||||||
|
} as never,
|
||||||
|
{ tenantId: "tenant-1", ticketId, eventType: "TICKET_CREATED" }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(runAfter).toHaveBeenCalledTimes(1)
|
||||||
|
expect(runAfter).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
to: ["cliente@empresa.com"],
|
||||||
|
subject: "Atualização do chamado #123",
|
||||||
|
html: expect.any(String),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue