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

@ -11,6 +11,7 @@ import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -57,6 +58,9 @@ type ActionType =
| "SET_FORM_TEMPLATE"
| "SET_CHAT_ENABLED"
| "ADD_INTERNAL_COMMENT"
| "SEND_EMAIL"
type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF"
type ActionDraft =
| { 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_CHAT_ENABLED"; enabled: boolean }
| { 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 = [
{ value: "LOW", label: "Baixa" },
@ -109,6 +125,17 @@ function safeString(value: unknown) {
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[] {
const group = raw as { conditions?: unknown } | null
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 type = safeString(base.type) as ActionType
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 === "ASSIGN_TO") return { id, type, assigneeId: safeString(base.assigneeId) }
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 === "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 === "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 }
})
@ -703,6 +800,20 @@ export function AutomationEditorDialog({
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 === "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" }
})
)
@ -718,6 +829,7 @@ export function AutomationEditorDialog({
<SelectItem value="SET_FORM_TEMPLATE">Aplicar formulário</SelectItem>
<SelectItem value="SET_CHAT_ENABLED">Habilitar/desabilitar chat</SelectItem>
<SelectItem value="ADD_INTERNAL_COMMENT">Adicionar comentário interno</SelectItem>
<SelectItem value="SEND_EMAIL">Enviar e-mail</SelectItem>
</SelectContent>
</Select>
</div>
@ -808,6 +920,131 @@ export function AutomationEditorDialog({
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
/>
</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
value={a.body}