chore: reorganize project structure and ensure default queues
This commit is contained in:
parent
854887f499
commit
1cccb852a5
201 changed files with 417 additions and 838 deletions
90
convex/README.md
Normal file
90
convex/README.md
Normal 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
69
convex/_generated/api.d.ts
vendored
Normal 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
23
convex/_generated/api.js
Normal 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
60
convex/_generated/dataModel.d.ts
vendored
Normal 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
149
convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
90
convex/_generated/server.js
Normal file
90
convex/_generated/server.js
Normal 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
36
convex/bootstrap.ts
Normal 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
550
convex/categories.ts
Normal 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
173
convex/commentTemplates.ts
Normal 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
9
convex/convex.config.ts
Normal 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
233
convex/fields.ts
Normal 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
18
convex/files.ts
Normal 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
115
convex/invites.ts
Normal 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
619
convex/migrations.ts
Normal 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
204
convex/queues.ts
Normal 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
81
convex/rbac.ts
Normal 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
360
convex/reports.ts
Normal 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
220
convex/schema.ts
Normal 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
390
convex/seed.ts
Normal 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
138
convex/slas.ts
Normal 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
232
convex/teams.ts
Normal 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
1288
convex/tickets.ts
Normal file
File diff suppressed because it is too large
Load diff
25
convex/tsconfig.json
Normal file
25
convex/tsconfig.json
Normal 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
112
convex/users.ts
Normal 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" };
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue