diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a812892..75fbfcb 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/automations.ts b/convex/automations.ts index 2dddd03..d6abc56 100644 --- a/convex/automations.ts +++ b/convex/automations.ts @@ -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() diff --git a/convex/checklistTemplates.ts b/convex/checklistTemplates.ts new file mode 100644 index 0000000..7b2f728 --- /dev/null +++ b/convex/checklistTemplates.ts @@ -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() + 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>() + for (const tpl of filtered) { + if (tpl.companyId) { + companiesToHydrate.set(String(tpl.companyId), tpl.companyId) + } + } + + const companyMap = new Map>() + 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>() + for (const tpl of filtered) { + if (tpl.companyId) { + companiesToHydrate.set(String(tpl.companyId), tpl.companyId) + } + } + + const companyMap = new Map>() + 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 } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 35fe78e..3402484 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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(), diff --git a/convex/ticketChecklist.ts b/convex/ticketChecklist.ts new file mode 100644 index 0000000..efef60b --- /dev/null +++ b/convex/ticketChecklist.ts @@ -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() + 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 } +} + diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index a9234bf..9d1d405 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -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 } + | 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((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((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((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((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((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((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 | 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({ diff --git a/convex/tickets.ts b/convex/tickets.ts index 93865f9..e2c5602 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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() for (const rel of relatedTicketIds ?? []) { if (String(rel) === String(ticketId)) continue diff --git a/src/app/settings/checklists/page.tsx b/src/app/settings/checklists/page.tsx new file mode 100644 index 0000000..fe04dfd --- /dev/null +++ b/src/app/settings/checklists/page.tsx @@ -0,0 +1,27 @@ +import { AppShell } from "@/components/app-shell" +import { ChecklistTemplatesManager } from "@/components/settings/checklist-templates-manager" +import { SiteHeader } from "@/components/site-header" +import { requireAdminSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +export default async function ChecklistTemplatesPage() { + await requireAdminSession() + + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/components/admin/fields/ticket-form-templates-manager.tsx b/src/components/admin/fields/ticket-form-templates-manager.tsx index 7804266..d264a18 100644 --- a/src/components/admin/fields/ticket-form-templates-manager.tsx +++ b/src/components/admin/fields/ticket-form-templates-manager.tsx @@ -32,6 +32,8 @@ type Template = { order: number } +const CLEAR_SELECT_VALUE = "__clear__" + export function TicketFormTemplatesManager() { const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID @@ -276,12 +278,15 @@ export function TicketFormTemplatesManager() {
- setBaseTemplate(value === CLEAR_SELECT_VALUE ? "" : value)} + > - Começar do zero + Começar do zero {baseOptions.map((tpl) => ( {tpl.label} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index a63f3a7..fab9b6e 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -120,6 +120,7 @@ const navigation: NavigationGroup[] = [ { title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }, { title: "Campos personalizados", url: "/admin/custom-fields", icon: ClipboardList, requiredRole: "admin" }, { title: "Templates de comentários", url: "/settings/templates", icon: LayoutTemplate, requiredRole: "admin" }, + { title: "Templates de checklist", url: "/settings/checklists", icon: ClipboardList, requiredRole: "admin" }, { title: "Templates de relatórios", url: "/admin/report-templates", icon: LayoutTemplate, requiredRole: "admin" }, ], }, @@ -340,7 +341,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return ( - + {item.icon ? : null} {item.title} diff --git a/src/components/automations/automation-editor-dialog.tsx b/src/components/automations/automation-editor-dialog.tsx index 0f74600..7494697 100644 --- a/src/components/automations/automation-editor-dialog.tsx +++ b/src/components/automations/automation-editor-dialog.tsx @@ -12,7 +12,7 @@ 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 { DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -58,6 +58,7 @@ type ActionType = | "SET_FORM_TEMPLATE" | "SET_CHAT_ENABLED" | "ADD_INTERNAL_COMMENT" + | "APPLY_CHECKLIST_TEMPLATE" | "SEND_EMAIL" type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF" @@ -69,6 +70,7 @@ 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: "APPLY_CHECKLIST_TEMPLATE"; templateId: string } | { id: string type: "SEND_EMAIL" @@ -112,6 +114,8 @@ const TRIGGERS = [ { value: "TICKET_RESOLVED", label: "Finalização" }, ] +const CLEAR_SELECT_VALUE = "__clear__" + function msToMinutes(ms: number | null) { if (!ms || ms <= 0) return 0 return Math.max(1, Math.round(ms / 60000)) @@ -197,6 +201,7 @@ function toDraftActions(raw: unknown[]): ActionDraft[] { if (type === "SET_FORM_TEMPLATE") return { id, type, formTemplate: safeString(base.formTemplate) || null } if (type === "SET_CHAT_ENABLED") return { id, type, enabled: Boolean(base.enabled) } if (type === "ADD_INTERNAL_COMMENT") return { id, type, body: safeString(base.body) } + if (type === "APPLY_CHECKLIST_TEMPLATE") return { id, type, templateId: safeString(base.templateId) } return { id, type: "SET_PRIORITY", priority: safeString(base.priority) || "MEDIUM" } }) } @@ -245,6 +250,11 @@ export function AutomationEditorDialog({ convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as Array<{ id: string; key: string; label: string }> | undefined + const checklistTemplates = useQuery( + api.checklistTemplates.listActive, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Array<{ id: Id<"ticketChecklistTemplates">; name: string; company: { id: Id<"companies">; name: string } | null }> | undefined + const initialState = useMemo(() => { const rawOp = (automation?.conditions as { op?: unknown } | null)?.op const conditionsOp = rawOp === "OR" ? ("OR" as const) : ("AND" as const) @@ -337,6 +347,11 @@ 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 === "APPLY_CHECKLIST_TEMPLATE") { + const templateId = a.templateId.trim() + if (!templateId) throw new Error("Selecione um template de checklist.") + return { type: a.type, templateId } + } if (a.type === "SEND_EMAIL") { const subject = a.subject.trim() const message = a.message.trim() @@ -422,6 +437,11 @@ export function AutomationEditorDialog({
{automation ? "Editar automação" : "Nova automação"} + + {automation + ? "Edite as condições e ações que devem disparar automaticamente nos tickets." + : "Crie uma automação definindo condições e ações automáticas para tickets."} +
Ativa - setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item))) - } + onValueChange={(value) => { + const nextValue = value === CLEAR_SELECT_VALUE ? "" : value + setConditions((prev) => + prev.map((item) => (item.id === c.id ? { ...item, value: nextValue } : item)) + ) + }} > - Nenhum + Nenhum {(templates ?? []).map((tpl) => ( {tpl.label} @@ -797,13 +820,14 @@ export function AutomationEditorDialog({ const next = value as ActionType if (next === "MOVE_QUEUE") return { id: item.id, type: next, queueId: "" } if (next === "ASSIGN_TO") return { id: item.id, type: next, assigneeId: "" } - 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, + 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 === "APPLY_CHECKLIST_TEMPLATE") return { id: item.id, type: next, templateId: "" } + if (next === "SEND_EMAIL") { + return { + id: item.id, + type: next, subject: "", message: "", toRequester: true, @@ -829,6 +853,7 @@ export function AutomationEditorDialog({ Aplicar formulário Habilitar/desabilitar chat Adicionar comentário interno + Aplicar checklist (template) Enviar e-mail @@ -893,15 +918,18 @@ export function AutomationEditorDialog({ ) : a.type === "SET_FORM_TEMPLATE" ? ( + setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, templateId: value } : item))) + } + > + + + + + {(checklistTemplates ?? []).map((tpl) => ( + + {tpl.name} + {tpl.company ? ` — ${tpl.company.name}` : ""} + + ))} + + ) : a.type === "SEND_EMAIL" ? (
@@ -982,15 +1029,18 @@ export function AutomationEditorDialog({ setName(e.target.value)} placeholder="Ex.: Checklist de instalação" /> +
+
+ + +
+
+ +
+ +