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

@ -6,6 +6,7 @@ import { sha256 } from "@noble/hashes/sha256"
import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets"
const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
@ -27,7 +28,7 @@ function getTokenTtlMs(): number {
return parsed
}
function getOfflineThresholdMs(): number {
export function getOfflineThresholdMs(): number {
const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"]
if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS
const parsed = Number(raw)
@ -37,7 +38,7 @@ function getOfflineThresholdMs(): number {
return parsed
}
function getStaleThresholdMs(offlineMs: number): number {
export function getStaleThresholdMs(offlineMs: number): number {
const raw = process.env["MACHINE_STALE_THRESHOLD_MS"]
if (!raw) return offlineMs * 12
const parsed = Number(raw)
@ -1019,6 +1020,51 @@ export const listAlerts = query({
},
})
export const listOpenTickets = query({
args: {
machineId: v.id("machines"),
limit: v.optional(v.number()),
},
handler: async (ctx, { machineId, limit }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return []
}
const takeLimit = Math.max(1, Math.min(limit ?? 10, 50))
const candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
.order("desc")
.take(200)
const openTickets = candidates
.filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED")
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
.slice(0, takeLimit)
return openTickets.map((ticket) => ({
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: normalizeStatus(ticket.status),
priority: ticket.priority ?? "MEDIUM",
updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt,
assignee: ticket.assigneeSnapshot
? {
name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null,
email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null,
}
: null,
machine: {
id: String(ticket.machineId ?? machineId),
hostname:
((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null),
},
}))
},
})
export const updatePersona = mutation({
args: {
machineId: v.id("machines"),

View file

@ -4,6 +4,7 @@ import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac";
import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
@ -87,6 +88,35 @@ async function fetchQueues(ctx: QueryCtx, tenantId: string) {
.collect();
}
function deriveMachineStatus(machine: Doc<"machines">, now: number) {
if (machine.isActive === false) {
return "deactivated";
}
const manualStatus = (machine.status ?? "").toLowerCase();
if (manualStatus === "maintenance" || manualStatus === "blocked") {
return manualStatus;
}
const offlineMs = getOfflineThresholdMs();
const staleMs = getStaleThresholdMs(offlineMs);
if (machine.lastHeartbeatAt) {
const age = now - machine.lastHeartbeatAt;
if (age <= offlineMs) return "online";
if (age <= staleMs) return "offline";
return "stale";
}
return (machine.status ?? "unknown") || "unknown";
}
function formatOsLabel(osName?: string | null, osVersion?: string | null) {
const name = osName?.trim();
if (!name) return "Desconhecido";
const version = osVersion?.trim();
if (!version) return name;
const conciseVersion = version.split(" ")[0];
if (!conciseVersion) return name;
return `${name} ${conciseVersion}`.trim();
}
type CsatSurvey = {
ticketId: Id<"tickets">;
reference: number;
@ -707,3 +737,163 @@ export const hoursByClientInternal = query({
}
},
})
export const companyOverview = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.id("companies"),
range: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) {
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
}
const company = await ctx.db.get(companyId);
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada");
}
const normalizedRange = (range ?? "30d").toLowerCase();
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
const now = Date.now();
const startMs = now - rangeDays * ONE_DAY_MS;
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.collect();
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.collect();
const users = await ctx.db
.query("users")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.collect();
const statusCounts = {} as Record<string, number>;
const priorityCounts = {} as Record<string, number>;
const channelCounts = {} as Record<string, number>;
const trendMap = new Map<string, { opened: number; resolved: number }>();
const openTickets: Doc<"tickets">[] = [];
tickets.forEach((ticket) => {
const normalizedStatus = normalizeStatus(ticket.status);
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] ?? 0) + 1;
const priorityKey = (ticket.priority ?? "MEDIUM").toUpperCase();
priorityCounts[priorityKey] = (priorityCounts[priorityKey] ?? 0) + 1;
const channelKey = (ticket.channel ?? "MANUAL").toUpperCase();
channelCounts[channelKey] = (channelCounts[channelKey] ?? 0) + 1;
if (normalizedStatus !== "RESOLVED") {
openTickets.push(ticket);
}
if (ticket.createdAt >= startMs) {
const key = formatDateKey(ticket.createdAt);
if (!trendMap.has(key)) {
trendMap.set(key, { opened: 0, resolved: 0 });
}
trendMap.get(key)!.opened += 1;
}
if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs) {
const key = formatDateKey(ticket.resolvedAt);
if (!trendMap.has(key)) {
trendMap.set(key, { opened: 0, resolved: 0 });
}
trendMap.get(key)!.resolved += 1;
}
});
const machineStatusCounts: Record<string, number> = {};
const machineOsCounts: Record<string, number> = {};
const machineLookup = new Map<string, Doc<"machines">>();
machines.forEach((machine) => {
machineLookup.set(String(machine._id), machine);
const status = deriveMachineStatus(machine, now);
machineStatusCounts[status] = (machineStatusCounts[status] ?? 0) + 1;
const osLabel = formatOsLabel(machine.osName ?? null, machine.osVersion ?? null);
machineOsCounts[osLabel] = (machineOsCounts[osLabel] ?? 0) + 1;
});
const roleCounts: Record<string, number> = {};
users.forEach((user) => {
const roleKey = (user.role ?? "COLLABORATOR").toUpperCase();
roleCounts[roleKey] = (roleCounts[roleKey] ?? 0) + 1;
});
const trend = Array.from(trendMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, value]) => ({ date, opened: value.opened, resolved: value.resolved }));
const machineOsDistribution = Object.entries(machineOsCounts)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const openTicketSummaries = openTickets
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
.slice(0, 20)
.map((ticket) => {
const machineSnapshot = ticket.machineSnapshot as { hostname?: string } | undefined;
const machine = ticket.machineId ? machineLookup.get(String(ticket.machineId)) : null;
return {
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: normalizeStatus(ticket.status),
priority: ticket.priority,
updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt,
machine: ticket.machineId
? {
id: ticket.machineId,
hostname: machine?.hostname ?? machineSnapshot?.hostname ?? null,
}
: null,
assignee: ticket.assigneeSnapshot
? {
name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null,
email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null,
}
: null,
};
});
return {
company: {
id: company._id,
name: company.name,
isAvulso: company.isAvulso ?? false,
},
rangeDays,
generatedAt: now,
tickets: {
total: tickets.length,
byStatus: statusCounts,
byPriority: priorityCounts,
byChannel: channelCounts,
trend,
open: openTicketSummaries,
},
machines: {
total: machines.length,
byStatus: machineStatusCounts,
byOs: machineOsDistribution,
},
users: {
total: users.length,
byRole: roleCounts,
},
};
},
});

View file

@ -7,6 +7,8 @@ export default defineSchema({
name: v.string(),
email: v.string(),
role: v.optional(v.string()),
jobTitle: v.optional(v.string()),
managerId: v.optional(v.id("users")),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
@ -14,7 +16,8 @@ export default defineSchema({
.index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"])
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"]),
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_manager", ["tenantId", "managerId"]),
companies: defineTable({
tenantId: v.string(),
@ -135,6 +138,16 @@ export default defineSchema({
isAvulso: v.optional(v.boolean()),
})
),
machineId: v.optional(v.id("machines")),
machineSnapshot: v.optional(
v.object({
hostname: v.optional(v.string()),
persona: v.optional(v.string()),
assignedUserName: v.optional(v.string()),
assignedUserEmail: v.optional(v.string()),
status: v.optional(v.string()),
})
),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch
@ -167,6 +180,7 @@ export default defineSchema({
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant_requester", ["tenantId", "requesterId"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_tenant", ["tenantId"]),
ticketComments: defineTable({

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",

View file

@ -15,6 +15,8 @@ export const ensureUser = mutation({
role: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
jobTitle: v.optional(v.string()),
managerId: v.optional(v.id("users")),
},
handler: async (ctx, args) => {
const existing = await ctx.db
@ -23,23 +25,38 @@ export const ensureUser = mutation({
.first();
const reconcile = async (record: typeof existing) => {
if (!record) return null;
const hasJobTitleArg = Object.prototype.hasOwnProperty.call(args, "jobTitle");
const hasManagerArg = Object.prototype.hasOwnProperty.call(args, "managerId");
const jobTitleChanged = hasJobTitleArg ? (record.jobTitle ?? null) !== (args.jobTitle ?? null) : false;
const managerChanged = hasManagerArg
? String(record.managerId ?? "") !== String(args.managerId ?? "")
: false;
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 ?? [])) ||
(args.companyId && record.companyId !== args.companyId);
(args.companyId && record.companyId !== args.companyId) ||
jobTitleChanged ||
managerChanged;
if (shouldPatch) {
await ctx.db.patch(record._id, {
const patch: Record<string, unknown> = {
tenantId: args.tenantId,
role: args.role ?? record.role,
avatarUrl: args.avatarUrl ?? record.avatarUrl,
name: args.name,
teams: args.teams ?? record.teams,
companyId: args.companyId ?? record.companyId,
});
};
if (hasJobTitleArg) {
patch.jobTitle = args.jobTitle ?? undefined;
}
if (hasManagerArg) {
patch.managerId = args.managerId ?? undefined;
}
await ctx.db.patch(record._id, patch);
const updated = await ctx.db.get(record._id);
if (updated) {
return updated;
@ -70,6 +87,8 @@ export const ensureUser = mutation({
role: args.role ?? "AGENT",
teams: args.teams ?? [],
companyId: args.companyId,
jobTitle: args.jobTitle,
managerId: args.managerId,
});
return await ctx.db.get(id);
},
@ -151,6 +170,8 @@ export const listCustomers = query({
companyName: company?.name ?? null,
companyIsAvulso: Boolean(company?.isAvulso),
avatarUrl: user.avatarUrl ?? null,
jobTitle: user.jobTitle ?? null,
managerId: user.managerId ? String(user.managerId) : null,
}
})
.sort((a, b) => a.name.localeCompare(b.name ?? "", "pt-BR"))
@ -239,6 +260,17 @@ export const deleteUser = mutation({
}
}
// Limpa vínculo de subordinados
const directReports = await ctx.db
.query("users")
.withIndex("by_tenant_manager", (q) => q.eq("tenantId", user.tenantId).eq("managerId", userId))
.collect();
await Promise.all(
directReports.map(async (report) => {
await ctx.db.patch(report._id, { managerId: undefined });
})
);
await ctx.db.delete(userId);
return { status: "deleted" };
},