chore: expand reports coverage and upgrade next
This commit is contained in:
parent
2fb587b01d
commit
8b82284e8c
21 changed files with 2952 additions and 2713 deletions
|
|
@ -1,7 +1,8 @@
|
|||
// ci: trigger convex functions deploy (no-op)
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { api } from "./_generated/api"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
import { paginationOptsValidator } from "convex/server"
|
||||
import { ConvexError, v, Infer } from "convex/values"
|
||||
import { sha256 } from "@noble/hashes/sha256"
|
||||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
|
|
@ -12,6 +13,8 @@ const DEFAULT_TENANT_ID = "tenant-atlas"
|
|||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
|
||||
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
||||
const OPEN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"])
|
||||
const MACHINE_TICKETS_STATS_PAGE_SIZE = 200
|
||||
|
||||
type NormalizedIdentifiers = {
|
||||
macs: string[]
|
||||
|
|
@ -876,123 +879,128 @@ export const listByTenant = query({
|
|||
},
|
||||
})
|
||||
|
||||
export async function getByIdHandler(
|
||||
ctx: QueryCtx,
|
||||
args: { id: Id<"machines">; includeMetadata?: boolean }
|
||||
) {
|
||||
const includeMetadata = Boolean(args.includeMetadata)
|
||||
const now = Date.now()
|
||||
|
||||
const machine = await ctx.db.get(args.id)
|
||||
if (!machine) return null
|
||||
|
||||
const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null
|
||||
const machineSlug = machine.companySlug ?? null
|
||||
let companyFromSlug: typeof companyFromId | null = null
|
||||
if (!companyFromId && machineSlug) {
|
||||
companyFromSlug = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug))
|
||||
.unique()
|
||||
}
|
||||
const resolvedCompany = companyFromId ?? companyFromSlug
|
||||
|
||||
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
|
||||
|
||||
const offlineThresholdMs = getOfflineThresholdMs()
|
||||
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
|
||||
const manualStatus = (machine.status ?? "").toLowerCase()
|
||||
let derivedStatus: string
|
||||
if (machine.isActive === false) {
|
||||
derivedStatus = "deactivated"
|
||||
} else if (["maintenance", "blocked"].includes(manualStatus)) {
|
||||
derivedStatus = manualStatus
|
||||
} else if (machine.lastHeartbeatAt) {
|
||||
const age = now - machine.lastHeartbeatAt
|
||||
if (age <= offlineThresholdMs) {
|
||||
derivedStatus = "online"
|
||||
} else if (age <= staleThresholdMs) {
|
||||
derivedStatus = "offline"
|
||||
} else {
|
||||
derivedStatus = "stale"
|
||||
}
|
||||
} else {
|
||||
derivedStatus = machine.status ?? "unknown"
|
||||
}
|
||||
|
||||
const meta = includeMetadata ? (machine.metadata ?? null) : null
|
||||
let metrics: Record<string, unknown> | null = null
|
||||
let inventory: Record<string, unknown> | null = null
|
||||
let postureAlerts: Array<Record<string, unknown>> | null = null
|
||||
let lastPostureAt: number | null = null
|
||||
if (meta && typeof meta === "object") {
|
||||
const metaRecord = meta as Record<string, unknown>
|
||||
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
|
||||
metrics = metaRecord.metrics as Record<string, unknown>
|
||||
}
|
||||
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
||||
inventory = metaRecord.inventory as Record<string, unknown>
|
||||
}
|
||||
if (Array.isArray(metaRecord.postureAlerts)) {
|
||||
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
|
||||
}
|
||||
if (typeof metaRecord.lastPostureAt === "number") {
|
||||
lastPostureAt = metaRecord.lastPostureAt as number
|
||||
}
|
||||
}
|
||||
|
||||
const linkedUserIds = machine.linkedUserIds ?? []
|
||||
const linkedUsers = await Promise.all(
|
||||
linkedUserIds.map(async (id) => {
|
||||
const u = await ctx.db.get(id)
|
||||
if (!u) return null
|
||||
return { id: u._id, email: u.email, name: u.name }
|
||||
})
|
||||
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
|
||||
|
||||
return {
|
||||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
osName: machine.osName,
|
||||
osVersion: machine.osVersion ?? null,
|
||||
architecture: machine.architecture ?? null,
|
||||
macAddresses: machine.macAddresses,
|
||||
serialNumbers: machine.serialNumbers,
|
||||
authUserId: machine.authUserId ?? null,
|
||||
authEmail: machine.authEmail ?? null,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
linkedUsers,
|
||||
status: derivedStatus,
|
||||
isActive: machine.isActive ?? true,
|
||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
||||
registeredBy: machine.registeredBy ?? null,
|
||||
createdAt: machine.createdAt,
|
||||
updatedAt: machine.updatedAt,
|
||||
token: activeToken
|
||||
? {
|
||||
expiresAt: activeToken.expiresAt,
|
||||
lastUsedAt: activeToken.lastUsedAt ?? null,
|
||||
usageCount: activeToken.usageCount ?? 0,
|
||||
}
|
||||
: null,
|
||||
metrics,
|
||||
inventory,
|
||||
postureAlerts,
|
||||
lastPostureAt,
|
||||
remoteAccess: machine.remoteAccess ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: {
|
||||
id: v.id("machines"),
|
||||
includeMetadata: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const includeMetadata = Boolean(args.includeMetadata)
|
||||
const now = Date.now()
|
||||
|
||||
const machine = await ctx.db.get(args.id)
|
||||
if (!machine) return null
|
||||
|
||||
const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null
|
||||
const machineSlug = machine.companySlug ?? null
|
||||
let companyFromSlug: typeof companyFromId | null = null
|
||||
if (!companyFromId && machineSlug) {
|
||||
companyFromSlug = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug))
|
||||
.unique()
|
||||
}
|
||||
const resolvedCompany = companyFromId ?? companyFromSlug
|
||||
|
||||
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
|
||||
|
||||
const offlineThresholdMs = getOfflineThresholdMs()
|
||||
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
|
||||
const manualStatus = (machine.status ?? "").toLowerCase()
|
||||
let derivedStatus: string
|
||||
if (machine.isActive === false) {
|
||||
derivedStatus = "deactivated"
|
||||
} else if (["maintenance", "blocked"].includes(manualStatus)) {
|
||||
derivedStatus = manualStatus
|
||||
} else if (machine.lastHeartbeatAt) {
|
||||
const age = now - machine.lastHeartbeatAt
|
||||
if (age <= offlineThresholdMs) {
|
||||
derivedStatus = "online"
|
||||
} else if (age <= staleThresholdMs) {
|
||||
derivedStatus = "offline"
|
||||
} else {
|
||||
derivedStatus = "stale"
|
||||
}
|
||||
} else {
|
||||
derivedStatus = machine.status ?? "unknown"
|
||||
}
|
||||
|
||||
const meta = includeMetadata ? (machine.metadata ?? null) : null
|
||||
let metrics: Record<string, unknown> | null = null
|
||||
let inventory: Record<string, unknown> | null = null
|
||||
let postureAlerts: Array<Record<string, unknown>> | null = null
|
||||
let lastPostureAt: number | null = null
|
||||
if (meta && typeof meta === "object") {
|
||||
const metaRecord = meta as Record<string, unknown>
|
||||
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
|
||||
metrics = metaRecord.metrics as Record<string, unknown>
|
||||
}
|
||||
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
||||
inventory = metaRecord.inventory as Record<string, unknown>
|
||||
}
|
||||
if (Array.isArray(metaRecord.postureAlerts)) {
|
||||
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
|
||||
}
|
||||
if (typeof metaRecord.lastPostureAt === "number") {
|
||||
lastPostureAt = metaRecord.lastPostureAt as number
|
||||
}
|
||||
}
|
||||
|
||||
const linkedUserIds = machine.linkedUserIds ?? []
|
||||
const linkedUsers = await Promise.all(
|
||||
linkedUserIds.map(async (id) => {
|
||||
const u = await ctx.db.get(id)
|
||||
if (!u) return null
|
||||
return { id: u._id, email: u.email, name: u.name }
|
||||
})
|
||||
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
|
||||
|
||||
return {
|
||||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
osName: machine.osName,
|
||||
osVersion: machine.osVersion ?? null,
|
||||
architecture: machine.architecture ?? null,
|
||||
macAddresses: machine.macAddresses,
|
||||
serialNumbers: machine.serialNumbers,
|
||||
authUserId: machine.authUserId ?? null,
|
||||
authEmail: machine.authEmail ?? null,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
linkedUsers,
|
||||
status: derivedStatus,
|
||||
isActive: machine.isActive ?? true,
|
||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
||||
registeredBy: machine.registeredBy ?? null,
|
||||
createdAt: machine.createdAt,
|
||||
updatedAt: machine.updatedAt,
|
||||
token: activeToken
|
||||
? {
|
||||
expiresAt: activeToken.expiresAt,
|
||||
lastUsedAt: activeToken.lastUsedAt ?? null,
|
||||
usageCount: activeToken.usageCount ?? 0,
|
||||
}
|
||||
: null,
|
||||
metrics,
|
||||
inventory,
|
||||
postureAlerts,
|
||||
lastPostureAt,
|
||||
remoteAccess: machine.remoteAccess ?? null,
|
||||
}
|
||||
},
|
||||
handler: getByIdHandler,
|
||||
})
|
||||
|
||||
export const listAlerts = query({
|
||||
|
|
@ -1029,7 +1037,7 @@ export const listOpenTickets = query({
|
|||
handler: async (ctx, { machineId, limit }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
return []
|
||||
return { totalOpen: 0, hasMore: false, tickets: [] }
|
||||
}
|
||||
const takeLimit = Math.max(1, Math.min(limit ?? 10, 50))
|
||||
const candidates = await ctx.db
|
||||
|
|
@ -1041,31 +1049,392 @@ export const listOpenTickets = query({
|
|||
const openTickets = candidates
|
||||
.filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED")
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||
.slice(0, takeLimit)
|
||||
const totalOpen = openTickets.length
|
||||
const limited = openTickets.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),
|
||||
},
|
||||
}))
|
||||
return {
|
||||
totalOpen,
|
||||
hasMore: totalOpen > takeLimit,
|
||||
tickets: limited.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),
|
||||
},
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type MachineTicketsHistoryFilter = {
|
||||
statusFilter: "all" | "open" | "resolved"
|
||||
priorityFilter: string | null
|
||||
from: number | null
|
||||
to: number | null
|
||||
}
|
||||
|
||||
type ListTicketsHistoryArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "all" | "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
paginationOpts: Infer<typeof paginationOptsValidator>
|
||||
}
|
||||
|
||||
type GetTicketsHistoryStatsArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "all" | "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
function createMachineTicketsQuery(
|
||||
ctx: QueryCtx,
|
||||
machine: Doc<"machines">,
|
||||
machineId: Id<"machines">,
|
||||
filters: MachineTicketsHistoryFilter
|
||||
) {
|
||||
let working = ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
|
||||
.order("desc")
|
||||
|
||||
if (filters.statusFilter === "open") {
|
||||
working = working.filter((q) =>
|
||||
q.or(
|
||||
q.eq(q.field("status"), "PENDING"),
|
||||
q.eq(q.field("status"), "AWAITING_ATTENDANCE"),
|
||||
q.eq(q.field("status"), "PAUSED")
|
||||
)
|
||||
)
|
||||
} else if (filters.statusFilter === "resolved") {
|
||||
working = working.filter((q) => q.eq(q.field("status"), "RESOLVED"))
|
||||
}
|
||||
|
||||
if (filters.priorityFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter))
|
||||
}
|
||||
|
||||
if (typeof filters.from === "number") {
|
||||
working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!))
|
||||
}
|
||||
|
||||
if (typeof filters.to === "number") {
|
||||
working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!))
|
||||
}
|
||||
|
||||
return working
|
||||
}
|
||||
|
||||
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
|
||||
const normalized = searchTerm.trim().toLowerCase()
|
||||
if (!normalized) return true
|
||||
|
||||
const subject = ticket.subject.toLowerCase()
|
||||
if (subject.includes(normalized)) return true
|
||||
|
||||
const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : ""
|
||||
if (summary.includes(normalized)) return true
|
||||
|
||||
const reference = `#${ticket.reference}`.toLowerCase()
|
||||
if (reference.includes(normalized)) return true
|
||||
|
||||
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||
if (requesterSnapshot) {
|
||||
if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true
|
||||
if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true
|
||||
}
|
||||
|
||||
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
|
||||
if (assigneeSnapshot) {
|
||||
if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true
|
||||
if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
return {
|
||||
page: [],
|
||||
isDone: true,
|
||||
continueCursor: args.paginationOpts.cursor ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStatusFilter = args.status ?? "all"
|
||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||
const searchTerm = args.search?.trim().toLowerCase() ?? null
|
||||
const from = typeof args.from === "number" ? args.from : null
|
||||
const to = typeof args.to === "number" ? args.to : null
|
||||
const filters: MachineTicketsHistoryFilter = {
|
||||
statusFilter: normalizedStatusFilter,
|
||||
priorityFilter: normalizedPriorityFilter,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
|
||||
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
|
||||
|
||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
||||
const queueCache = new Map<string, Doc<"queues"> | null>()
|
||||
const items = await Promise.all(
|
||||
page.map(async (ticket) => {
|
||||
let queueName: string | null = null
|
||||
if (ticket.queueId) {
|
||||
const key = String(ticket.queueId)
|
||||
if (!queueCache.has(key)) {
|
||||
queueCache.set(key, (await ctx.db.get(ticket.queueId)) as Doc<"queues"> | null)
|
||||
}
|
||||
queueName = queueCache.get(key)?.name ?? null
|
||||
}
|
||||
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
|
||||
return {
|
||||
id: ticket._id,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: normalizeStatus(ticket.status),
|
||||
priority: (ticket.priority ?? "MEDIUM").toString().toUpperCase(),
|
||||
updatedAt: ticket.updatedAt ?? ticket.createdAt ?? 0,
|
||||
createdAt: ticket.createdAt ?? 0,
|
||||
queue: queueName,
|
||||
requester: requesterSnapshot
|
||||
? {
|
||||
name: requesterSnapshot.name ?? null,
|
||||
email: requesterSnapshot.email ?? null,
|
||||
}
|
||||
: null,
|
||||
assignee: assigneeSnapshot
|
||||
? {
|
||||
name: assigneeSnapshot.name ?? null,
|
||||
email: assigneeSnapshot.email ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
page: items,
|
||||
isDone: pageResult.isDone,
|
||||
continueCursor: pageResult.continueCursor,
|
||||
splitCursor: pageResult.splitCursor ?? undefined,
|
||||
pageStatus: pageResult.pageStatus ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const listTicketsHistory = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||
priority: v.optional(v.string()),
|
||||
search: v.optional(v.string()),
|
||||
from: v.optional(v.number()),
|
||||
to: v.optional(v.number()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: listTicketsHistoryHandler,
|
||||
})
|
||||
|
||||
export async function getTicketsHistoryStatsHandler(
|
||||
ctx: QueryCtx,
|
||||
args: GetTicketsHistoryStatsArgs
|
||||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
return { total: 0, openCount: 0, resolvedCount: 0 }
|
||||
}
|
||||
|
||||
const normalizedStatusFilter = args.status ?? "all"
|
||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||
const searchTerm = args.search?.trim().toLowerCase() ?? ""
|
||||
const from = typeof args.from === "number" ? args.from : null
|
||||
const to = typeof args.to === "number" ? args.to : null
|
||||
const filters: MachineTicketsHistoryFilter = {
|
||||
statusFilter: normalizedStatusFilter,
|
||||
priorityFilter: normalizedPriorityFilter,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
|
||||
let cursor: string | null = null
|
||||
let total = 0
|
||||
let openCount = 0
|
||||
let done = false
|
||||
|
||||
while (!done) {
|
||||
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({
|
||||
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
|
||||
cursor,
|
||||
})
|
||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
||||
total += page.length
|
||||
for (const ticket of page) {
|
||||
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
|
||||
openCount += 1
|
||||
}
|
||||
}
|
||||
done = pageResult.isDone
|
||||
cursor = pageResult.continueCursor ?? null
|
||||
if (!cursor) {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
openCount,
|
||||
resolvedCount: total - openCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const getTicketsHistoryStats = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||
priority: v.optional(v.string()),
|
||||
search: v.optional(v.string()),
|
||||
from: v.optional(v.number()),
|
||||
to: v.optional(v.number()),
|
||||
},
|
||||
handler: getTicketsHistoryStatsHandler,
|
||||
})
|
||||
|
||||
export async function updatePersonaHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
machineId: Id<"machines">
|
||||
persona?: string | null
|
||||
assignedUserId?: Id<"users">
|
||||
assignedUserEmail?: string | null
|
||||
assignedUserName?: string | null
|
||||
assignedUserRole?: string | null
|
||||
}
|
||||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
}
|
||||
|
||||
let nextPersona = machine.persona ?? undefined
|
||||
const personaProvided = args.persona !== undefined
|
||||
if (args.persona !== undefined) {
|
||||
const trimmed = (args.persona ?? "").trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
throw new ConvexError("Perfil inválido para a máquina")
|
||||
} else {
|
||||
nextPersona = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let nextAssignedUserId = machine.assignedUserId ?? undefined
|
||||
if (args.assignedUserId !== undefined) {
|
||||
nextAssignedUserId = args.assignedUserId
|
||||
}
|
||||
|
||||
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
|
||||
if (args.assignedUserEmail !== undefined) {
|
||||
const trimmedEmail = (args.assignedUserEmail ?? "").trim().toLowerCase()
|
||||
nextAssignedEmail = trimmedEmail || undefined
|
||||
}
|
||||
|
||||
let nextAssignedName = machine.assignedUserName ?? undefined
|
||||
if (args.assignedUserName !== undefined) {
|
||||
const trimmedName = (args.assignedUserName ?? "").trim()
|
||||
nextAssignedName = trimmedName || undefined
|
||||
}
|
||||
|
||||
let nextAssignedRole = machine.assignedUserRole ?? undefined
|
||||
if (args.assignedUserRole !== undefined) {
|
||||
const trimmedRole = (args.assignedUserRole ?? "").trim().toUpperCase()
|
||||
nextAssignedRole = trimmedRole || undefined
|
||||
}
|
||||
|
||||
if (personaProvided && !nextPersona) {
|
||||
nextAssignedUserId = undefined
|
||||
nextAssignedEmail = undefined
|
||||
nextAssignedName = undefined
|
||||
nextAssignedRole = undefined
|
||||
}
|
||||
|
||||
if (nextPersona && !nextAssignedUserId) {
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
||||
}
|
||||
|
||||
if (nextPersona && nextAssignedUserId) {
|
||||
const assignedUser = await ctx.db.get(nextAssignedUserId)
|
||||
if (!assignedUser) {
|
||||
throw new ConvexError("Usuário vinculado não encontrado")
|
||||
}
|
||||
if (assignedUser.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Usuário vinculado pertence a outro tenant")
|
||||
}
|
||||
}
|
||||
|
||||
let nextMetadata = machine.metadata
|
||||
if (nextPersona) {
|
||||
const collaboratorMeta = {
|
||||
email: nextAssignedEmail ?? null,
|
||||
name: nextAssignedName ?? null,
|
||||
role: nextPersona,
|
||||
}
|
||||
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
persona: nextPersona,
|
||||
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
|
||||
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
|
||||
assignedUserName: nextPersona ? nextAssignedName : undefined,
|
||||
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
if (nextMetadata !== machine.metadata) {
|
||||
patch.metadata = nextMetadata
|
||||
}
|
||||
|
||||
if (personaProvided) {
|
||||
patch.persona = nextPersona
|
||||
}
|
||||
|
||||
if (nextPersona) {
|
||||
patch.assignedUserId = nextAssignedUserId
|
||||
patch.assignedUserEmail = nextAssignedEmail
|
||||
patch.assignedUserName = nextAssignedName
|
||||
patch.assignedUserRole = nextAssignedRole
|
||||
} else if (personaProvided) {
|
||||
patch.assignedUserId = undefined
|
||||
patch.assignedUserEmail = undefined
|
||||
patch.assignedUserName = undefined
|
||||
patch.assignedUserRole = undefined
|
||||
}
|
||||
|
||||
await ctx.db.patch(machine._id, patch)
|
||||
return { ok: true, persona: nextPersona ?? null }
|
||||
}
|
||||
|
||||
export const updatePersona = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
@ -1075,110 +1444,7 @@ export const updatePersona = mutation({
|
|||
assignedUserName: v.optional(v.string()),
|
||||
assignedUserRole: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
}
|
||||
|
||||
let nextPersona = machine.persona ?? undefined
|
||||
const personaProvided = args.persona !== undefined
|
||||
if (args.persona !== undefined) {
|
||||
const trimmed = args.persona.trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
throw new ConvexError("Perfil inválido para a máquina")
|
||||
} else {
|
||||
nextPersona = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let nextAssignedUserId = machine.assignedUserId ?? undefined
|
||||
if (args.assignedUserId !== undefined) {
|
||||
nextAssignedUserId = args.assignedUserId
|
||||
}
|
||||
|
||||
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
|
||||
if (args.assignedUserEmail !== undefined) {
|
||||
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
|
||||
nextAssignedEmail = trimmedEmail || undefined
|
||||
}
|
||||
|
||||
let nextAssignedName = machine.assignedUserName ?? undefined
|
||||
if (args.assignedUserName !== undefined) {
|
||||
const trimmedName = args.assignedUserName.trim()
|
||||
nextAssignedName = trimmedName || undefined
|
||||
}
|
||||
|
||||
let nextAssignedRole = machine.assignedUserRole ?? undefined
|
||||
if (args.assignedUserRole !== undefined) {
|
||||
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
|
||||
nextAssignedRole = trimmedRole || undefined
|
||||
}
|
||||
|
||||
if (personaProvided && !nextPersona) {
|
||||
nextAssignedUserId = undefined
|
||||
nextAssignedEmail = undefined
|
||||
nextAssignedName = undefined
|
||||
nextAssignedRole = undefined
|
||||
}
|
||||
|
||||
if (nextPersona && !nextAssignedUserId) {
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
||||
}
|
||||
|
||||
if (nextPersona && nextAssignedUserId) {
|
||||
const assignedUser = await ctx.db.get(nextAssignedUserId)
|
||||
if (!assignedUser) {
|
||||
throw new ConvexError("Usuário vinculado não encontrado")
|
||||
}
|
||||
if (assignedUser.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Usuário vinculado pertence a outro tenant")
|
||||
}
|
||||
}
|
||||
|
||||
let nextMetadata = machine.metadata
|
||||
if (nextPersona) {
|
||||
const collaboratorMeta = {
|
||||
email: nextAssignedEmail ?? null,
|
||||
name: nextAssignedName ?? null,
|
||||
role: nextPersona,
|
||||
}
|
||||
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
persona: nextPersona,
|
||||
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
|
||||
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
|
||||
assignedUserName: nextPersona ? nextAssignedName : undefined,
|
||||
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
if (nextMetadata !== machine.metadata) {
|
||||
patch.metadata = nextMetadata
|
||||
}
|
||||
|
||||
if (personaProvided) {
|
||||
patch.persona = nextPersona
|
||||
}
|
||||
|
||||
if (nextPersona) {
|
||||
patch.assignedUserId = nextAssignedUserId
|
||||
patch.assignedUserEmail = nextAssignedEmail
|
||||
patch.assignedUserName = nextAssignedName
|
||||
patch.assignedUserRole = nextAssignedRole
|
||||
} else if (personaProvided) {
|
||||
patch.assignedUserId = undefined
|
||||
patch.assignedUserEmail = undefined
|
||||
patch.assignedUserName = undefined
|
||||
patch.assignedUserRole = undefined
|
||||
}
|
||||
|
||||
await ctx.db.patch(machine._id, patch)
|
||||
return { ok: true, persona: nextPersona ?? null }
|
||||
},
|
||||
handler: updatePersonaHandler,
|
||||
})
|
||||
|
||||
export const getContext = query({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue