chore: reorganize project structure and ensure default queues

This commit is contained in:
Esdras Renan 2025-10-06 22:59:35 -03:00
parent 854887f499
commit 1cccb852a5
201 changed files with 417 additions and 838 deletions

90
convex/README.md Normal file
View file

@ -0,0 +1,90 @@
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// convex/myFunctions.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.myFunctions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// convex/myFunctions.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.myFunctions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.

69
convex/_generated/api.d.ts vendored Normal file
View file

@ -0,0 +1,69 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as invites from "../invites.js";
import type * as migrations from "../migrations.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap;
categories: typeof categories;
commentTemplates: typeof commentTemplates;
fields: typeof fields;
files: typeof files;
invites: typeof invites;
migrations: typeof migrations;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
tickets: typeof tickets;
users: typeof users;
}>;
declare const fullApiWithMounts: typeof fullApi;
export declare const api: FilterApi<
typeof fullApiWithMounts,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApiWithMounts,
FunctionReference<any, "internal">
>;
export declare const components: {};

23
convex/_generated/api.js Normal file
View file

@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

60
convex/_generated/dataModel.d.ts vendored Normal file
View file

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

149
convex/_generated/server.d.ts vendored Normal file
View file

@ -0,0 +1,149 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
AnyComponents,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
FunctionReference,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -0,0 +1,90 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
componentsGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;

36
convex/bootstrap.ts Normal file
View file

@ -0,0 +1,36 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const ensureDefaults = mutation({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
let existing = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
existing = await Promise.all(
existing.map(async (queue) => {
if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") {
await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" });
return (await ctx.db.get(queue._id)) ?? queue;
}
if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") {
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
return (await ctx.db.get(queue._id)) ?? queue;
}
return queue;
})
);
if (existing.length === 0) {
const queues = [
{ name: "Chamados", slug: "chamados" },
{ name: "Laboratório", slug: "laboratorio" },
{ name: "Field Services", slug: "field-services" },
];
for (const q of queues) {
await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined });
}
}
},
});

550
convex/categories.ts Normal file
View file

@ -0,0 +1,550 @@
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)
},
})

173
convex/commentTemplates.ts Normal file
View file

@ -0,0 +1,173 @@
import sanitizeHtml from "sanitize-html"
import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import { requireStaff } from "./rbac"
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
"p",
"br",
"a",
"strong",
"em",
"u",
"s",
"blockquote",
"ul",
"ol",
"li",
"code",
"pre",
"span",
"h1",
"h2",
"h3",
],
allowedAttributes: {
a: ["href", "name", "target", "rel"],
span: ["style"],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http", "https", "mailto"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
},
allowVulnerableTags: false,
}
function sanitizeTemplateBody(body: string) {
const sanitized = sanitizeHtml(body || "", SANITIZE_OPTIONS).trim()
return sanitized
}
function normalizeTitle(title: string) {
return title?.trim()
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return templates
.sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" }))
.map((template) => ({
id: template._id,
title: template.title,
body: template.body,
createdAt: template.createdAt,
updatedAt: template.updatedAt,
createdBy: template.createdBy,
updatedBy: template.updatedBy ?? null,
}))
},
})
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
title: v.string(),
body: v.string(),
},
handler: async (ctx, { tenantId, actorId, title, body }) => {
await requireStaff(ctx, actorId, tenantId)
const normalizedTitle = normalizeTitle(title)
if (!normalizedTitle || normalizedTitle.length < 3) {
throw new ConvexError("Informe um título válido para o template")
}
const sanitizedBody = sanitizeTemplateBody(body)
if (!sanitizedBody) {
throw new ConvexError("Informe o conteúdo do template")
}
const existing = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
.first()
if (existing) {
throw new ConvexError("Já existe um template com este título")
}
const now = Date.now()
const id = await ctx.db.insert("commentTemplates", {
tenantId,
title: normalizedTitle,
body: sanitizedBody,
createdBy: actorId,
updatedBy: actorId,
createdAt: now,
updatedAt: now,
})
return id
},
})
export const update = mutation({
args: {
templateId: v.id("commentTemplates"),
tenantId: v.string(),
actorId: v.id("users"),
title: v.string(),
body: v.string(),
},
handler: async (ctx, { templateId, tenantId, actorId, title, body }) => {
await requireStaff(ctx, actorId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
throw new ConvexError("Template não encontrado")
}
const normalizedTitle = normalizeTitle(title)
if (!normalizedTitle || normalizedTitle.length < 3) {
throw new ConvexError("Informe um título válido para o template")
}
const sanitizedBody = sanitizeTemplateBody(body)
if (!sanitizedBody) {
throw new ConvexError("Informe o conteúdo do template")
}
const duplicate = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
.first()
if (duplicate && duplicate._id !== templateId) {
throw new ConvexError("Já existe um template com este título")
}
const now = Date.now()
await ctx.db.patch(templateId, {
title: normalizedTitle,
body: sanitizedBody,
updatedBy: actorId,
updatedAt: now,
})
},
})
export const remove = mutation({
args: {
templateId: v.id("commentTemplates"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { templateId, tenantId, actorId }) => {
await requireStaff(ctx, actorId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
throw new ConvexError("Template não encontrado")
}
await ctx.db.delete(templateId)
},
})

9
convex/convex.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineApp } from "convex/server";
const app = defineApp();
// You can install Convex Components here in the future, e.g. rate limiter, workflows, etc.
// app.use(componentConfig)
export default app;

233
convex/fields.ts Normal file
View file

@ -0,0 +1,233 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireAdmin, requireUser } from "./rbac";
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
type FieldType = (typeof FIELD_TYPES)[number];
function normalizeKey(label: string) {
return label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "_")
.replace(/_+/g, "_");
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) {
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um campo com este identificador");
}
}
function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) {
if (type === "select" && (!options || options.length === 0)) {
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
return fields
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: field.required,
options: field.options ?? [],
order: field.order,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
}));
},
});
export const listForTenant = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireUser(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
return fields
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: field.required,
options: field.options ?? [],
order: field.order,
}));
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.boolean(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
},
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo");
}
if (!FIELD_TYPES.includes(type as FieldType)) {
throw new ConvexError("Tipo de campo inválido");
}
validateOptions(type as FieldType, options ?? undefined);
const key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key);
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0);
const now = Date.now();
const id = await ctx.db.insert("ticketFields", {
tenantId,
key,
label: normalizedLabel,
description,
type,
required,
options,
order: maxOrder + 1,
createdAt: now,
updatedAt: now,
});
return id;
},
});
export const update = mutation({
args: {
tenantId: v.string(),
fieldId: v.id("ticketFields"),
actorId: v.id("users"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.boolean(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
},
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo não encontrado");
}
if (!FIELD_TYPES.includes(type as FieldType)) {
throw new ConvexError("Tipo de campo inválido");
}
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo");
}
validateOptions(type as FieldType, options ?? undefined);
let key = field.key;
if (field.label !== normalizedLabel) {
key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key, fieldId);
}
await ctx.db.patch(fieldId, {
key,
label: normalizedLabel,
description,
type,
required,
options,
updatedAt: Date.now(),
});
},
});
export const remove = mutation({
args: {
tenantId: v.string(),
fieldId: v.id("ticketFields"),
actorId: v.id("users"),
},
handler: async (ctx, { tenantId, fieldId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo não encontrado");
}
await ctx.db.delete(fieldId);
},
});
export const reorder = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
orderedIds: v.array(v.id("ticketFields")),
},
handler: async (ctx, { tenantId, actorId, orderedIds }) => {
await requireAdmin(ctx, actorId, tenantId);
const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id)));
fields.forEach((field) => {
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo inválido para reordenação");
}
});
const now = Date.now();
await Promise.all(
orderedIds.map((fieldId, index) =>
ctx.db.patch(fieldId, {
order: index + 1,
updatedAt: now,
})
)
);
},
});

18
convex/files.ts Normal file
View file

@ -0,0 +1,18 @@
import { action } from "./_generated/server";
import { v } from "convex/values";
export const generateUploadUrl = action({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
export const getUrl = action({
args: { storageId: v.id("_storage") },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);
return url;
},
});

115
convex/invites.ts Normal file
View file

@ -0,0 +1,115 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { requireAdmin } from "./rbac";
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const invites = await ctx.db
.query("userInvites")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return invites
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
.map((invite) => ({
id: invite._id,
inviteId: invite.inviteId,
email: invite.email,
name: invite.name ?? null,
role: invite.role,
status: invite.status,
token: invite.token,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
createdById: invite.createdById ?? null,
acceptedAt: invite.acceptedAt ?? null,
acceptedById: invite.acceptedById ?? null,
revokedAt: invite.revokedAt ?? null,
revokedById: invite.revokedById ?? null,
revokedReason: invite.revokedReason ?? null,
}));
},
});
export const sync = mutation({
args: {
tenantId: v.string(),
inviteId: v.string(),
email: v.string(),
name: v.optional(v.string()),
role: v.string(),
status: v.string(),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
createdById: v.optional(v.string()),
acceptedAt: v.optional(v.number()),
acceptedById: v.optional(v.string()),
revokedAt: v.optional(v.number()),
revokedById: v.optional(v.string()),
revokedReason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("userInvites")
.withIndex("by_invite", (q) => q.eq("tenantId", args.tenantId).eq("inviteId", args.inviteId))
.first();
if (!existing) {
const id = await ctx.db.insert("userInvites", {
tenantId: args.tenantId,
inviteId: args.inviteId,
email: args.email,
name: args.name,
role: args.role,
status: args.status,
token: args.token,
expiresAt: args.expiresAt,
createdAt: args.createdAt,
createdById: args.createdById,
acceptedAt: args.acceptedAt,
acceptedById: args.acceptedById,
revokedAt: args.revokedAt,
revokedById: args.revokedById,
revokedReason: args.revokedReason,
});
return await ctx.db.get(id);
}
await ctx.db.patch(existing._id, {
email: args.email,
name: args.name,
role: args.role,
status: args.status,
token: args.token,
expiresAt: args.expiresAt,
createdAt: args.createdAt,
createdById: args.createdById,
acceptedAt: args.acceptedAt,
acceptedById: args.acceptedById,
revokedAt: args.revokedAt,
revokedById: args.revokedById,
revokedReason: args.revokedReason,
});
return await ctx.db.get(existing._id);
},
});
export const remove = mutation({
args: { tenantId: v.string(), inviteId: v.string() },
handler: async (ctx, { tenantId, inviteId }) => {
const existing = await ctx.db
.query("userInvites")
.withIndex("by_invite", (q) => q.eq("tenantId", tenantId).eq("inviteId", inviteId))
.first();
if (existing) {
await ctx.db.delete(existing._id);
}
},
});

619
convex/migrations.ts Normal file
View file

@ -0,0 +1,619 @@
import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
function normalizeEmail(value: string) {
return value.trim().toLowerCase()
}
type ImportedUser = {
email: string
name: string
role?: string | null
avatarUrl?: string | null
teams?: string[] | null
companySlug?: string | null
}
type ImportedQueue = {
slug?: string | null
name: string
}
type ImportedCompany = {
slug: string
name: string
cnpj?: string | null
domain?: string | null
phone?: string | null
description?: string | null
address?: string | null
createdAt?: number | null
updatedAt?: number | null
}
function normalizeRole(role: string | null | undefined) {
if (!role) return "AGENT"
const normalized = role.toUpperCase()
if (VALID_ROLES.has(normalized)) return normalized
return "AGENT"
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase()
}
function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
for (const key of Object.keys(input)) {
if (input[key] === undefined) {
delete input[key]
}
}
return input
}
async function ensureUser(
ctx: MutationCtx,
tenantId: string,
data: ImportedUser,
cache: Map<string, Id<"users">>,
companyCache: Map<string, Id<"companies">>
) {
if (cache.has(data.email)) {
return cache.get(data.email)!
}
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email))
.first()
const role = normalizeRole(data.role)
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
const record = existing
? (() => {
const needsPatch =
existing.name !== data.name ||
existing.role !== role ||
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
(existing.companyId ?? undefined) !== companyId
if (needsPatch) {
return ctx.db.patch(existing._id, {
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
tenantId,
companyId,
})
}
return Promise.resolve()
})()
: ctx.db.insert("users", {
tenantId,
email: data.email,
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
companyId,
})
const id = existing ? existing._id : ((await record) as Id<"users">)
cache.set(data.email, id)
return id
}
async function ensureQueue(
ctx: MutationCtx,
tenantId: string,
data: ImportedQueue,
cache: Map<string, Id<"queues">>
) {
const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name)
if (cache.has(slug)) return cache.get(slug)!
const bySlug = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (bySlug) {
if (bySlug.name !== data.name) {
await ctx.db.patch(bySlug._id, { name: data.name })
}
cache.set(slug, bySlug._id)
return bySlug._id
}
const byName = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), data.name))
.first()
if (byName) {
if (byName.slug !== slug) {
await ctx.db.patch(byName._id, { slug })
}
cache.set(slug, byName._id)
return byName._id
}
const id = await ctx.db.insert("queues", {
tenantId,
name: data.name,
slug,
teamId: undefined,
})
cache.set(slug, id)
return id
}
async function ensureCompany(
ctx: MutationCtx,
tenantId: string,
data: ImportedCompany,
cache: Map<string, Id<"companies">>
) {
const slug = data.slug || slugify(data.name)
if (cache.has(slug)) {
return cache.get(slug)!
}
const existing = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
const payload = pruneUndefined({
tenantId,
name: data.name,
slug,
cnpj: data.cnpj ?? undefined,
domain: data.domain ?? undefined,
phone: data.phone ?? undefined,
description: data.description ?? undefined,
address: data.address ?? undefined,
createdAt: data.createdAt ?? Date.now(),
updatedAt: data.updatedAt ?? Date.now(),
})
let id: Id<"companies">
if (existing) {
const needsPatch =
existing.name !== payload.name ||
existing.cnpj !== (payload.cnpj ?? undefined) ||
existing.domain !== (payload.domain ?? undefined) ||
existing.phone !== (payload.phone ?? undefined) ||
existing.description !== (payload.description ?? undefined) ||
existing.address !== (payload.address ?? undefined)
if (needsPatch) {
await ctx.db.patch(existing._id, {
name: payload.name,
cnpj: payload.cnpj,
domain: payload.domain,
phone: payload.phone,
description: payload.description,
address: payload.address,
updatedAt: Date.now(),
})
}
id = existing._id
} else {
id = await ctx.db.insert("companies", payload)
}
cache.set(slug, id)
return id
}
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
async function getTenantCompanies(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
export const exportTenantSnapshot = query({
args: {
secret: v.string(),
tenantId: v.string(),
},
handler: async (ctx, { secret, tenantId }) => {
if (secret !== SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
const [users, queues, companies] = await Promise.all([
getTenantUsers(ctx, tenantId),
getTenantQueues(ctx, tenantId),
getTenantCompanies(ctx, tenantId),
])
const userMap = new Map(users.map((user) => [user._id, user]))
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
const companyMap = new Map(companies.map((company) => [company._id, company]))
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const ticketsWithRelations = []
for (const ticket of tickets) {
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const requester = userMap.get(ticket.requesterId)
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
const company = ticket.companyId
? companyMap.get(ticket.companyId)
: requester?.companyId
? companyMap.get(requester.companyId)
: undefined
if (!requester) {
continue
}
ticketsWithRelations.push({
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? null,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueSlug: queue?.slug ?? undefined,
requesterEmail: requester.email,
assigneeEmail: assignee?.email ?? undefined,
companySlug: company?.slug ?? undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
tags: ticket.tags ?? [],
comments: comments
.map((comment) => {
const author = userMap.get(comment.authorId)
if (!author) {
return null
}
return {
authorEmail: author.email,
visibility: comment.visibility,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}
})
.filter((value): value is {
authorEmail: string
visibility: string
body: string
createdAt: number
updatedAt: number
} => value !== null),
events: events.map((event) => ({
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})),
})
}
return {
tenantId,
companies: companies.map((company) => ({
slug: company.slug,
name: company.name,
cnpj: company.cnpj ?? null,
domain: company.domain ?? null,
phone: company.phone ?? null,
description: company.description ?? null,
address: company.address ?? null,
createdAt: company.createdAt,
updatedAt: company.updatedAt,
})),
users: users.map((user) => ({
email: user.email,
name: user.name,
role: user.role ?? null,
avatarUrl: user.avatarUrl ?? null,
teams: user.teams ?? [],
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
})),
queues: queues.map((queue) => ({
name: queue.name,
slug: queue.slug,
})),
tickets: ticketsWithRelations,
}
},
})
export const importPrismaSnapshot = mutation({
args: {
secret: v.string(),
snapshot: v.object({
tenantId: v.string(),
companies: v.array(
v.object({
slug: v.string(),
name: v.string(),
cnpj: v.optional(v.string()),
domain: v.optional(v.string()),
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
),
users: v.array(
v.object({
email: v.string(),
name: v.string(),
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companySlug: v.optional(v.string()),
})
),
queues: v.array(
v.object({
name: v.string(),
slug: v.optional(v.string()),
})
),
tickets: v.array(
v.object({
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
queueSlug: v.optional(v.string()),
requesterEmail: v.string(),
assigneeEmail: v.optional(v.string()),
companySlug: v.optional(v.string()),
dueAt: v.optional(v.number()),
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
tags: v.optional(v.array(v.string())),
comments: v.array(
v.object({
authorEmail: v.string(),
visibility: v.string(),
body: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
),
events: v.array(
v.object({
type: v.string(),
payload: v.optional(v.any()),
createdAt: v.number(),
})
),
})
),
}),
},
handler: async (ctx, { secret, snapshot }) => {
if (!SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
if (secret !== SECRET) {
throw new ConvexError("Segredo inválido para sincronização")
}
const companyCache = new Map<string, Id<"companies">>()
const userCache = new Map<string, Id<"users">>()
const queueCache = new Map<string, Id<"queues">>()
for (const company of snapshot.companies) {
await ensureCompany(ctx, snapshot.tenantId, company, companyCache)
}
for (const user of snapshot.users) {
await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
}
for (const queue of snapshot.queues) {
await ensureQueue(ctx, snapshot.tenantId, queue, queueCache)
}
const snapshotStaffEmails = new Set(
snapshot.users
.filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
.map((user) => normalizeEmail(user.email))
)
const existingTenantUsers = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId))
.collect()
for (const user of existingTenantUsers) {
const role = normalizeRole(user.role ?? null)
if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
await ctx.db.delete(user._id)
}
}
let ticketsUpserted = 0
let commentsInserted = 0
let eventsInserted = 0
for (const ticket of snapshot.tickets) {
const requesterId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.requesterEmail,
name: ticket.requesterEmail,
},
userCache,
companyCache
)
const assigneeId = ticket.assigneeEmail
? await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.assigneeEmail,
name: ticket.assigneeEmail,
},
userCache,
companyCache
)
: undefined
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined
const existing = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference))
.first()
const payload = pruneUndefined({
tenantId: snapshot.tenantId,
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? undefined,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueId: queueId as Id<"queues"> | undefined,
categoryId: undefined,
subcategoryId: undefined,
requesterId,
assigneeId: assigneeId as Id<"users"> | undefined,
working: false,
slaPolicyId: undefined,
companyId: companyId as Id<"companies"> | undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt,
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
customFields: undefined,
totalWorkedMs: undefined,
activeSessionId: undefined,
})
let ticketId: Id<"tickets">
if (existing) {
await ctx.db.patch(existing._id, payload)
ticketId = existing._id
} else {
ticketId = await ctx.db.insert("tickets", payload)
}
ticketsUpserted += 1
const existingComments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const comment of existingComments) {
await ctx.db.delete(comment._id)
}
const existingEvents = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const event of existingEvents) {
await ctx.db.delete(event._id)
}
for (const comment of ticket.comments) {
const authorId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: comment.authorEmail,
name: comment.authorEmail,
},
userCache,
companyCache
)
await ctx.db.insert("ticketComments", {
ticketId,
authorId,
visibility: comment.visibility,
body: comment.body,
attachments: [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
})
commentsInserted += 1
}
for (const event of ticket.events) {
await ctx.db.insert("ticketEvents", {
ticketId,
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})
eventsInserted += 1
}
}
return {
usersProcessed: userCache.size,
queuesProcessed: queueCache.size,
ticketsUpserted,
commentsInserted,
eventsInserted,
}
},
})

204
convex/queues.ts Normal file
View file

@ -0,0 +1,204 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { requireAdmin, requireStaff } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
chamados: "Chamados",
"Suporte N2": "Laboratório",
"suporte-n2": "Laboratório",
laboratorio: "Laboratório",
Laboratorio: "Laboratório",
visitas: "Visitas",
};
function renameQueueString(value: string) {
const direct = QUEUE_RENAME_LOOKUP[value];
if (direct) return direct;
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este identificador");
}
}
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
return queues.map((queue) => {
const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null;
return {
id: queue._id,
name: queue.name,
slug: queue.slug,
team: team
? {
id: team._id,
name: team.name,
}
: null,
};
});
},
});
export const summary = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
const result = await Promise.all(
queues.map(async (qItem) => {
const pending = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
.collect();
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
const breached = 0;
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
})
);
return result;
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed);
const slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug);
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
const id = await ctx.db.insert("queues", {
tenantId,
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
return id;
},
});
export const update = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed, queueId);
let slug = queue.slug;
if (queue.name !== trimmed) {
slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug, queueId);
}
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
await ctx.db.patch(queueId, {
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
},
});
export const remove = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { queueId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const ticketUsingQueue = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.first();
if (ticketUsingQueue) {
throw new ConvexError("Não é possível remover uma fila vinculada a tickets");
}
await ctx.db.delete(queueId);
},
});

81
convex/rbac.ts Normal file
View file

@ -0,0 +1,81 @@
import { ConvexError } from "convex/values"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const CUSTOMER_ROLE = "CUSTOMER"
const MANAGER_ROLE = "MANAGER"
type Ctx = QueryCtx | MutationCtx
function normalizeRole(role?: string | null) {
return role?.toUpperCase() ?? null
}
async function getUser(ctx: Ctx, userId: Id<"users">) {
const user = await ctx.db.get(userId)
if (!user) {
throw new ConvexError("Usuário não encontrado")
}
return user
}
export async function requireUser(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const user = await getUser(ctx, userId)
if (tenantId && user.tenantId !== tenantId) {
throw new ConvexError("Usuário não pertence a este tenant")
}
return { user, role: normalizeRole(user.role) }
}
export async function requireStaff(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (!result.role || !STAFF_ROLES.has(result.role)) {
throw new ConvexError("Acesso restrito à equipe interna")
}
return result
}
export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireStaff(ctx, userId, tenantId)
if (result.role !== "ADMIN") {
throw new ConvexError("Apenas administradores podem executar esta ação")
}
return result
}
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (result.role !== CUSTOMER_ROLE) {
throw new ConvexError("Acesso restrito ao portal do cliente")
}
return result
}
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (result.role !== MANAGER_ROLE) {
throw new ConvexError("Apenas gestores da empresa podem executar esta ação")
}
if (!result.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
return result
}
export async function requireCompanyAssociation(
ctx: Ctx,
userId: Id<"users">,
companyId: Id<"companies">,
tenantId?: string,
) {
const result = await requireUser(ctx, userId, tenantId)
if (!result.user.companyId) {
throw new ConvexError("Usuário não possui empresa vinculada")
}
if (result.user.companyId !== companyId) {
throw new ConvexError("Usuário não pertence a esta empresa")
}
return result
}

360
convex/reports.ts Normal file
View file

@ -0,0 +1,360 @@
import { query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac";
function average(values: number[]) {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
const OPEN_STATUSES = new Set(["NEW", "OPEN", "PENDING", "ON_HOLD"]);
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function percentageChange(current: number, previous: number) {
if (previous === 0) {
return current === 0 ? 0 : null;
}
return ((current - previous) / previous) * 100;
}
function extractScore(payload: unknown): number | null {
if (typeof payload === "number") return payload;
if (payload && typeof payload === "object" && "score" in payload) {
const value = (payload as { score: unknown }).score;
if (typeof value === "number") {
return value;
}
}
return null;
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
async function fetchTickets(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
}
async function fetchScopedTickets(
ctx: QueryCtx,
tenantId: string,
viewer: Awaited<ReturnType<typeof requireStaff>>,
) {
if (viewer.role === "MANAGER") {
if (!viewer.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!)
)
.collect();
}
return fetchTickets(ctx, tenantId);
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
}
type CsatSurvey = {
ticketId: Id<"tickets">;
reference: number;
score: number;
receivedAt: number;
};
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
const perTicket = await Promise.all(
tickets.map(async (ticket) => {
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect();
return events
.filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED")
.map((event) => {
const score = extractScore(event.payload);
if (score === null) return null;
return {
ticketId: ticket._id,
reference: ticket.reference,
score,
receivedAt: event.createdAt,
} as CsatSurvey;
})
.filter(isNotNull);
})
);
return perTicket.flat();
}
function formatDateKey(timestamp: number) {
const date = new Date(timestamp);
const year = date.getUTCFullYear();
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const queues = await fetchQueues(ctx, tenantId);
const now = Date.now();
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED");
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const firstResponseTimes = tickets
.filter((ticket) => ticket.firstResponseAt)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const resolutionTimes = resolvedTickets
.filter((ticket) => ticket.resolvedAt)
.map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000);
const queueBreakdown = queues.map((queue) => {
const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length;
return {
id: queue._id,
name: queue.name,
open: count,
};
});
return {
totals: {
total: tickets.length,
open: openTickets.length,
resolved: resolvedTickets.length,
overdue: overdueTickets.length,
},
response: {
averageFirstResponseMinutes: average(firstResponseTimes),
responsesRegistered: firstResponseTimes.length,
},
resolution: {
averageResolutionMinutes: average(resolutionTimes),
resolvedCount: resolutionTimes.length,
},
queueBreakdown,
};
},
});
export const csatOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score));
const distribution = [1, 2, 3, 4, 5].map((score) => ({
score,
total: surveys.filter((item) => item.score === score).length,
}));
return {
totalSurveys: surveys.length,
averageScore,
distribution,
recent: surveys
.slice()
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, 10)
.map((item) => ({
ticketId: item.ticketId,
reference: item.reference,
score: item.score,
receivedAt: item.receivedAt,
})),
};
},
});
export const backlogOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
return acc;
}, {});
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
return acc;
}, {});
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const queueMap = new Map<string, { name: string; count: number }>();
for (const ticket of openTickets) {
const queueId = ticket.queueId ? ticket.queueId : "sem-fila";
const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 };
current.count += 1;
queueMap.set(queueId, current);
}
const queues = await fetchQueues(ctx, tenantId);
for (const queue of queues) {
const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 };
entry.name = queue.name;
queueMap.set(queue._id, entry);
}
return {
statusCounts,
priorityCounts,
queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({
id,
name: data.name,
total: data.count,
})),
totalOpen: openTickets.length,
};
},
});
export const dashboardOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const now = Date.now();
const lastDayStart = now - ONE_DAY_MS;
const previousDayStart = now - 2 * ONE_DAY_MS;
const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart);
const previousTickets = tickets.filter(
(ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart
);
const trend = percentageChange(newTickets.length, previousTickets.length);
const lastWindowStart = now - 7 * ONE_DAY_MS;
const previousWindowStart = now - 14 * ONE_DAY_MS;
const firstResponseWindow = tickets
.filter(
(ticket) =>
ticket.createdAt >= lastWindowStart &&
ticket.createdAt < now &&
ticket.firstResponseAt
)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const firstResponsePrevious = tickets
.filter(
(ticket) =>
ticket.createdAt >= previousWindowStart &&
ticket.createdAt < lastWindowStart &&
ticket.firstResponseAt
)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const averageWindow = average(firstResponseWindow);
const averagePrevious = average(firstResponsePrevious);
const deltaMinutes =
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score));
return {
newTickets: {
last24h: newTickets.length,
previous24h: previousTickets.length,
trendPercentage: trend,
},
firstResponse: {
averageMinutes: averageWindow,
previousAverageMinutes: averagePrevious,
deltaMinutes,
responsesCount: firstResponseWindow.length,
},
awaitingAction: {
total: awaitingTickets.length,
atRisk: atRiskTickets.length,
},
csat: {
averageScore,
totalSurveys: surveys.length,
},
};
},
});
export const ticketsByChannel = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, range }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * ONE_DAY_MS;
const timeline = new Map<string, Map<string, number>>();
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
timeline.set(formatDateKey(ts), new Map());
}
const channels = new Set<string>();
for (const ticket of tickets) {
if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue;
const dateKey = formatDateKey(ticket.createdAt);
const channelKey = ticket.channel ?? "OUTRO";
channels.add(channelKey);
const dayMap = timeline.get(dateKey) ?? new Map<string, number>();
dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1);
timeline.set(dateKey, dayMap);
}
const sortedChannels = Array.from(channels).sort();
const points = Array.from(timeline.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, map]) => {
const values: Record<string, number> = {};
for (const channel of sortedChannels) {
values[channel] = map.get(channel) ?? 0;
}
return { date, values };
});
return {
rangeDays: days,
channels: sortedChannels,
points,
};
},
});

220
convex/schema.ts Normal file
View file

@ -0,0 +1,220 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
tenantId: v.string(),
name: v.string(),
email: v.string(),
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
})
.index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"])
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"]),
companies: defineTable({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
cnpj: v.optional(v.string()),
domain: v.optional(v.string()),
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]),
queues: defineTable({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
teamId: v.optional(v.id("teams")),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]),
teams: defineTable({
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
}).index("by_tenant_name", ["tenantId", "name"]),
slaPolicies: defineTable({
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), // minutes
timeToResolution: v.optional(v.number()), // minutes
}).index("by_tenant_name", ["tenantId", "name"]),
tickets: defineTable({
tenantId: v.string(),
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
description: v.optional(v.string()),
status: v.string(),
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")),
companyId: v.optional(v.id("companies")),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),
updatedAt: v.number(),
createdAt: v.number(),
tags: v.optional(v.array(v.string())),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
fieldKey: v.string(),
label: v.string(),
type: v.string(),
value: v.any(),
displayValue: v.optional(v.string()),
})
)
),
totalWorkedMs: v.optional(v.number()),
activeSessionId: v.optional(v.id("ticketWorkSessions")),
})
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant", ["tenantId"]),
ticketComments: defineTable({
ticketId: v.id("tickets"),
authorId: v.id("users"),
visibility: v.string(), // PUBLIC | INTERNAL
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_ticket", ["ticketId"]),
ticketEvents: defineTable({
ticketId: v.id("tickets"),
type: v.string(),
payload: v.optional(v.any()),
createdAt: v.number(),
}).index("by_ticket", ["ticketId"]),
commentTemplates: defineTable({
tenantId: v.string(),
title: v.string(),
body: v.string(),
createdBy: v.id("users"),
updatedBy: v.optional(v.id("users")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_title", ["tenantId", "title"]),
ticketWorkSessions: defineTable({
ticketId: v.id("tickets"),
agentId: v.id("users"),
startedAt: v.number(),
stoppedAt: v.optional(v.number()),
durationMs: v.optional(v.number()),
})
.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"]),
ticketFields: defineTable({
tenantId: v.string(),
key: v.string(),
label: v.string(),
type: v.string(),
description: v.optional(v.string()),
required: v.boolean(),
order: v.number(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant", ["tenantId"]),
userInvites: defineTable({
tenantId: v.string(),
inviteId: v.string(),
email: v.string(),
name: v.optional(v.string()),
role: v.string(),
status: v.string(),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
createdById: v.optional(v.string()),
acceptedAt: v.optional(v.number()),
acceptedById: v.optional(v.string()),
revokedAt: v.optional(v.number()),
revokedById: v.optional(v.string()),
revokedReason: v.optional(v.string()),
})
.index("by_tenant", ["tenantId"])
.index("by_token", ["tenantId", "token"])
.index("by_invite", ["tenantId", "inviteId"]),
});

390
convex/seed.ts Normal file
View file

@ -0,0 +1,390 @@
import type { Id } from "./_generated/dataModel"
import { mutation } from "./_generated/server"
export const seedDemo = mutation({
args: {},
handler: async (ctx) => {
const tenantId = "tenant-atlas";
const desiredQueues = [
{ name: "Chamados", slug: "chamados" },
{ name: "Laboratório", slug: "laboratorio" },
{ name: "Visitas", slug: "visitas" },
];
// Ensure queues
const existingQueues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const normalizedQueues = await Promise.all(
existingQueues.map(async (queue) => {
if (!queue) return queue;
if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") {
await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" });
return (await ctx.db.get(queue._id)) ?? queue;
}
if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") {
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
return (await ctx.db.get(queue._id)) ?? queue;
}
return queue;
})
);
const presentQueues = normalizedQueues.filter(
(queue): queue is NonNullable<(typeof normalizedQueues)[number]> => Boolean(queue)
);
const queuesBySlug = new Map(presentQueues.map((queue) => [queue.slug, queue]));
const queuesByName = new Map(presentQueues.map((queue) => [queue.name, queue]));
const queues = [] as typeof presentQueues;
for (const def of desiredQueues) {
let queue = queuesBySlug.get(def.slug) ?? queuesByName.get(def.name);
if (!queue) {
const newId = await ctx.db.insert("queues", { tenantId, name: def.name, slug: def.slug, teamId: undefined });
queue = (await ctx.db.get(newId))!;
queuesBySlug.set(queue.slug, queue);
queuesByName.set(queue.name, queue);
}
queues.push(queue);
}
const queueChamados = queuesBySlug.get("chamados");
const queueLaboratorio = queuesBySlug.get("laboratorio");
const queueVisitas = queuesBySlug.get("visitas");
if (!queueChamados || !queueLaboratorio || !queueVisitas) {
throw new Error("Filas padrão não foram inicializadas");
}
// Ensure users
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
function defaultAvatar(name: string, email: string, role: string) {
const normalizedRole = role.toUpperCase();
if (normalizedRole === "CUSTOMER" || normalizedRole === "MANAGER") {
return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`;
}
const first = name.split(" ")[0] ?? email;
return `https://avatar.vercel.sh/${encodeURIComponent(first)}`;
}
async function ensureCompany(def: {
name: string;
slug?: string;
cnpj?: string;
domain?: string;
phone?: string;
description?: string;
address?: string;
}): Promise<Id<"companies">> {
const slug = def.slug ?? slugify(def.name);
const existing = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
const now = Date.now();
const payload = {
tenantId,
name: def.name,
slug,
cnpj: def.cnpj ?? undefined,
domain: def.domain ?? undefined,
phone: def.phone ?? undefined,
description: def.description ?? undefined,
address: def.address ?? undefined,
createdAt: now,
updatedAt: now,
};
if (existing) {
const updates: Record<string, unknown> = {};
if (existing.name !== payload.name) updates.name = payload.name;
if (existing.cnpj !== payload.cnpj) updates.cnpj = payload.cnpj;
if (existing.domain !== payload.domain) updates.domain = payload.domain;
if (existing.phone !== payload.phone) updates.phone = payload.phone;
if (existing.description !== payload.description) updates.description = payload.description;
if (existing.address !== payload.address) updates.address = payload.address;
if (Object.keys(updates).length > 0) {
updates.updatedAt = now;
await ctx.db.patch(existing._id, updates);
}
return existing._id;
}
return await ctx.db.insert("companies", payload);
}
async function ensureUser(params: {
name: string;
email: string;
role?: string;
companyId?: Id<"companies">;
avatarUrl?: string;
}): Promise<Id<"users">> {
const normalizedEmail = params.email.trim().toLowerCase();
const normalizedRole = (params.role ?? "CUSTOMER").toUpperCase();
const desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole);
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedEmail))
.first();
if (existing) {
const updates: Record<string, unknown> = {};
if (existing.name !== params.name) updates.name = params.name;
if ((existing.role ?? "CUSTOMER") !== normalizedRole) updates.role = normalizedRole;
if ((existing.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar;
if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(existing._id, updates);
}
return existing._id;
}
return await ctx.db.insert("users", {
tenantId,
name: params.name,
email: normalizedEmail,
role: normalizedRole,
avatarUrl: desiredAvatar,
companyId: params.companyId ?? undefined,
});
}
const companiesSeed = [
{
name: "Atlas Engenharia Digital",
slug: "atlas-engenharia",
cnpj: "12.345.678/0001-90",
domain: "atlasengenharia.com.br",
phone: "+55 11 4002-8922",
description: "Transformação digital para empresas de engenharia e construção.",
address: "Av. Paulista, 1234 - Bela Vista, São Paulo/SP",
},
{
name: "Omni Saúde Integrada",
slug: "omni-saude",
cnpj: "45.678.912/0001-05",
domain: "omnisaude.com.br",
phone: "+55 31 3555-7788",
description: "Rede de clínicas com serviços de telemedicina e prontuário eletrônico.",
address: "Rua da Bahia, 845 - Centro, Belo Horizonte/MG",
},
];
const companyIds = new Map<string, Id<"companies">>();
for (const company of companiesSeed) {
const id = await ensureCompany(company);
companyIds.set(company.slug, id);
}
const adminId = await ensureUser({ name: "Administrador", email: "admin@sistema.dev", role: "ADMIN" });
const staffRoster = [
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
{ name: "George Araujo", email: "george.araujo@rever.com.br" },
{ name: "Hugo Soares", email: "hugo.soares@rever.com.br" },
{ name: "Julio Cesar", email: "julio@rever.com.br" },
{ name: "Lorena Magalhães", email: "lorena@rever.com.br" },
{ name: "Rever", email: "renan.pac@paulicon.com.br" },
{ name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br" },
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
];
const staffIds = await Promise.all(
staffRoster.map((staff) => ensureUser({ name: staff.name, email: staff.email, role: "AGENT" })),
);
const defaultAssigneeId = staffIds[0] ?? adminId;
const atlasCompanyId = companyIds.get("atlas-engenharia");
const omniCompanyId = companyIds.get("omni-saude");
if (!atlasCompanyId || !omniCompanyId) {
throw new Error("Empresas padrão não foram inicializadas");
}
const atlasManagerId = await ensureUser({
name: "Mariana Andrade",
email: "mariana.andrade@atlasengenharia.com.br",
role: "MANAGER",
companyId: atlasCompanyId,
});
const joaoAtlasId = await ensureUser({
name: "João Pedro Ramos",
email: "joao.ramos@atlasengenharia.com.br",
role: "CUSTOMER",
companyId: atlasCompanyId,
});
await ensureUser({
name: "Aline Rezende",
email: "aline.rezende@atlasengenharia.com.br",
role: "CUSTOMER",
companyId: atlasCompanyId,
});
const omniManagerId = await ensureUser({
name: "Fernanda Lima",
email: "fernanda.lima@omnisaude.com.br",
role: "MANAGER",
companyId: omniCompanyId,
});
const ricardoOmniId = await ensureUser({
name: "Ricardo Matos",
email: "ricardo.matos@omnisaude.com.br",
role: "CUSTOMER",
companyId: omniCompanyId,
});
await ensureUser({
name: "Luciana Prado",
email: "luciana.prado@omnisaude.com.br",
role: "CUSTOMER",
companyId: omniCompanyId,
});
const clienteDemoId = await ensureUser({
name: "Cliente Demo",
email: "cliente.demo@sistema.dev",
role: "CUSTOMER",
companyId: omniCompanyId,
});
const templateDefinitions = [
{
title: "A Rever agradece seu contato",
body: "<p>A Rever agradece seu contato. Recebemos sua solicitação e nossa equipe já está analisando os detalhes. Retornaremos com atualizações em breve.</p>",
},
{
title: "Atualização do chamado",
body: "<p>Seu chamado foi atualizado. Caso tenha novas informações ou dúvidas, basta responder a esta mensagem.</p>",
},
{
title: "Chamado resolvido",
body: "<p>Concluímos o atendimento deste chamado. A Rever agradece a parceria e permanecemos à disposição para novos suportes.</p>",
},
];
const existingTemplates = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
for (const definition of templateDefinitions) {
const already = existingTemplates.find((template) => template?.title === definition.title);
if (already) continue;
const timestamp = Date.now();
await ctx.db.insert("commentTemplates", {
tenantId,
title: definition.title,
body: definition.body,
createdBy: adminId,
updatedBy: adminId,
createdAt: timestamp,
updatedAt: timestamp,
});
}
// Seed a couple of tickets
const now = Date.now();
const newestRef = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId))
.order("desc")
.take(1);
let ref = newestRef[0]?.reference ?? 41000;
const queue1 = queueChamados._id;
const queue2 = queueLaboratorio._id;
const t1 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Erro 500 ao acessar portal do cliente",
summary: "Clientes relatam erro intermitente no portal web",
status: "OPEN",
priority: "URGENT",
channel: "EMAIL",
queueId: queue1,
requesterId: joaoAtlasId,
assigneeId: defaultAssigneeId,
companyId: atlasCompanyId,
createdAt: now - 1000 * 60 * 60 * 5,
updatedAt: now - 1000 * 60 * 10,
tags: ["portal", "cliente"],
});
await ctx.db.insert("ticketEvents", {
ticketId: t1,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 5,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t1,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 4,
payload: { managerUserId: atlasManagerId },
});
const t2 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Integração ERP parada",
summary: "Webhook do ERP retornando timeout",
status: "PENDING",
priority: "HIGH",
channel: "WHATSAPP",
queueId: queue2,
requesterId: ricardoOmniId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
createdAt: now - 1000 * 60 * 60 * 8,
updatedAt: now - 1000 * 60 * 30,
tags: ["Integração", "erp"],
});
await ctx.db.insert("ticketEvents", {
ticketId: t2,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 8,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t2,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 7,
payload: { managerUserId: omniManagerId },
});
const t3 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Visita técnica para instalação de roteadores",
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
status: "OPEN",
priority: "MEDIUM",
channel: "PHONE",
queueId: queueVisitas._id,
requesterId: clienteDemoId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
createdAt: now - 1000 * 60 * 60 * 3,
updatedAt: now - 1000 * 60 * 20,
tags: ["visita", "infraestrutura"],
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 3,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "VISIT_SCHEDULED",
createdAt: now - 1000 * 60 * 15,
payload: { scheduledFor: now + 1000 * 60 * 60 * 24 },
});
},
});

138
convex/slas.ts Normal file
View file

@ -0,0 +1,138 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { requireAdmin } from "./rbac";
function normalizeName(value: string) {
return value.trim();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
const existing = await ctx.db
.query("slaPolicies")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma política SLA com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const items = await ctx.db
.query("slaPolicies")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
return items.map((policy) => ({
id: policy._id,
name: policy.name,
description: policy.description ?? "",
timeToFirstResponse: policy.timeToFirstResponse ?? null,
timeToResolution: policy.timeToResolution ?? null,
}));
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
timeToResolution: v.optional(v.number()),
},
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a política");
}
await ensureUniqueName(ctx, tenantId, trimmed);
if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) {
throw new ConvexError("Tempo para primeira resposta deve ser positivo");
}
if (timeToResolution !== undefined && timeToResolution < 0) {
throw new ConvexError("Tempo para resolução deve ser positivo");
}
const id = await ctx.db.insert("slaPolicies", {
tenantId,
name: trimmed,
description,
timeToFirstResponse,
timeToResolution,
});
return id;
},
});
export const update = mutation({
args: {
policyId: v.id("slaPolicies"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
timeToResolution: v.optional(v.number()),
},
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
throw new ConvexError("Política não encontrada");
}
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a política");
}
if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) {
throw new ConvexError("Tempo para primeira resposta deve ser positivo");
}
if (timeToResolution !== undefined && timeToResolution < 0) {
throw new ConvexError("Tempo para resolução deve ser positivo");
}
await ensureUniqueName(ctx, tenantId, trimmed, policyId);
await ctx.db.patch(policyId, {
name: trimmed,
description,
timeToFirstResponse,
timeToResolution,
});
},
});
export const remove = mutation({
args: {
policyId: v.id("slaPolicies"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { policyId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
throw new ConvexError("Política não encontrada");
}
const ticketLinked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("slaPolicyId"), policyId))
.first();
if (ticketLinked) {
throw new ConvexError("Remova a associação de tickets antes de excluir a política");
}
await ctx.db.delete(policyId);
},
});

232
convex/teams.ts Normal file
View file

@ -0,0 +1,232 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { ConvexError, v } from "convex/values";
import { requireAdmin } from "./rbac";
function normalizeName(value: string) {
return value.trim();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"teams">) {
const existing = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um time com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return teams.map((team) => {
const members = users
.filter((user) => (user.teams ?? []).includes(team.name))
.map((user) => ({
id: user._id,
name: user.name,
email: user.email,
role: user.role ?? "AGENT",
}));
const linkedQueues = queues.filter((queue) => queue.teamId === team._id);
return {
id: team._id,
name: team.name,
description: team.description ?? "",
members,
queueCount: linkedQueues.length,
createdAt: team._creationTime,
};
});
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { tenantId, actorId, name, description }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para o time");
}
await ensureUniqueName(ctx, tenantId, trimmed);
const id = await ctx.db.insert("teams", {
tenantId,
name: trimmed,
description,
});
return id;
},
});
export const update = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { teamId, tenantId, actorId, name, description }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para o time");
}
await ensureUniqueName(ctx, tenantId, trimmed, teamId);
if (team.name !== trimmed) {
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const now = users
.filter((user) => (user.teams ?? []).includes(team.name))
.map(async (user) => {
const teams = (user.teams ?? []).map((entry) => (entry === team.name ? trimmed : entry));
await ctx.db.patch(user._id, { teams });
});
await Promise.all(now);
}
await ctx.db.patch(teamId, { name: trimmed, description });
},
});
export const remove = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { teamId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const queuesLinked = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("teamId"), teamId))
.first();
if (queuesLinked) {
throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time");
}
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
await Promise.all(
users
.filter((user) => (user.teams ?? []).includes(team.name))
.map((user) => {
const teams = (user.teams ?? []).filter((entry) => entry !== team.name);
return ctx.db.patch(user._id, { teams });
})
);
await ctx.db.delete(teamId);
},
});
export const setMembers = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
memberIds: v.array(v.id("users")),
},
handler: async (ctx, { teamId, tenantId, actorId, memberIds }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const tenantUserIds = new Set(users.map((user) => user._id));
for (const memberId of memberIds) {
if (!tenantUserIds.has(memberId)) {
throw new ConvexError("Usuário inválido para este tenant");
}
}
const target = new Set(memberIds);
await Promise.all(
users.map(async (user) => {
const teams = new Set(user.teams ?? []);
const hasTeam = teams.has(team.name);
const shouldHave = target.has(user._id);
if (shouldHave && !hasTeam) {
teams.add(team.name);
await ctx.db.patch(user._id, { teams: Array.from(teams) });
}
if (!shouldHave && hasTeam) {
teams.delete(team.name);
await ctx.db.patch(user._id, { teams: Array.from(teams) });
}
})
);
},
});
export const directory = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return users.map((user) => ({
id: user._id,
name: user.name,
email: user.email,
role: user.role ?? "AGENT",
teams: user.teams ?? [],
}));
},
});

1288
convex/tickets.ts Normal file

File diff suppressed because it is too large Load diff

25
convex/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

112
convex/users.ts Normal file
View file

@ -0,0 +1,112 @@
import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { requireAdmin } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
export const ensureUser = mutation({
args: {
tenantId: v.string(),
email: v.string(),
name: v.string(),
avatarUrl: v.optional(v.string()),
role: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
.first();
const reconcile = async (record: typeof existing) => {
if (!record) return null;
const shouldPatch =
record.tenantId !== args.tenantId ||
(args.role && record.role !== args.role) ||
(args.avatarUrl && record.avatarUrl !== args.avatarUrl) ||
record.name !== args.name ||
(args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? []));
if (shouldPatch) {
await ctx.db.patch(record._id, {
tenantId: args.tenantId,
role: args.role ?? record.role,
avatarUrl: args.avatarUrl ?? record.avatarUrl,
name: args.name,
teams: args.teams ?? record.teams,
});
const updated = await ctx.db.get(record._id);
if (updated) {
return updated;
}
}
return record;
};
if (existing) {
const reconciled = await reconcile(existing);
if (reconciled) {
return reconciled;
}
} else {
const anyTenant = (await ctx.db.query("users").collect()).find((user) => user.email === args.email);
if (anyTenant) {
const reconciled = await reconcile(anyTenant);
if (reconciled) {
return reconciled;
}
}
}
const id = await ctx.db.insert("users", {
tenantId: args.tenantId,
email: args.email,
name: args.name,
avatarUrl: args.avatarUrl,
role: args.role ?? "AGENT",
teams: args.teams ?? [],
});
return await ctx.db.get(id);
},
});
export const listAgents = query({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return users
.filter((user) => {
const normalizedRole = (user.role ?? "AGENT").toUpperCase();
return STAFF_ROLES.has(normalizedRole);
})
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
},
});
export const deleteUser = mutation({
args: { userId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { userId, actorId }) => {
const user = await ctx.db.get(userId);
if (!user) {
return { status: "not_found" };
}
await requireAdmin(ctx, actorId, user.tenantId);
const assignedTickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", userId))
.take(1);
if (assignedTickets.length > 0) {
throw new ConvexError("Usuário ainda está atribuído a tickets");
}
await ctx.db.delete(userId);
return { status: "deleted" };
},
});