chore: prep platform improvements

This commit is contained in:
Esdras Renan 2025-11-09 21:09:38 -03:00
parent a62f3d5283
commit c5ddd54a3e
24 changed files with 777 additions and 649 deletions

View file

@ -412,8 +412,7 @@ export const deleteCategory = mutation({
}
const ticketsToMove = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.collect()
for (const ticket of ticketsToMove) {
await ctx.db.patch(ticket._id, {
@ -425,8 +424,7 @@ export const deleteCategory = mutation({
} else {
const ticketsLinked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.first()
if (ticketsLinked) {
throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino")
@ -526,8 +524,7 @@ export const deleteSubcategory = mutation({
}
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId))
.collect()
for (const ticket of tickets) {
await ctx.db.patch(ticket._id, {
@ -538,8 +535,7 @@ export const deleteSubcategory = mutation({
} else {
const linked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId))
.first()
if (linked) {
throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino")

View file

@ -37,6 +37,15 @@ function validateOptions(type: FieldType, options: { value: string; label: strin
}
}
async function validateCompanyScope(ctx: AnyCtx, tenantId: string, companyId?: Id<"companies"> | null) {
if (!companyId) return undefined;
const company = await ctx.db.get(companyId);
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa inválida para o campo");
}
return companyId;
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId, scope }) => {
@ -64,6 +73,7 @@ export const list = query({
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
companyId: field.companyId ?? null,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
}));
@ -97,6 +107,7 @@ export const listForTenant = query({
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
companyId: field.companyId ?? null,
}));
},
});
@ -118,8 +129,9 @@ export const create = mutation({
)
),
scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => {
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope, companyId }) => {
await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
@ -140,6 +152,7 @@ export const create = mutation({
}
return safe;
})();
const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined);
const existing = await ctx.db
.query("ticketFields")
@ -158,6 +171,7 @@ export const create = mutation({
options,
order: maxOrder + 1,
scope: normalizedScope,
companyId: companyRef,
createdAt: now,
updatedAt: now,
});
@ -183,8 +197,9 @@ export const update = mutation({
)
),
scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => {
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope, companyId }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
@ -208,6 +223,7 @@ export const update = mutation({
}
return safe;
})();
const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined);
let key = field.key;
if (field.label !== normalizedLabel) {
@ -223,6 +239,7 @@ export const update = mutation({
required,
options,
scope: normalizedScope,
companyId: companyRef,
updatedAt: Date.now(),
});
},

View file

@ -211,8 +211,7 @@ async function ensureQueue(
const byName = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), data.name))
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", data.name))
.first()
if (byName) {
if (byName.slug !== slug) {

View file

@ -67,8 +67,7 @@ async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, exc
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))
.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 fila com este nome");

View file

@ -184,6 +184,8 @@ export default defineSchema({
teamId: v.optional(v.id("teams")),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant_team", ["tenantId", "teamId"])
.index("by_tenant_name", ["tenantId", "name"])
.index("by_tenant", ["tenantId"]),
teams: defineTable({
@ -322,6 +324,9 @@ export default defineSchema({
.index("by_tenant_requester", ["tenantId", "requesterId"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_tenant_category", ["tenantId", "categoryId"])
.index("by_tenant_subcategory", ["tenantId", "subcategoryId"])
.index("by_tenant_sla_policy", ["tenantId", "slaPolicyId"])
.index("by_tenant", ["tenantId"])
.index("by_tenant_created", ["tenantId", "createdAt"])
.index("by_tenant_resolved", ["tenantId", "resolvedAt"])
@ -480,6 +485,7 @@ export default defineSchema({
key: v.string(),
label: v.string(),
type: v.string(),
companyId: v.optional(v.id("companies")),
description: v.optional(v.string()),
required: v.boolean(),
order: v.number(),
@ -498,6 +504,7 @@ export default defineSchema({
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant_scope", ["tenantId", "scope"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant", ["tenantId"]),
ticketFormSettings: defineTable({

View file

@ -126,8 +126,7 @@ export const remove = mutation({
const ticketLinked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("slaPolicyId"), policyId))
.withIndex("by_tenant_sla_policy", (q) => q.eq("tenantId", tenantId).eq("slaPolicyId", policyId))
.first();
if (ticketLinked) {
throw new ConvexError("Remova a associação de tickets antes de excluir a política");

View file

@ -141,8 +141,7 @@ export const remove = mutation({
const queuesLinked = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("teamId"), teamId))
.withIndex("by_tenant_team", (q) => q.eq("tenantId", tenantId).eq("teamId", teamId))
.first();
if (queuesLinked) {
throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time");

View file

@ -121,6 +121,7 @@ async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourc
required: field.required,
options: field.options ?? undefined,
scope: targetKey,
companyId: field.companyId ?? undefined,
order,
createdAt: now,
updatedAt: now,

View file

@ -1,5 +1,7 @@
"use server";
import type { Id } from "./_generated/dataModel";
export type TicketFormFieldSeed = {
key: string;
label: string;
@ -7,6 +9,7 @@ export type TicketFormFieldSeed = {
required?: boolean;
description?: string;
options?: Array<{ value: string; label: string }>;
companyId?: Id<"companies"> | null;
};
export const TICKET_FORM_CONFIG = [

View file

@ -3,8 +3,7 @@ import { mutation, query } from "./_generated/server";
import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc, type DataModel } from "./_generated/dataModel";
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireStaff, requireUser } from "./rbac";
import {
@ -477,13 +476,15 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<Te
async function fetchTicketFieldsByScopes(
ctx: QueryCtx,
tenantId: string,
scopes: string[]
scopes: string[],
companyId: Id<"companies"> | null
): Promise<TicketFieldScopeMap> {
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
if (uniqueScopes.length === 0) {
return new Map();
}
const scopeSet = new Set(uniqueScopes);
const companyIdStr = companyId ? String(companyId) : null;
const result: TicketFieldScopeMap = new Map();
const allFields = await ctx.db
.query("ticketFields")
@ -495,6 +496,10 @@ async function fetchTicketFieldsByScopes(
if (!scopeSet.has(scope)) {
continue;
}
const fieldCompanyId = field.companyId ? String(field.companyId) : null;
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
continue;
}
const current = result.get(scope);
if (current) {
current.push(field);
@ -634,6 +639,7 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
label: option.label,
})),
scope: template.key,
companyId: field.companyId ?? undefined,
order,
createdAt: now,
updatedAt: now,
@ -1319,9 +1325,6 @@ const MAX_FETCH_LIMIT = 1000;
const FETCH_MULTIPLIER_NO_SEARCH = 3;
const FETCH_MULTIPLIER_WITH_SEARCH = 5;
type TicketsTableInfo = NamedTableInfo<DataModel, "tickets">;
type TicketsQueryBuilder = ConvexQuery<TicketsTableInfo>;
function clampTicketLimit(limit: number) {
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit)));
@ -1371,7 +1374,6 @@ export const list = query({
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
const primaryPriorityFilter = normalizedPriorityFilter.length === 1 ? normalizedPriorityFilter[0] : null;
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
const searchTerm = args.search?.trim().toLowerCase() ?? null;
@ -1379,80 +1381,43 @@ export const list = query({
const requestedLimit = clampTicketLimit(requestedLimitRaw);
const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm));
const applyQueryFilters = (query: TicketsQueryBuilder) => {
let working = query;
if (normalizedStatusFilter) {
working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter));
}
if (primaryPriorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter));
}
if (normalizedChannelFilter) {
working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter));
}
if (args.queueId) {
working = working.filter((q) => q.eq(q.field("queueId"), args.queueId!));
}
if (args.assigneeId) {
working = working.filter((q) => q.eq(q.field("assigneeId"), args.assigneeId!));
}
if (args.requesterId) {
working = working.filter((q) => q.eq(q.field("requesterId"), args.requesterId!));
}
return working;
};
let base: Doc<"tickets">[] = [];
if (role === "MANAGER") {
const baseQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
);
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.assigneeId) {
const baseQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
);
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.requesterId) {
const baseQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
);
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.queueId) {
const baseQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
);
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (normalizedStatusFilter) {
const baseQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter))
);
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (role === "COLLABORATOR") {
const viewerEmail = user.email.trim().toLowerCase();
const directQuery = applyQueryFilters(
ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId))
);
const directQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId));
const directTickets = await directQuery.order("desc").take(fetchLimit);
let combined = directTickets;
if (directTickets.length < fetchLimit) {
const fallbackQuery = applyQueryFilters(
ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
);
const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
const fallbackMatches = fallbackRaw.filter((ticket) => {
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
@ -1463,9 +1428,7 @@ export const list = query({
}
base = combined.slice(0, fetchLimit);
} else {
const baseQuery = applyQueryFilters(
ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
);
const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
base = await baseQuery.order("desc").take(fetchLimit);
}
@ -2829,7 +2792,7 @@ export const listTicketForms = query({
const templates = await fetchTemplateSummaries(ctx, tenantId)
const scopes = templates.map((template) => template.key)
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
const settingsByTemplate = staffOverride