feat: export reports as xlsx and add machine inventory
This commit is contained in:
parent
29b865885c
commit
714b199879
34 changed files with 2304 additions and 245 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue