feat: add ticket category model and align ticket ui\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
55511f3a0e
commit
fab1cbe476
17 changed files with 1121 additions and 42 deletions
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import type * as bootstrap from "../bootstrap.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as seed from "../seed.js";
|
||||
|
|
@ -31,6 +32,7 @@ import type {
|
|||
*/
|
||||
declare const fullApi: ApiFromModules<{
|
||||
bootstrap: typeof bootstrap;
|
||||
categories: typeof categories;
|
||||
files: typeof files;
|
||||
queues: typeof queues;
|
||||
seed: typeof seed;
|
||||
|
|
|
|||
510
web/convex/categories.ts
Normal file
510
web/convex/categories.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
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)
|
||||
},
|
||||
})
|
||||
|
|
@ -46,6 +46,8 @@ export default defineSchema({
|
|||
priority: v.string(),
|
||||
channel: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
categoryId: v.optional(v.id("ticketCategories")),
|
||||
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
||||
requesterId: v.id("users"),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
working: v.optional(v.boolean()),
|
||||
|
|
@ -101,4 +103,30 @@ export default defineSchema({
|
|||
})
|
||||
.index("by_ticket", ["ticketId"])
|
||||
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
||||
|
||||
ticketCategories: defineTable({
|
||||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
order: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
ticketSubcategories: defineTable({
|
||||
tenantId: v.string(),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
order: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_category_order", ["categoryId", "order"])
|
||||
.index("by_category_slug", ["categoryId", "slug"])
|
||||
.index("by_tenant_slug", ["tenantId", "slug"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export const list = query({
|
|||
);
|
||||
}
|
||||
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
|
||||
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
|
||||
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
|
||||
// hydrate requester and assignee
|
||||
const result = await Promise.all(
|
||||
limited.map(async (t) => {
|
||||
|
|
@ -54,6 +56,26 @@ export const list = query({
|
|||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
||||
let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null;
|
||||
if (t.categoryId) {
|
||||
if (!categoryCache.has(t.categoryId)) {
|
||||
categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId));
|
||||
}
|
||||
const category = categoryCache.get(t.categoryId);
|
||||
if (category) {
|
||||
categorySummary = { id: category._id, name: category.name };
|
||||
}
|
||||
}
|
||||
if (t.subcategoryId) {
|
||||
if (!subcategoryCache.has(t.subcategoryId)) {
|
||||
subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId));
|
||||
}
|
||||
const subcategory = subcategoryCache.get(t.subcategoryId);
|
||||
if (subcategory) {
|
||||
subcategorySummary = { id: subcategory._id, name: subcategory.name };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: t._id,
|
||||
reference: t.reference,
|
||||
|
|
@ -89,6 +111,8 @@ export const list = query({
|
|||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
category: categorySummary,
|
||||
subcategory: subcategorySummary,
|
||||
workSummary: {
|
||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||
activeSession: activeSession
|
||||
|
|
@ -115,6 +139,8 @@ export const getById = query({
|
|||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
||||
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
|
|
@ -191,6 +217,19 @@ export const getById = query({
|
|||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
category: category
|
||||
? {
|
||||
id: category._id,
|
||||
name: category.name,
|
||||
}
|
||||
: null,
|
||||
subcategory: subcategory
|
||||
? {
|
||||
id: subcategory._id,
|
||||
name: subcategory.name,
|
||||
categoryId: subcategory.categoryId,
|
||||
}
|
||||
: null,
|
||||
workSummary: {
|
||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||
activeSession: activeSession
|
||||
|
|
@ -223,12 +262,22 @@ export const create = mutation({
|
|||
channel: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
requesterId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
subcategoryId: v.id("ticketSubcategories"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const subject = args.subject.trim();
|
||||
if (subject.length < 3) {
|
||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||
}
|
||||
const category = await ctx.db.get(args.categoryId);
|
||||
if (!category || category.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Categoria inválida");
|
||||
}
|
||||
const subcategory = await ctx.db.get(args.subcategoryId);
|
||||
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Subcategoria inválida");
|
||||
}
|
||||
// compute next reference (simple monotonic counter per tenant)
|
||||
const existing = await ctx.db
|
||||
.query("tickets")
|
||||
|
|
@ -246,6 +295,8 @@ export const create = mutation({
|
|||
priority: args.priority,
|
||||
channel: args.channel,
|
||||
queueId: args.queueId,
|
||||
categoryId: args.categoryId,
|
||||
subcategoryId: args.subcategoryId,
|
||||
requesterId: args.requesterId,
|
||||
assigneeId: undefined,
|
||||
working: false,
|
||||
|
|
@ -409,6 +460,58 @@ export const changeQueue = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const updateCategories = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
subcategoryId: v.id("ticketSubcategories"),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Categoria inválida")
|
||||
}
|
||||
const subcategory = await ctx.db.get(subcategoryId)
|
||||
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Subcategoria inválida")
|
||||
}
|
||||
|
||||
if (ticket.categoryId === categoryId && ticket.subcategoryId === subcategoryId) {
|
||||
return { status: "unchanged" }
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(ticketId, {
|
||||
categoryId,
|
||||
subcategoryId,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "CATEGORY_CHANGED",
|
||||
payload: {
|
||||
categoryId,
|
||||
categoryName: category.name,
|
||||
subcategoryId,
|
||||
subcategoryName: subcategory.name,
|
||||
actorId,
|
||||
actorName: actor?.name,
|
||||
actorAvatar: actor?.avatarUrl,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { status: "updated" }
|
||||
},
|
||||
})
|
||||
|
||||
export const workSummary = query({
|
||||
args: { ticketId: v.id("tickets") },
|
||||
handler: async (ctx, { ticketId }) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue