sistema-de-chamados/web/convex/categories.ts

510 lines
14 KiB
TypeScript

import { mutation, query } from "./_generated/server"
import type { MutationCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import { Id } from "./_generated/dataModel"
type CategorySeed = {
name: string
description?: string
secondary: string[]
}
const DEFAULT_CATEGORY_SEED: CategorySeed[] = [
{
name: "Backup",
secondary: ["Instalação, configuração ou agendamento", "Restauração"],
},
{
name: "Certificado digital",
secondary: ["Instalação", "Reparo"],
},
{
name: "E-mail",
secondary: [
"Cota excedida",
"Criação, remoção ou configuração",
"Mensagem de caixa cheia",
"Migração",
"Não envia ou recebe",
"Política Office 365",
"Problemas com anexo",
"Problemas com spam",
"Problemas na base de dados",
"Relay de servidores ou aplicação",
"Resetar ou alterar senha",
],
},
{
name: "Estações",
secondary: [
"Formatação ou clonagem",
"Instalação de SSD ou memória",
"Lentidão ou travamento",
"Problemas com o sistema operacional",
],
},
{
name: "Firewall / Roteador",
secondary: ["Configuração de VPN", "Instalação, restrição ou reparo", "Liberação ou restrição de sites"],
},
{
name: "Hardware",
secondary: [
"Bateria de lítio",
"Fonte de alimentação",
"HD",
"Limpeza de PC",
"Memória",
"Monitor",
"Nobreak",
"Placa de rede",
"Placa de vídeo",
"Placa-mãe",
"Troca de peça",
],
},
{
name: "Implantação",
secondary: ["Implantação Rever"],
},
{
name: "Implantação de serviços",
secondary: ["Antivírus", "E-mail", "Firewall", "Office", "Sistemas"],
},
{
name: "Impressora",
secondary: [
"Configuração",
"Instalação de impressora",
"Instalação de scanner",
"Problemas de impressão",
"Problemas de scanner",
],
},
{
name: "Internet / Rede",
secondary: [
"Lentidão",
"Mapear unidade de rede",
"Problemas de acesso ao Wi-Fi",
"Problemas de cabeamento",
"Problemas no switch",
"Sem acesso à internet",
"Sem acesso à rede",
"Sem acesso a um site específico",
],
},
{
name: "Kernel Panic Full",
secondary: ["Firewall", "Internet", "Provedor de e-mail", "Servidor", "Wi-Fi"],
},
{
name: "Orçamento",
secondary: ["Computadores", "Periféricos", "Serviços", "Softwares", "Servidores"],
},
{
name: "Procedimento de admissão/desligamento",
secondary: ["Admissão", "Desligamento"],
},
{
name: "Projetos",
secondary: ["Projeto de infraestrutura", "Projeto de Wi-Fi", "Projeto de servidor"],
},
{
name: "Relatório / Licenciamento",
secondary: [
"Levantamento de NFs de softwares",
"Licenças",
"Preencher auditoria Microsoft",
"Relatório de estações",
],
},
{
name: "Servidor",
secondary: [
"Adicionar ou trocar HD",
"Configuração de AD/Pastas/GPO",
"Configuração de SO",
"Configuração ou reparo de TS",
"Criação ou remoção de usuário",
"Lentidão ou travamento",
"Problemas de login",
],
},
{
name: "Sistema de produção (ERP)",
secondary: [
"Instalação, atualização, configuração ou reparo",
"Lentidão ou travamento",
"Mensagem de erro",
"Phoenix atualização ou configuração",
"SCI ÚNICO atualização ou configuração",
"SCI ÚNICO lentidão",
"SCI VISUAL atualização ou configuração",
"SCI VISUAL lentidão ou travamento",
],
},
{
name: "Software APP",
secondary: [
"Ativação do Office",
"Ativação do Windows",
"Instalação, atualização, configuração ou reparo",
],
},
{
name: "Telefonia",
secondary: ["Instalação, atualização, configuração ou reparo"],
},
{
name: "Visita de rotina",
secondary: ["Serviços agendados"],
},
]
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
}
async function ensureUniqueSlug(
ctx: Pick<MutationCtx, "db">,
table: "ticketCategories" | "ticketSubcategories",
tenantId: string,
base: string,
scope: { categoryId?: Id<"ticketCategories"> }
) {
let slug = base || "categoria"
let counter = 1
while (true) {
const existing =
table === "ticketCategories"
? await ctx.db
.query("ticketCategories")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
: await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_slug", (q) => q.eq("categoryId", scope.categoryId!).eq("slug", slug))
.first()
if (!existing) return slug
slug = `${base}-${counter}`
counter += 1
}
}
export const list = query({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const categories = await ctx.db
.query("ticketCategories")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect()
if (categories.length === 0) {
return []
}
const subcategories = await ctx.db
.query("ticketSubcategories")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId))
.collect()
return categories.map((category) => ({
id: category._id,
name: category.name,
slug: category.slug,
description: category.description,
order: category.order,
secondary: subcategories
.filter((item) => item.categoryId === category._id)
.sort((a, b) => a.order - b.order)
.map((item) => ({
id: item._id,
name: item.name,
slug: item.slug,
order: item.order,
})),
}))
},
})
export const ensureDefaults = mutation({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const existingCount = await ctx.db
.query("ticketCategories")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
if (existingCount.length > 0) {
return { created: 0 }
}
const now = Date.now()
let created = 0
let order = 0
for (const seed of DEFAULT_CATEGORY_SEED) {
const baseSlug = slugify(seed.name)
const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {})
const categoryId = await ctx.db.insert("ticketCategories", {
tenantId,
name: seed.name,
slug,
description: seed.description,
order,
createdAt: now,
updatedAt: now,
})
created += 1
let subOrder = 0
for (const secondary of seed.secondary) {
const subSlug = await ensureUniqueSlug(
ctx,
"ticketSubcategories",
tenantId,
slugify(secondary),
{ categoryId }
)
await ctx.db.insert("ticketSubcategories", {
tenantId,
categoryId,
name: secondary,
slug: subSlug,
order: subOrder,
createdAt: now,
updatedAt: now,
})
subOrder += 1
}
order += 1
}
return { created }
},
})
export const createCategory = mutation({
args: {
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { tenantId, name, description }) => {
const trimmed = name.trim()
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a categoria")
}
const baseSlug = slugify(trimmed)
const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {})
const now = Date.now()
const last = await ctx.db
.query("ticketCategories")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.order("desc")
.first()
const order = (last?.order ?? -1) + 1
const id = await ctx.db.insert("ticketCategories", {
tenantId,
name: trimmed,
slug,
description,
order,
createdAt: now,
updatedAt: now,
})
return id
},
})
export const updateCategory = mutation({
args: {
categoryId: v.id("ticketCategories"),
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { categoryId, tenantId, name, description }) => {
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
}
const trimmed = name.trim()
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a categoria")
}
const now = Date.now()
await ctx.db.patch(categoryId, {
name: trimmed,
description,
updatedAt: now,
})
},
})
export const deleteCategory = mutation({
args: {
categoryId: v.id("ticketCategories"),
tenantId: v.string(),
transferTo: v.optional(v.id("ticketCategories")),
},
handler: async (ctx, { categoryId, tenantId, transferTo }) => {
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
}
if (transferTo) {
const target = await ctx.db.get(transferTo)
if (!target || target.tenantId !== tenantId) {
throw new ConvexError("Categoria de destino inválida")
}
const subs = await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_order", (q) => q.eq("categoryId", categoryId))
.collect()
for (const sub of subs) {
await ctx.db.patch(sub._id, {
categoryId: transferTo,
updatedAt: Date.now(),
})
}
const ticketsToMove = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.collect()
for (const ticket of ticketsToMove) {
await ctx.db.patch(ticket._id, {
categoryId: transferTo,
subcategoryId: undefined,
updatedAt: Date.now(),
})
}
} else {
const ticketsLinked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.first()
if (ticketsLinked) {
throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino")
}
const subs = await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_order", (q) => q.eq("categoryId", categoryId))
.collect()
for (const sub of subs) {
await ctx.db.delete(sub._id)
}
}
await ctx.db.delete(categoryId)
},
})
export const createSubcategory = mutation({
args: {
tenantId: v.string(),
categoryId: v.id("ticketCategories"),
name: v.string(),
},
handler: async (ctx, { tenantId, categoryId, name }) => {
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
}
const trimmed = name.trim()
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a subcategoria")
}
const baseSlug = slugify(trimmed)
const slug = await ensureUniqueSlug(ctx, "ticketSubcategories", tenantId, baseSlug, { categoryId })
const now = Date.now()
const last = await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_order", (q) => q.eq("categoryId", categoryId))
.order("desc")
.first()
const order = (last?.order ?? -1) + 1
const id = await ctx.db.insert("ticketSubcategories", {
tenantId,
categoryId,
name: trimmed,
slug,
order,
createdAt: now,
updatedAt: now,
})
return id
},
})
export const updateSubcategory = mutation({
args: {
tenantId: v.string(),
subcategoryId: v.id("ticketSubcategories"),
name: v.string(),
},
handler: async (ctx, { tenantId, subcategoryId, name }) => {
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada")
}
const trimmed = name.trim()
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a subcategoria")
}
await ctx.db.patch(subcategoryId, {
name: trimmed,
updatedAt: Date.now(),
})
},
})
export const deleteSubcategory = mutation({
args: {
tenantId: v.string(),
subcategoryId: v.id("ticketSubcategories"),
transferTo: v.optional(v.id("ticketSubcategories")),
},
handler: async (ctx, { tenantId, subcategoryId, transferTo }) => {
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada")
}
if (transferTo) {
const target = await ctx.db.get(transferTo)
if (!target || target.tenantId !== tenantId) {
throw new ConvexError("Subcategoria destino inválida")
}
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.collect()
for (const ticket of tickets) {
await ctx.db.patch(ticket._id, {
subcategoryId: transferTo,
updatedAt: Date.now(),
})
}
} else {
const linked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.first()
if (linked) {
throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino")
}
}
await ctx.db.delete(subcategoryId)
},
})