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:
parent
4306b0504d
commit
88a9ef454e
27 changed files with 2685 additions and 226 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
259
convex/checklistTemplates.ts
Normal file
259
convex/checklistTemplates.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
|
|
@ -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
71
convex/ticketChecklist.ts
Normal 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 }
|
||||
}
|
||||
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue