feat: checklists em tickets + automações

- Adiciona checklist no ticket (itens obrigatórios/opcionais) e bloqueia encerramento com pendências\n- Cria templates de checklist (globais/por empresa) + tela em /settings/checklists\n- Nova ação de automação: aplicar template de checklist\n- Corrige crash do Select (value vazio), warnings de Dialog e dimensionamento de charts\n- Ajusta SMTP (STARTTLS) e melhora teste de integração
This commit is contained in:
esdrasrenan 2025-12-13 20:51:47 -03:00
parent 4306b0504d
commit 88a9ef454e
27 changed files with 2685 additions and 226 deletions

View file

@ -13,6 +13,7 @@ import type * as automations from "../automations.js";
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as categorySlas from "../categorySlas.js";
import type * as checklistTemplates from "../checklistTemplates.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js";
import type * as crons from "../crons.js";
@ -57,6 +58,7 @@ declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap;
categories: typeof categories;
categorySlas: typeof categorySlas;
checklistTemplates: typeof checklistTemplates;
commentTemplates: typeof commentTemplates;
companies: typeof companies;
crons: typeof crons;

View file

@ -16,6 +16,7 @@ import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplate
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail"
import { buildBaseUrl } from "./url"
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
type AutomationEmailTarget = "AUTO" | "PORTAL" | "STAFF"
@ -32,6 +33,7 @@ type AutomationAction =
| { type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
| { type: "ADD_INTERNAL_COMMENT"; body: string }
| { type: "APPLY_CHECKLIST_TEMPLATE"; templateId: Id<"ticketChecklistTemplates"> }
| {
type: "SEND_EMAIL"
recipients: AutomationEmailRecipient[]
@ -141,6 +143,12 @@ function parseAction(value: unknown): AutomationAction | null {
return { type: "ADD_INTERNAL_COMMENT", body }
}
if (type === "APPLY_CHECKLIST_TEMPLATE") {
const templateId = typeof record.templateId === "string" ? record.templateId : ""
if (!templateId) return null
return { type: "APPLY_CHECKLIST_TEMPLATE", templateId: templateId as Id<"ticketChecklistTemplates"> }
}
if (type === "SEND_EMAIL") {
const subject = typeof record.subject === "string" ? record.subject.trim() : ""
const message = typeof record.message === "string" ? record.message : ""
@ -672,6 +680,10 @@ async function applyActions(
ctaLabel: string
}> = []
let checklist = (Array.isArray((ticket as unknown as { checklist?: unknown }).checklist)
? ((ticket as unknown as { checklist?: unknown }).checklist as TicketChecklistItem[])
: []) as TicketChecklistItem[]
for (const action of actions) {
if (action.type === "SET_PRIORITY") {
const next = action.priority.trim().toUpperCase()
@ -814,6 +826,32 @@ async function applyActions(
continue
}
if (action.type === "APPLY_CHECKLIST_TEMPLATE") {
const template = (await ctx.db.get(action.templateId)) as Doc<"ticketChecklistTemplates"> | null
if (!template || template.tenantId !== ticket.tenantId || template.isArchived === true) {
throw new ConvexError("Template de checklist inválido na automação")
}
if (template.companyId && (!ticket.companyId || String(template.companyId) !== String(ticket.companyId))) {
throw new ConvexError("Template de checklist não pertence à empresa do ticket")
}
const result = applyChecklistTemplateToItems(checklist, template, {
now,
actorId: automation.createdBy ?? undefined,
})
if (result.added > 0) {
checklist = result.checklist
patch.checklist = checklist.length > 0 ? checklist : undefined
}
applied.push({
type: action.type,
details: { templateId: String(template._id), templateName: template.name, added: result.added },
})
continue
}
if (action.type === "SEND_EMAIL") {
const subject = action.subject.trim()
const message = action.message.replace(/\r\n/g, "\n").trim()

View file

@ -0,0 +1,259 @@
import { ConvexError, v } from "convex/values"
import type { Doc, Id } from "./_generated/dataModel"
import { mutation, query } from "./_generated/server"
import { requireAdmin, requireStaff } from "./rbac"
import { normalizeChecklistText } from "./ticketChecklist"
function normalizeTemplateName(input: string) {
return input.trim()
}
function normalizeTemplateDescription(input: string | null | undefined) {
const text = (input ?? "").trim()
return text.length > 0 ? text : null
}
function normalizeTemplateItems(
raw: Array<{ id?: string; text: string; required?: boolean }>,
options: { generateId?: () => string }
) {
if (!Array.isArray(raw) || raw.length === 0) {
throw new ConvexError("Adicione pelo menos um item no checklist.")
}
const generateId = options.generateId ?? (() => crypto.randomUUID())
const seen = new Set<string>()
const items: Array<{ id: string; text: string; required?: boolean }> = []
for (const entry of raw) {
const id = String(entry.id ?? "").trim() || generateId()
if (seen.has(id)) {
throw new ConvexError("Itens do checklist com IDs duplicados.")
}
seen.add(id)
const text = normalizeChecklistText(entry.text)
if (!text) {
throw new ConvexError("Todos os itens do checklist precisam ter um texto.")
}
if (text.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
}
const required = typeof entry.required === "boolean" ? entry.required : true
items.push({ id, text, required })
}
return items
}
function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"companies"> | null) {
return {
id: template._id,
name: template.name,
description: template.description ?? "",
company: company ? { id: company._id, name: company.name } : null,
items: (template.items ?? []).map((item) => ({
id: item.id,
text: item.text,
required: typeof item.required === "boolean" ? item.required : true,
})),
isArchived: Boolean(template.isArchived),
createdAt: template.createdAt,
updatedAt: template.updatedAt,
}
}
export const listActive = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
await requireStaff(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("ticketChecklistTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(200)
const filtered = templates.filter((tpl) => {
if (tpl.isArchived === true) return false
if (!companyId) return true
return !tpl.companyId || String(tpl.companyId) === String(companyId)
})
const companiesToHydrate = new Map<string, Id<"companies">>()
for (const tpl of filtered) {
if (tpl.companyId) {
companiesToHydrate.set(String(tpl.companyId), tpl.companyId)
}
}
const companyMap = new Map<string, Doc<"companies">>()
for (const id of companiesToHydrate.values()) {
const company = await ctx.db.get(id)
if (company && company.tenantId === tenantId) {
companyMap.set(String(id), company as Doc<"companies">)
}
}
return filtered
.sort((a, b) => {
const aSpecific = a.companyId ? 1 : 0
const bSpecific = b.companyId ? 1 : 0
if (aSpecific !== bSpecific) return bSpecific - aSpecific
return (a.name ?? "").localeCompare(b.name ?? "", "pt-BR")
})
.map((tpl) => mapTemplate(tpl, tpl.companyId ? (companyMap.get(String(tpl.companyId)) ?? null) : null))
},
})
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
includeArchived: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, viewerId, includeArchived }) => {
await requireAdmin(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("ticketChecklistTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500)
const filtered = templates.filter((tpl) => includeArchived || tpl.isArchived !== true)
const companiesToHydrate = new Map<string, Id<"companies">>()
for (const tpl of filtered) {
if (tpl.companyId) {
companiesToHydrate.set(String(tpl.companyId), tpl.companyId)
}
}
const companyMap = new Map<string, Doc<"companies">>()
for (const id of companiesToHydrate.values()) {
const company = await ctx.db.get(id)
if (company && company.tenantId === tenantId) {
companyMap.set(String(id), company as Doc<"companies">)
}
}
return filtered
.sort((a, b) => {
const aSpecific = a.companyId ? 1 : 0
const bSpecific = b.companyId ? 1 : 0
if (aSpecific !== bSpecific) return bSpecific - aSpecific
return (a.name ?? "").localeCompare(b.name ?? "", "pt-BR")
})
.map((tpl) => mapTemplate(tpl, tpl.companyId ? (companyMap.get(String(tpl.companyId)) ?? null) : null))
},
})
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
items: v.array(
v.object({
id: v.optional(v.string()),
text: v.string(),
required: v.optional(v.boolean()),
}),
),
},
handler: async (ctx, { tenantId, actorId, name, description, companyId, items }) => {
await requireAdmin(ctx, actorId, tenantId)
const normalizedName = normalizeTemplateName(name)
if (normalizedName.length < 3) {
throw new ConvexError("Informe um nome com pelo menos 3 caracteres.")
}
if (companyId) {
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa inválida para o template.")
}
}
const normalizedItems = normalizeTemplateItems(items, {})
const normalizedDescription = normalizeTemplateDescription(description)
const now = Date.now()
return ctx.db.insert("ticketChecklistTemplates", {
tenantId,
name: normalizedName,
description: normalizedDescription ?? undefined,
companyId: companyId ?? undefined,
items: normalizedItems,
isArchived: false,
createdAt: now,
updatedAt: now,
createdBy: actorId,
updatedBy: actorId,
})
},
})
export const update = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
templateId: v.id("ticketChecklistTemplates"),
name: v.string(),
description: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
items: v.array(
v.object({
id: v.optional(v.string()),
text: v.string(),
required: v.optional(v.boolean()),
}),
),
isArchived: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, actorId, templateId, name, description, companyId, items, isArchived }) => {
await requireAdmin(ctx, actorId, tenantId)
const existing = await ctx.db.get(templateId)
if (!existing || existing.tenantId !== tenantId) {
throw new ConvexError("Template de checklist não encontrado.")
}
const normalizedName = normalizeTemplateName(name)
if (normalizedName.length < 3) {
throw new ConvexError("Informe um nome com pelo menos 3 caracteres.")
}
if (companyId) {
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa inválida para o template.")
}
}
const normalizedItems = normalizeTemplateItems(items, {})
const normalizedDescription = normalizeTemplateDescription(description)
const nextArchived = typeof isArchived === "boolean" ? isArchived : Boolean(existing.isArchived)
const now = Date.now()
await ctx.db.patch(templateId, {
name: normalizedName,
description: normalizedDescription ?? undefined,
companyId: companyId ?? undefined,
items: normalizedItems,
isArchived: nextArchived,
updatedAt: now,
updatedBy: actorId,
})
return { ok: true }
},
})

View file

@ -309,6 +309,22 @@ export default defineSchema({
),
formTemplate: v.optional(v.string()),
formTemplateLabel: v.optional(v.string()),
checklist: v.optional(
v.array(
v.object({
id: v.string(),
text: v.string(),
done: v.boolean(),
required: v.optional(v.boolean()),
templateId: v.optional(v.id("ticketChecklistTemplates")),
templateItemId: v.optional(v.string()),
createdAt: v.optional(v.number()),
createdBy: v.optional(v.id("users")),
doneAt: v.optional(v.number()),
doneBy: v.optional(v.id("users")),
})
)
),
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
resolvedWithTicketId: v.optional(v.id("tickets")),
reopenDeadline: v.optional(v.number()),
@ -633,6 +649,28 @@ export default defineSchema({
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_active", ["tenantId", "isArchived"]),
ticketChecklistTemplates: defineTable({
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
items: v.array(
v.object({
id: v.string(),
text: v.string(),
required: v.optional(v.boolean()),
})
),
isArchived: v.optional(v.boolean()),
createdAt: v.number(),
updatedAt: v.number(),
createdBy: v.optional(v.id("users")),
updatedBy: v.optional(v.id("users")),
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_active", ["tenantId", "isArchived"]),
userInvites: defineTable({
tenantId: v.string(),
inviteId: v.string(),

71
convex/ticketChecklist.ts Normal file
View file

@ -0,0 +1,71 @@
import type { Id } from "./_generated/dataModel"
export type TicketChecklistItem = {
id: string
text: string
done: boolean
required?: boolean
templateId?: Id<"ticketChecklistTemplates">
templateItemId?: string
createdAt?: number
createdBy?: Id<"users">
doneAt?: number
doneBy?: Id<"users">
}
export type TicketChecklistTemplateLike = {
_id: Id<"ticketChecklistTemplates">
items: Array<{ id: string; text: string; required?: boolean }>
}
export function normalizeChecklistText(input: string) {
return input.replace(/\r\n/g, "\n").trim()
}
export function checklistBlocksResolution(checklist: TicketChecklistItem[] | null | undefined) {
return (checklist ?? []).some((item) => (item.required ?? true) && item.done !== true)
}
export function applyChecklistTemplateToItems(
existing: TicketChecklistItem[],
template: TicketChecklistTemplateLike,
options: {
now: number
actorId?: Id<"users">
generateId?: () => string
}
) {
const generateId = options.generateId ?? (() => crypto.randomUUID())
const now = options.now
const next = Array.isArray(existing) ? [...existing] : []
const existingKeys = new Set<string>()
for (const item of next) {
if (!item.templateId || !item.templateItemId) continue
existingKeys.add(`${String(item.templateId)}:${item.templateItemId}`)
}
let added = 0
for (const tplItem of template.items ?? []) {
const templateItemId = String(tplItem.id ?? "").trim()
const text = normalizeChecklistText(String(tplItem.text ?? ""))
if (!templateItemId || !text) continue
const key = `${String(template._id)}:${templateItemId}`
if (existingKeys.has(key)) continue
existingKeys.add(key)
next.push({
id: generateId(),
text,
done: false,
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
templateId: template._id,
templateItemId,
createdAt: now,
createdBy: options.actorId,
})
added += 1
}
return { checklist: next, added }
}

View file

@ -1,5 +1,6 @@
"use node"
import net from "net"
import tls from "tls"
import { action } from "./_generated/server"
import { v } from "convex/values"
@ -11,7 +12,28 @@ function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
type SmtpConfig = { host: string; port: number; username: string; password: string; from: string }
function extractEnvelopeAddress(from: string): string {
const angle = from.match(/<\s*([^>\s]+)\s*>/)
if (angle?.[1]) return angle[1]
const paren = from.match(/\(([^)\s]+@[^)\s]+)\)/)
if (paren?.[1]) return paren[1]
const email = from.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/)
if (email?.[0]) return email[0]
return from
}
type SmtpConfig = {
host: string
port: number
username: string
password: string
from: string
secure: boolean
timeoutMs: number
}
function buildSmtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_ADDRESS || process.env.SMTP_HOST
@ -26,62 +48,237 @@ function buildSmtpConfig(): SmtpConfig | null {
if (!host || !username || !password) return null
return { host, port, username, password, from }
const secureFlag = (process.env.SMTP_SECURE ?? process.env.SMTP_TLS ?? "").toLowerCase()
const secure = secureFlag ? secureFlag === "true" : port === 465
return { host, port, username, password, from, secure, timeoutMs: 30000 }
}
type SmtpSocket = net.Socket | tls.TLSSocket
type SmtpResponse = { code: number; lines: string[] }
function createSmtpReader(socket: SmtpSocket, timeoutMs: number) {
let buffer = ""
let current: SmtpResponse | null = null
const queue: SmtpResponse[] = []
let pending:
| { resolve: (response: SmtpResponse) => void; reject: (error: unknown) => void; timer: ReturnType<typeof setTimeout> }
| null = null
const finalize = (response: SmtpResponse) => {
if (pending) {
clearTimeout(pending.timer)
const resolve = pending.resolve
pending = null
resolve(response)
return
}
queue.push(response)
}
const onData = (data: Buffer) => {
buffer += data.toString("utf8")
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line) continue
const match = line.match(/^(\d{3})([ -])\s?(.*)$/)
if (!match) continue
const code = Number(match[1])
const isFinal = match[2] === " "
if (!current) current = { code, lines: [] }
current.lines.push(line)
if (isFinal) {
const response = current
current = null
finalize(response)
}
}
}
const onError = (error: unknown) => {
if (pending) {
clearTimeout(pending.timer)
const reject = pending.reject
pending = null
reject(error)
}
}
socket.on("data", onData)
socket.on("error", onError)
const read = () =>
new Promise<SmtpResponse>((resolve, reject) => {
const queued = queue.shift()
if (queued) {
resolve(queued)
return
}
if (pending) {
reject(new Error("smtp_concurrent_read"))
return
}
const timer = setTimeout(() => {
if (!pending) return
const rejectPending = pending.reject
pending = null
rejectPending(new Error("smtp_timeout"))
}, timeoutMs)
pending = { resolve, reject, timer }
})
const dispose = () => {
socket.off("data", onData)
socket.off("error", onError)
if (pending) {
clearTimeout(pending.timer)
pending = null
}
}
return { read, dispose }
}
function isCapability(lines: string[], capability: string) {
const upper = capability.trim().toUpperCase()
return lines.some((line) => line.replace(/^(\d{3})([ -])/, "").trim().toUpperCase().startsWith(upper))
}
function assertCode(response: SmtpResponse, expected: number | ((code: number) => boolean), context: string) {
const ok = typeof expected === "number" ? response.code === expected : expected(response.code)
if (ok) return
throw new Error(`smtp_unexpected_response:${context}:${response.code}:${response.lines.join(" | ")}`)
}
async function connectPlain(host: string, port: number, timeoutMs: number) {
return new Promise<net.Socket>((resolve, reject) => {
const socket = net.connect(port, host)
const timer = setTimeout(() => {
socket.destroy()
reject(new Error("smtp_connect_timeout"))
}, timeoutMs)
socket.once("connect", () => {
clearTimeout(timer)
resolve(socket)
})
socket.once("error", (e) => {
clearTimeout(timer)
reject(e)
})
})
}
async function connectTls(host: string, port: number, timeoutMs: number) {
return new Promise<tls.TLSSocket>((resolve, reject) => {
const socket = tls.connect({ host, port, rejectUnauthorized: false, servername: host })
const timer = setTimeout(() => {
socket.destroy()
reject(new Error("smtp_connect_timeout"))
}, timeoutMs)
socket.once("secureConnect", () => {
clearTimeout(timer)
resolve(socket)
})
socket.once("error", (e) => {
clearTimeout(timer)
reject(e)
})
})
}
async function upgradeToStartTls(socket: net.Socket, host: string, timeoutMs: number) {
return new Promise<tls.TLSSocket>((resolve, reject) => {
const tlsSocket = tls.connect({ socket, servername: host, rejectUnauthorized: false })
const timer = setTimeout(() => {
tlsSocket.destroy()
reject(new Error("smtp_connect_timeout"))
}, timeoutMs)
tlsSocket.once("secureConnect", () => {
clearTimeout(timer)
resolve(tlsSocket)
})
tlsSocket.once("error", (e) => {
clearTimeout(timer)
reject(e)
})
})
}
async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) {
return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
let buffer = ""
const send = (line: string) => socket.write(line + "\r\n")
const wait = (expected: string | RegExp) =>
new Promise<void>((res) => {
const onData = (data: Buffer) => {
buffer += data.toString()
const lines = buffer.split(/\r?\n/)
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
socket.removeListener("data", onData)
res()
}
}
socket.on("data", onData)
socket.on("error", reject)
})
const timeoutMs = Math.max(1000, cfg.timeoutMs)
;(async () => {
await wait(/^220 /)
send(`EHLO ${cfg.host}`)
await wait(/^250-/)
await wait(/^250 /)
send("AUTH LOGIN")
await wait(/^334 /)
send(b64(cfg.username))
await wait(/^334 /)
send(b64(cfg.password))
await wait(/^235 /)
send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`)
await wait(/^250 /)
send(`RCPT TO:<${to}>`)
await wait(/^250 /)
send("DATA")
await wait(/^354 /)
const headers = [
`From: ${cfg.from}`,
`To: ${to}`,
`Subject: ${subject}`,
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
].join("\r\n")
send(headers + "\r\n\r\n" + html + "\r\n.")
await wait(/^250 /)
send("QUIT")
socket.end()
resolve()
})().catch(reject)
})
socket.on("error", reject)
})
let socket: SmtpSocket | null = null
let reader: ReturnType<typeof createSmtpReader> | null = null
const sendLine = (line: string) => socket?.write(line + "\r\n")
const readExpected = async (expected: number | ((code: number) => boolean), context: string) => {
if (!reader) throw new Error("smtp_reader_not_ready")
const response = await reader.read()
assertCode(response, expected, context)
return response
}
try {
socket = cfg.secure ? await connectTls(cfg.host, cfg.port, timeoutMs) : await connectPlain(cfg.host, cfg.port, timeoutMs)
reader = createSmtpReader(socket, timeoutMs)
await readExpected(220, "greeting")
sendLine(`EHLO ${cfg.host}`)
let ehlo = await readExpected(250, "ehlo")
if (!cfg.secure && isCapability(ehlo.lines, "STARTTLS")) {
sendLine("STARTTLS")
await readExpected(220, "starttls")
reader.dispose()
socket = await upgradeToStartTls(socket as net.Socket, cfg.host, timeoutMs)
reader = createSmtpReader(socket, timeoutMs)
sendLine(`EHLO ${cfg.host}`)
ehlo = await readExpected(250, "ehlo_starttls")
}
sendLine("AUTH LOGIN")
await readExpected(334, "auth_login")
sendLine(b64(cfg.username))
await readExpected(334, "auth_username")
sendLine(b64(cfg.password))
await readExpected(235, "auth_password")
const envelopeFrom = extractEnvelopeAddress(cfg.from)
sendLine(`MAIL FROM:<${envelopeFrom}>`)
await readExpected((code) => Math.floor(code / 100) === 2, "mail_from")
sendLine(`RCPT TO:<${to}>`)
await readExpected((code) => Math.floor(code / 100) === 2, "rcpt_to")
sendLine("DATA")
await readExpected(354, "data")
const headers = [
`From: ${cfg.from}`,
`To: ${to}`,
`Subject: ${subject}`,
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
].join("\r\n")
sendLine(headers + "\r\n\r\n" + html + "\r\n.")
await readExpected((code) => Math.floor(code / 100) === 2, "message")
sendLine("QUIT")
await readExpected(221, "quit")
} finally {
reader?.dispose()
socket?.end()
}
}
export const sendPublicCommentEmail = action({

View file

@ -19,6 +19,12 @@ import {
getTemplateByKey,
normalizeFormTemplateKey,
} from "./ticketFormTemplates";
import {
applyChecklistTemplateToItems,
checklistBlocksResolution,
normalizeChecklistText,
type TicketChecklistItem,
} from "./ticketChecklist";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
@ -2088,6 +2094,20 @@ export const getById = query({
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
checklist: Array.isArray(t.checklist)
? t.checklist.map((item) => ({
id: item.id,
text: item.text,
done: item.done,
required: typeof item.required === "boolean" ? item.required : true,
templateId: item.templateId ? String(item.templateId) : undefined,
templateItemId: item.templateItemId ?? undefined,
createdAt: item.createdAt ?? undefined,
createdBy: item.createdBy ? String(item.createdBy) : undefined,
doneAt: item.doneAt ?? undefined,
doneBy: item.doneBy ? String(item.doneBy) : undefined,
}))
: [],
lastTimelineEntry: null,
metrics: null,
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
@ -2177,6 +2197,15 @@ export const create = mutation({
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
machineId: v.optional(v.id("machines")),
checklist: v.optional(
v.array(
v.object({
text: v.string(),
required: v.optional(v.boolean()),
})
)
),
checklistTemplateIds: v.optional(v.array(v.id("ticketChecklistTemplates"))),
customFields: v.optional(
v.array(
v.object({
@ -2285,6 +2314,23 @@ export const create = mutation({
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = "PENDING";
const manualChecklist: TicketChecklistItem[] = (args.checklist ?? []).map((entry) => {
const text = normalizeChecklistText(entry.text ?? "");
if (!text) {
throw new ConvexError("Item do checklist inválido.")
}
if (text.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
}
return {
id: crypto.randomUUID(),
text,
done: false,
required: typeof entry.required === "boolean" ? entry.required : true,
createdAt: now,
createdBy: args.actorId,
}
})
const requesterSnapshot = {
name: requester.name,
email: requester.email,
@ -2302,6 +2348,19 @@ export const create = mutation({
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined
let checklist = manualChecklist
for (const templateId of args.checklistTemplateIds ?? []) {
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== args.tenantId || template.isArchived === true) {
throw new ConvexError("Template de checklist inválido.")
}
if (template.companyId && (!resolvedCompanyId || String(template.companyId) !== String(resolvedCompanyId))) {
throw new ConvexError("Template de checklist não pertence à empresa do ticket.")
}
checklist = applyChecklistTemplateToItems(checklist, template, { now, actorId: args.actorId }).checklist
}
const assigneeSnapshot = initialAssignee
? {
@ -2358,7 +2417,7 @@ export const create = mutation({
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: companyDoc?._id ?? requester.companyId ?? undefined,
companyId: resolvedCompanyId,
companySnapshot,
machineId: machineDoc?._id ?? undefined,
machineSnapshot: machineDoc
@ -2382,6 +2441,7 @@ export const create = mutation({
resolvedAt: undefined,
closedAt: undefined,
tags: [],
checklist: checklist.length > 0 ? checklist : undefined,
slaPolicyId: undefined,
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
@ -2423,6 +2483,259 @@ export const create = mutation({
},
});
function ensureChecklistEditor(viewer: { role: string | null }) {
const normalizedRole = (viewer.role ?? "").toUpperCase();
if (!INTERNAL_STAFF_ROLES.has(normalizedRole)) {
throw new ConvexError("Apenas administradores e agentes podem alterar o checklist.");
}
}
function normalizeTicketChecklist(list: unknown): TicketChecklistItem[] {
if (!Array.isArray(list)) return [];
return list as TicketChecklistItem[];
}
export const addChecklistItem = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
text: v.string(),
required: v.optional(v.boolean()),
},
handler: async (ctx, { ticketId, actorId, text, required }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const normalizedText = normalizeChecklistText(text);
if (!normalizedText) {
throw new ConvexError("Informe o texto do item do checklist.");
}
if (normalizedText.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
}
const now = Date.now();
const item: TicketChecklistItem = {
id: crypto.randomUUID(),
text: normalizedText,
done: false,
required: typeof required === "boolean" ? required : true,
createdAt: now,
createdBy: actorId,
};
const checklist = normalizeTicketChecklist(ticketDoc.checklist).concat(item);
await ctx.db.patch(ticketId, {
checklist: checklist.length > 0 ? checklist : undefined,
updatedAt: now,
});
return { ok: true, item };
},
});
export const updateChecklistItemText = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
text: v.string(),
},
handler: async (ctx, { ticketId, actorId, itemId, text }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const normalizedText = normalizeChecklistText(text);
if (!normalizedText) {
throw new ConvexError("Informe o texto do item do checklist.");
}
if (normalizedText.length > 240) {
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
}
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const nextChecklist = checklist.map((item) =>
item.id === itemId ? { ...item, text: normalizedText } : item
);
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
return { ok: true };
},
});
export const setChecklistItemDone = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
done: v.boolean(),
},
handler: async (ctx, { ticketId, actorId, itemId, done }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
await requireTicketStaff(ctx, actorId, ticketDoc);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const previous = checklist[index]!;
if (previous.done === done) {
return { ok: true };
}
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.id !== itemId) return item;
if (done) {
return { ...item, done: true, doneAt: now, doneBy: actorId };
}
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const setChecklistItemRequired = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
required: v.boolean(),
},
handler: async (ctx, { ticketId, actorId, itemId, required }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const nextChecklist = checklist.map((item) => (item.id === itemId ? { ...item, required: Boolean(required) } : item));
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
return { ok: true };
},
});
export const removeChecklistItem = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
},
handler: async (ctx, { ticketId, actorId, itemId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const nextChecklist = checklist.filter((item) => item.id !== itemId);
await ctx.db.patch(ticketId, {
checklist: nextChecklist.length > 0 ? nextChecklist : undefined,
updatedAt: Date.now(),
});
return { ok: true };
},
});
export const completeAllChecklistItems = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, actorId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
if (checklist.length === 0) return { ok: true };
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.done === true) return item;
return { ...item, done: true, doneAt: now, doneBy: actorId };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const applyChecklistTemplate = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
templateId: v.id("ticketChecklistTemplates"),
},
handler: async (ctx, { ticketId, actorId, templateId }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
ensureChecklistEditor(viewer);
const template = await ctx.db.get(templateId);
if (!template || template.tenantId !== ticketDoc.tenantId || template.isArchived === true) {
throw new ConvexError("Template de checklist inválido.");
}
if (template.companyId && (!ticketDoc.companyId || String(template.companyId) !== String(ticketDoc.companyId))) {
throw new ConvexError("Template de checklist não pertence à empresa do ticket.");
}
const now = Date.now();
const current = normalizeTicketChecklist(ticketDoc.checklist);
const result = applyChecklistTemplateToItems(current, template, { now, actorId });
if (result.added === 0) {
return { ok: true, added: 0 };
}
await ctx.db.patch(ticketId, { checklist: result.checklist, updatedAt: now });
return { ok: true, added: result.added };
},
});
export const addComment = mutation({
args: {
ticketId: v.id("tickets"),
@ -2743,6 +3056,10 @@ export async function resolveTicketHandler(
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const now = Date.now()
if (checklistBlocksResolution((ticketDoc.checklist ?? []) as unknown as TicketChecklistItem[])) {
throw new ConvexError("Conclua todos os itens obrigatórios do checklist antes de encerrar o ticket.")
}
const baseRelated = new Set<string>()
for (const rel of relatedTicketIds ?? []) {
if (String(rel) === String(ticketId)) continue