feat: núcleo de tickets com Convex (CRUD, play, comentários com anexos) + auth placeholder; docs em AGENTS.md; toasts e updates otimistas; mapeadores Zod; refinos PT-BR e layout do painel de detalhes
This commit is contained in:
parent
2230590e57
commit
27b103cb46
97 changed files with 15117 additions and 15715 deletions
51
web/convex/_generated/api.d.ts
vendored
Normal file
51
web/convex/_generated/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* 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 files from "../files.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as seed from "../seed.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;
|
||||
files: typeof files;
|
||||
queues: typeof queues;
|
||||
seed: typeof seed;
|
||||
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
web/convex/_generated/api.js
Normal file
23
web/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
web/convex/_generated/dataModel.d.ts
vendored
Normal file
60
web/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
web/convex/_generated/server.d.ts
vendored
Normal file
149
web/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
web/convex/_generated/server.js
Normal file
90
web/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;
|
||||
23
web/convex/bootstrap.ts
Normal file
23
web/convex/bootstrap.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ensureDefaults = mutation({
|
||||
args: { tenantId: v.string() },
|
||||
handler: async (ctx, { tenantId }) => {
|
||||
const existing = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
if (existing.length === 0) {
|
||||
const queues = [
|
||||
{ name: "Suporte N1", slug: "suporte-n1" },
|
||||
{ name: "Suporte N2", slug: "suporte-n2" },
|
||||
{ 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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
9
web/convex/convex.config.ts
Normal file
9
web/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;
|
||||
|
||||
18
web/convex/files.ts
Normal file
18
web/convex/files.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { action, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const generateUploadUrl = action({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.storage.generateUploadUrl();
|
||||
},
|
||||
});
|
||||
|
||||
export const getUrl = query({
|
||||
args: { storageId: v.id("_storage") },
|
||||
handler: async (ctx, { storageId }) => {
|
||||
const url = await ctx.storage.getUrl(storageId);
|
||||
return url;
|
||||
},
|
||||
});
|
||||
|
||||
24
web/convex/queues.ts
Normal file
24
web/convex/queues.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const summary = query({
|
||||
args: { tenantId: v.string() },
|
||||
handler: async (ctx, { tenantId }) => {
|
||||
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
|
||||
// Compute counts per queue
|
||||
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; // Placeholder, SLAs later
|
||||
return { id: qItem._id, name: qItem.name, pending: open, waiting, breached };
|
||||
})
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
90
web/convex/schema.ts
Normal file
90
web/convex/schema.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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())),
|
||||
})
|
||||
.index("by_tenant_email", ["tenantId", "email"])
|
||||
.index("by_tenant_role", ["tenantId", "role"]),
|
||||
|
||||
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()),
|
||||
status: v.string(),
|
||||
priority: v.string(),
|
||||
channel: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
requesterId: v.id("users"),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
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())),
|
||||
})
|
||||
.index("by_tenant_status", ["tenantId", "status"])
|
||||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
||||
.index("by_tenant_reference", ["tenantId", "reference"]),
|
||||
|
||||
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"]),
|
||||
});
|
||||
|
||||
81
web/convex/seed.ts
Normal file
81
web/convex/seed.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { mutation } from "./_generated/server";
|
||||
|
||||
export const seedDemo = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const tenantId = "tenant-atlas";
|
||||
// Ensure queues
|
||||
const existingQueues = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
const queues = existingQueues.length
|
||||
? existingQueues
|
||||
: await Promise.all(
|
||||
[
|
||||
{ name: "Suporte N1", slug: "suporte-n1" },
|
||||
{ name: "Suporte N2", slug: "suporte-n2" },
|
||||
].map((q) => ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }))
|
||||
).then((ids) => Promise.all(ids.map((id) => ctx.db.get(id))))
|
||||
;
|
||||
|
||||
// Ensure users
|
||||
async function ensureUser(name: string, email: string, role = "AGENT") {
|
||||
const found = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||
.first();
|
||||
if (found) return found._id;
|
||||
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` });
|
||||
}
|
||||
const anaId = await ensureUser("Ana Souza", "ana.souza@example.com");
|
||||
const brunoId = await ensureUser("Bruno Lima", "bruno.lima@example.com");
|
||||
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
|
||||
|
||||
// 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 = queues[0]!._id;
|
||||
const queue2 = queues[1]!._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: eduardaId,
|
||||
assigneeId: anaId,
|
||||
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: {} });
|
||||
|
||||
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: eduardaId,
|
||||
assigneeId: brunoId,
|
||||
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: {} });
|
||||
},
|
||||
});
|
||||
|
||||
368
web/convex/tickets.ts
Normal file
368
web/convex/tickets.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
const STATUS_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
status: v.optional(v.string()),
|
||||
priority: v.optional(v.string()),
|
||||
channel: v.optional(v.string()),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
search: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
let q = ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId));
|
||||
|
||||
const all = await q.collect();
|
||||
let filtered = all;
|
||||
if (args.status) filtered = filtered.filter((t) => t.status === args.status);
|
||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
||||
if (args.queueId) filtered = filtered.filter((t) => t.queueId === args.queueId);
|
||||
if (args.search) {
|
||||
const term = args.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(t) =>
|
||||
t.subject.toLowerCase().includes(term) ||
|
||||
t.summary?.toLowerCase().includes(term) ||
|
||||
`#${t.reference}`.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
|
||||
// hydrate requester and assignee
|
||||
const result = await Promise.all(
|
||||
limited.map(async (t) => {
|
||||
const requester = await ctx.db.get(t.requesterId);
|
||||
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
|
||||
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
|
||||
return {
|
||||
id: t._id,
|
||||
reference: t.reference,
|
||||
tenantId: t.tenantId,
|
||||
subject: t.subject,
|
||||
summary: t.summary,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queue?.name ?? null,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
id: assignee._id,
|
||||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
dueAt: t.dueAt ?? null,
|
||||
firstResponseAt: t.firstResponseAt ?? null,
|
||||
resolvedAt: t.resolvedAt ?? null,
|
||||
updatedAt: t.updatedAt,
|
||||
createdAt: t.createdAt,
|
||||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
// sort by updatedAt desc
|
||||
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any));
|
||||
},
|
||||
});
|
||||
|
||||
export const getById = query({
|
||||
args: { tenantId: v.string(), id: v.id("tickets") },
|
||||
handler: async (ctx, { tenantId, id }) => {
|
||||
const t = await ctx.db.get(id);
|
||||
if (!t || t.tenantId !== tenantId) return null;
|
||||
const requester = await ctx.db.get(t.requesterId);
|
||||
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
|
||||
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
.collect();
|
||||
const timeline = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
.collect();
|
||||
|
||||
const commentsHydrated = await Promise.all(
|
||||
comments.map(async (c) => {
|
||||
const author = await ctx.db.get(c.authorId);
|
||||
const attachments = await Promise.all(
|
||||
(c.attachments ?? []).map(async (att) => ({
|
||||
id: att.storageId,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
url: await ctx.storage.getUrl(att.storageId),
|
||||
}))
|
||||
);
|
||||
return {
|
||||
id: c._id,
|
||||
author: {
|
||||
id: author!._id,
|
||||
name: author!.name,
|
||||
email: author!.email,
|
||||
avatarUrl: author!.avatarUrl,
|
||||
teams: author!.teams ?? [],
|
||||
},
|
||||
visibility: c.visibility,
|
||||
body: c.body,
|
||||
attachments,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
id: t._id,
|
||||
reference: t.reference,
|
||||
tenantId: t.tenantId,
|
||||
subject: t.subject,
|
||||
summary: t.summary,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queue?.name ?? null,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
id: assignee._id,
|
||||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
dueAt: t.dueAt ?? null,
|
||||
firstResponseAt: t.firstResponseAt ?? null,
|
||||
resolvedAt: t.resolvedAt ?? null,
|
||||
updatedAt: t.updatedAt,
|
||||
createdAt: t.createdAt,
|
||||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
description: undefined,
|
||||
customFields: {},
|
||||
timeline: timeline.map((ev) => ({
|
||||
id: ev._id,
|
||||
type: ev.type,
|
||||
payload: ev.payload,
|
||||
createdAt: ev.createdAt,
|
||||
})),
|
||||
comments: commentsHydrated,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
subject: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
priority: v.string(),
|
||||
channel: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
requesterId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
// compute next reference (simple monotonic counter per tenant)
|
||||
const existing = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
|
||||
.order("desc")
|
||||
.take(1);
|
||||
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("tickets", {
|
||||
tenantId: args.tenantId,
|
||||
reference: nextRef,
|
||||
subject: args.subject,
|
||||
summary: args.summary,
|
||||
status: "NEW",
|
||||
priority: args.priority,
|
||||
channel: args.channel,
|
||||
queueId: args.queueId,
|
||||
requesterId: args.requesterId,
|
||||
assigneeId: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
tags: [],
|
||||
slaPolicyId: undefined,
|
||||
dueAt: undefined,
|
||||
});
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
type: "CREATED",
|
||||
payload: { requesterId: args.requesterId },
|
||||
createdAt: now,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
||||
export const addComment = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
authorId: v.id("users"),
|
||||
visibility: v.string(),
|
||||
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()),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("ticketComments", {
|
||||
ticketId: args.ticketId,
|
||||
authorId: args.authorId,
|
||||
visibility: args.visibility,
|
||||
body: args.body,
|
||||
attachments: args.attachments ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: args.ticketId,
|
||||
type: "COMMENT_ADDED",
|
||||
payload: { authorId: args.authorId },
|
||||
createdAt: now,
|
||||
});
|
||||
// bump ticket updatedAt
|
||||
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
||||
export const updateStatus = mutation({
|
||||
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, status, actorId }) => {
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "STATUS_CHANGED",
|
||||
payload: { to: status, actorId },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const playNext = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
agentId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, queueId, agentId }) => {
|
||||
// Find eligible tickets: not resolved/closed and not assigned
|
||||
let candidates = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any))
|
||||
.collect();
|
||||
|
||||
candidates = candidates.filter(
|
||||
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// prioritize by priority then createdAt
|
||||
candidates.sort((a, b) => {
|
||||
const pa = STATUS_ORDER.indexOf(a.priority as any);
|
||||
const pb = STATUS_ORDER.indexOf(b.priority as any);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
|
||||
const chosen = candidates[0];
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: chosen._id,
|
||||
type: "ASSIGNEE_CHANGED",
|
||||
payload: { assigneeId: agentId },
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return await getPublicById(ctx, chosen._id);
|
||||
},
|
||||
});
|
||||
|
||||
// internal helper to hydrate a ticket in the same shape as list/getById
|
||||
const getPublicById = async (ctx: any, id: Id<"tickets">) => {
|
||||
const t = await ctx.db.get(id);
|
||||
if (!t) return null;
|
||||
const requester = await ctx.db.get(t.requesterId);
|
||||
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
|
||||
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
|
||||
return {
|
||||
id: t._id,
|
||||
reference: t.reference,
|
||||
tenantId: t.tenantId,
|
||||
subject: t.subject,
|
||||
summary: t.summary,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queue?.name ?? null,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
id: assignee._id,
|
||||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
dueAt: t.dueAt ?? null,
|
||||
firstResponseAt: t.firstResponseAt ?? null,
|
||||
resolvedAt: t.resolvedAt ?? null,
|
||||
updatedAt: t.updatedAt,
|
||||
createdAt: t.createdAt,
|
||||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
};
|
||||
};
|
||||
42
web/convex/users.ts
Normal file
42
web/convex/users.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
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();
|
||||
if (existing) return existing;
|
||||
const now = Date.now();
|
||||
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 agents = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT"))
|
||||
.collect();
|
||||
return agents;
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue