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:
parent
0ec5b49e8a
commit
29a647f6c6
43 changed files with 4992 additions and 363 deletions
8
web/convex/_generated/api.d.ts
vendored
8
web/convex/_generated/api.d.ts
vendored
|
|
@ -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
209
web/convex/fields.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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
341
web/convex/reports.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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
138
web/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
web/convex/teams.ts
Normal file
232
web/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 ?? [],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -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">[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue