feat: export reports as xlsx and add machine inventory

This commit is contained in:
Esdras Renan 2025-10-27 18:00:28 -03:00
parent 29b865885c
commit 714b199879
34 changed files with 2304 additions and 245 deletions

View file

@ -2,7 +2,8 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { Id, type Doc, type DataModel } from "./_generated/dataModel";
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
import { requireAdmin, requireStaff, requireUser } from "./rbac";
@ -176,7 +177,7 @@ async function normalizeTicketMentions(
return output
}
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
@ -651,6 +652,39 @@ function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
}, {});
}
const DEFAULT_TICKETS_LIST_LIMIT = 250;
const MIN_TICKETS_LIST_LIMIT = 25;
const MAX_TICKETS_LIST_LIMIT = 600;
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)));
}
function computeFetchLimit(limit: number, hasSearch: boolean) {
const multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH;
const target = limit * multiplier;
return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
}
function dedupeTicketsById(tickets: Doc<"tickets">[]) {
const seen = new Set<string>();
const result: Doc<"tickets">[] = [];
for (const ticket of tickets) {
const key = String(ticket._id);
if (seen.has(key)) continue;
seen.add(key);
result.push(ticket);
}
return result;
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
@ -666,85 +700,138 @@ export const list = query({
},
handler: async (ctx, args) => {
if (!args.viewerId) {
return []
return [];
}
const viewerId = args.viewerId as Id<"users">;
const { user, role } = await requireUser(ctx, viewerId, args.tenantId);
if (role === "MANAGER" && !user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null;
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
const searchTerm = args.search?.trim().toLowerCase() ?? null;
const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT;
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));
}
// Managers are scoped to company; allow secondary narrowing by requester/assignee
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
if (normalizedPriorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter));
}
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!))
);
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.assigneeId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
.collect();
const baseQuery = applyQueryFilters(
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) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
.collect();
const baseQuery = applyQueryFilters(
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) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
.collect();
const baseQuery = applyQueryFilters(
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))
);
base = await baseQuery.order("desc").take(fetchLimit);
} else if (role === "COLLABORATOR") {
// Colaborador: exibir apenas tickets onde ele é o solicitante
// Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer
const all = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect()
const viewerEmail = user.email.trim().toLowerCase()
base = all.filter((t) => {
if (t.requesterId === args.viewerId) return true
const rs = t.requesterSnapshot as { email?: string } | undefined
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
return Boolean(email && email === viewerEmail)
})
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 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 fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
const fallbackMatches = fallbackRaw.filter((ticket) => {
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
if (typeof snapshotEmail !== "string") return false;
return snapshotEmail.trim().toLowerCase() === viewerEmail;
});
combined = dedupeTicketsById([...directTickets, ...fallbackMatches]);
}
base = combined.slice(0, fetchLimit);
} else {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect();
const baseQuery = applyQueryFilters(
ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
);
base = await baseQuery.order("desc").take(fetchLimit);
}
let filtered = base;
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
filtered = filtered.filter((t) => t.companyId === user.companyId);
}
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
if (normalizedPriorityFilter) filtered = filtered.filter((t) => t.priority === normalizedPriorityFilter);
if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
if (normalizedStatusFilter) {
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
}
if (args.search) {
const term = args.search.toLowerCase();
if (searchTerm) {
filtered = filtered.filter(
(t) =>
t.subject.toLowerCase().includes(term) ||
t.summary?.toLowerCase().includes(term) ||
`#${t.reference}`.toLowerCase().includes(term)
t.subject.toLowerCase().includes(searchTerm) ||
t.summary?.toLowerCase().includes(searchTerm) ||
`#${t.reference}`.toLowerCase().includes(searchTerm)
);
}
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
const limited = filtered.slice(0, requestedLimit);
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
const machineCache = new Map<string, Doc<"machines"> | null>();
// hydrate requester and assignee
const result = await Promise.all(
limited.map(async (t) => {
@ -774,6 +861,49 @@ export const list = query({
subcategorySummary = { id: subcategory._id, name: subcategory.name };
}
}
const machineSnapshot = t.machineSnapshot as
| {
hostname?: string
persona?: string
assignedUserName?: string
assignedUserEmail?: string
status?: string
}
| undefined;
let machineSummary:
| {
id: Id<"machines"> | null
hostname: string | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
status: string | null
}
| null = null;
if (t.machineId) {
const cacheKey = String(t.machineId);
if (!machineCache.has(cacheKey)) {
machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null);
}
const machineDoc = machineCache.get(cacheKey);
machineSummary = {
id: t.machineId,
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
};
} else if (machineSnapshot) {
machineSummary = {
id: null,
hostname: machineSnapshot.hostname ?? null,
persona: machineSnapshot.persona ?? null,
assignedUserName: machineSnapshot.assignedUserName ?? null,
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
status: machineSnapshot.status ?? null,
};
}
const serverNow = Date.now()
return {
id: t._id,
@ -819,6 +949,7 @@ export const list = query({
metrics: null,
category: categorySummary,
subcategory: subcategorySummary,
machine: machineSummary,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: t.internalWorkedMs ?? 0,
@ -867,6 +998,45 @@ export const getById = query({
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const machineSnapshot = t.machineSnapshot as
| {
hostname?: string
persona?: string
assignedUserName?: string
assignedUserEmail?: string
status?: string
}
| undefined;
let machineSummary:
| {
id: Id<"machines"> | null
hostname: string | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
status: string | null
}
| null = null;
if (t.machineId) {
const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null;
machineSummary = {
id: t.machineId,
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
};
} else if (machineSnapshot) {
machineSummary = {
id: null,
hostname: machineSnapshot.hostname ?? null,
persona: machineSnapshot.persona ?? null,
assignedUserName: machineSnapshot.assignedUserName ?? null,
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
status: machineSnapshot.status ?? null,
};
}
const queueName = normalizeQueueName(queue);
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
@ -1011,6 +1181,7 @@ export const getById = query({
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
machine: machineSummary,
category: category
? {
id: category._id,
@ -1082,6 +1253,7 @@ export const create = mutation({
assigneeId: v.optional(v.id("users")),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
machineId: v.optional(v.id("machines")),
customFields: v.optional(
v.array(
v.object({
@ -1147,6 +1319,15 @@ export const create = mutation({
}
}
let machineDoc: Doc<"machines"> | null = null
if (args.machineId) {
const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Máquina inválida para este chamado")
}
machineDoc = machine
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
@ -1156,14 +1337,20 @@ export const create = mutation({
.take(1);
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
const initialStatus: TicketStatusNormalized = "PENDING";
const requesterSnapshot = {
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
const companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
if (!companyDoc && machineDoc?.companyId) {
const candidateCompany = await ctx.db.get(machineDoc.companyId)
if (candidateCompany && candidateCompany.tenantId === args.tenantId) {
companyDoc = candidateCompany as Doc<"companies">
}
}
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
@ -1205,8 +1392,18 @@ export const create = mutation({
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: requester.companyId ?? undefined,
companyId: companyDoc?._id ?? requester.companyId ?? undefined,
companySnapshot,
machineId: machineDoc?._id ?? undefined,
machineSnapshot: machineDoc
? {
hostname: machineDoc.hostname ?? undefined,
persona: machineDoc.persona ?? undefined,
assignedUserName: machineDoc.assignedUserName ?? undefined,
assignedUserEmail: machineDoc.assignedUserEmail ?? undefined,
status: machineDoc.status ?? undefined,
}
: undefined,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
@ -1492,6 +1689,9 @@ export const updateStatus = mutation({
const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedStatus = normalizeStatus(status)
if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) {
throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
}
const now = Date.now();
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
@ -2006,6 +2206,26 @@ export const pauseWork = mutation({
}
if (!ticketDoc.activeSessionId) {
const normalizedStatus = normalizeStatus(ticketDoc.status)
if (normalizedStatus === "AWAITING_ATTENDANCE") {
const now = Date.now()
await ctx.db.patch(ticketId, {
status: "PAUSED",
working: false,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: {
to: "PAUSED",
toLabel: STATUS_LABELS.PAUSED,
actorId,
},
createdAt: now,
})
return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now }
}
return { status: "already_paused" }
}
@ -2278,15 +2498,21 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
const currentStatus = normalizeStatus(chosen.status);
const nextStatus: TicketStatusNormalized =
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
const nextStatus: TicketStatusNormalized = currentStatus;
const assigneeSnapshot = {
name: agent.name,
email: agent.email,
avatarUrl: agent.avatarUrl ?? undefined,
teams: agent.teams ?? undefined,
}
await ctx.db.patch(chosen._id, { assigneeId: agentId, assigneeSnapshot, status: nextStatus, updatedAt: now });
await ctx.db.patch(chosen._id, {
assigneeId: agentId,
assigneeSnapshot,
status: nextStatus,
working: false,
activeSessionId: undefined,
updatedAt: now,
});
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",