550 lines
15 KiB
TypeScript
550 lines
15 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"
|
|
|
|
import { requireAdmin } from "./rbac"
|
|
|
|
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(),
|
|
actorId: v.id("users"),
|
|
name: v.string(),
|
|
description: v.optional(v.string()),
|
|
secondary: v.optional(v.array(v.string())),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, name, description, secondary }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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,
|
|
})
|
|
|
|
if (secondary?.length) {
|
|
let subOrder = 0
|
|
for (const item of secondary) {
|
|
const value = item.trim()
|
|
if (value.length < 2) continue
|
|
const subSlug = await ensureUniqueSlug(
|
|
ctx,
|
|
"ticketSubcategories",
|
|
tenantId,
|
|
slugify(value),
|
|
{ categoryId: id }
|
|
)
|
|
await ctx.db.insert("ticketSubcategories", {
|
|
tenantId,
|
|
categoryId: id,
|
|
name: value,
|
|
slug: subSlug,
|
|
order: subOrder,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
subOrder += 1
|
|
}
|
|
}
|
|
return id
|
|
},
|
|
})
|
|
|
|
export const updateCategory = mutation({
|
|
args: {
|
|
categoryId: v.id("ticketCategories"),
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
name: v.string(),
|
|
description: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { categoryId, tenantId, actorId, name, description }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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(),
|
|
actorId: v.id("users"),
|
|
transferTo: v.optional(v.id("ticketCategories")),
|
|
},
|
|
handler: async (ctx, { categoryId, tenantId, actorId, transferTo }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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(),
|
|
actorId: v.id("users"),
|
|
categoryId: v.id("ticketCategories"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, categoryId, name }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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(),
|
|
actorId: v.id("users"),
|
|
subcategoryId: v.id("ticketSubcategories"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, subcategoryId, name }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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(),
|
|
actorId: v.id("users"),
|
|
subcategoryId: v.id("ticketSubcategories"),
|
|
transferTo: v.optional(v.id("ticketSubcategories")),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, subcategoryId, transferTo }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
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)
|
|
},
|
|
})
|