feat: secure convex admin flows with real metrics\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

This commit is contained in:
esdrasrenan 2025-10-05 19:59:24 -03:00
parent 0ec5b49e8a
commit 29a647f6c6
43 changed files with 4992 additions and 363 deletions

View file

@ -10,10 +10,14 @@
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as fields from "../fields.js";
import type * as files from "../files.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";
@ -34,10 +38,14 @@ import type {
declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap;
categories: typeof categories;
fields: typeof fields;
files: typeof files;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
tickets: typeof tickets;
users: typeof users;
}>;

209
web/convex/fields.ts Normal file
View file

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

View file

@ -1,5 +1,9 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
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",
@ -15,11 +19,75 @@ function renameQueueString(value: string) {
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() },
handler: async (ctx, { tenantId }) => {
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();
// Compute counts per queue
const result = await Promise.all(
queues.map(async (qItem) => {
const pending = await ctx.db
@ -28,7 +96,7 @@ export const summary = query({
.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
const breached = 0;
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
})
);
@ -36,3 +104,98 @@ export const summary = query({
},
});
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);
},
});

341
web/convex/reports.ts Normal file
View file

@ -0,0 +1,341 @@
import { query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server";
import { 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 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 }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
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 }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
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 }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
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 }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
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 }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
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,
};
},
});

View file

@ -11,7 +11,8 @@ export default defineSchema({
teams: v.optional(v.array(v.string())),
})
.index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"]),
.index("by_tenant_role", ["tenantId", "role"])
.index("by_tenant", ["tenantId"]),
queues: defineTable({
tenantId: v.string(),
@ -129,4 +130,27 @@ export default defineSchema({
.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"]),
});

138
web/convex/slas.ts Normal file
View file

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

232
web/convex/teams.ts Normal file
View file

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

View file

@ -2,7 +2,7 @@ import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac";
import { requireCustomer, requireStaff, requireUser } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
@ -52,7 +52,7 @@ export const list = query({
if (!args.viewerId) {
return []
}
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
const { role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];

View file

@ -15,7 +15,45 @@ export const ensureUser = mutation({
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
.first();
if (existing) return existing;
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,