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
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 }
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue