Problema: Convex backend consumindo 16GB+ de RAM causando OOM kills Correcoes aplicadas: - Substituido todos os .collect() por .take(LIMIT) em 27+ arquivos - Adicionado indice by_usbPolicyStatus para otimizar query de maquinas - Corrigido N+1 problem em alerts.ts usando Map lookup - Corrigido full table scan em usbPolicy.ts - Corrigido subscription leaks no frontend (tickets-view, use-ticket-categories) - Atualizado versao do Convex backend para precompiled-2025-12-04-cc6af4c Arquivos principais modificados: - convex/*.ts - limites em todas as queries .collect() - convex/schema.ts - novo indice by_usbPolicyStatus - convex/alerts.ts - N+1 fix com Map - convex/usbPolicy.ts - uso do novo indice - src/components/tickets/tickets-view.tsx - skip condicional - src/hooks/use-ticket-categories.ts - skip condicional 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
929 lines
29 KiB
TypeScript
929 lines
29 KiB
TypeScript
import { randomBytes } from "@noble/hashes/utils.js"
|
|
import { ConvexError, v } from "convex/values"
|
|
|
|
import { mutation, query } from "./_generated/server"
|
|
import type { Id } from "./_generated/dataModel"
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
|
|
|
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
|
|
|
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"])
|
|
|
|
function normalizeEmail(value: string) {
|
|
return value.trim().toLowerCase()
|
|
}
|
|
|
|
function generateProvisioningCode() {
|
|
return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")
|
|
}
|
|
|
|
type ImportedUser = {
|
|
email: string
|
|
name: string
|
|
role?: string | null
|
|
avatarUrl?: string | null
|
|
teams?: string[] | null
|
|
companySlug?: string | null
|
|
}
|
|
|
|
type ImportedQueue = {
|
|
slug?: string | null
|
|
name: string
|
|
}
|
|
|
|
type ImportedCompany = {
|
|
slug: string
|
|
name: string
|
|
provisioningCode?: string | null
|
|
isAvulso?: boolean | null
|
|
cnpj?: string | null
|
|
domain?: string | null
|
|
phone?: string | null
|
|
description?: string | null
|
|
address?: string | null
|
|
createdAt?: number | null
|
|
updatedAt?: number | null
|
|
}
|
|
|
|
function normalizeRole(role: string | null | undefined) {
|
|
if (!role) return "AGENT"
|
|
const normalized = role.toUpperCase()
|
|
if (VALID_ROLES.has(normalized)) return normalized
|
|
// map legacy CUSTOMER to MANAGER
|
|
if (normalized === "CUSTOMER") return "MANAGER"
|
|
return "AGENT"
|
|
}
|
|
|
|
function slugify(value: string) {
|
|
return value
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^\w\s-]/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.toLowerCase()
|
|
}
|
|
|
|
function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
|
|
for (const key of Object.keys(input)) {
|
|
if (input[key] === undefined) {
|
|
delete input[key]
|
|
}
|
|
}
|
|
return input
|
|
}
|
|
|
|
type TicketSlaSnapshotRecord = {
|
|
categoryId?: Id<"ticketCategories">
|
|
categoryName?: string
|
|
priority?: string
|
|
responseTargetMinutes?: number
|
|
responseMode?: string
|
|
solutionTargetMinutes?: number
|
|
solutionMode?: string
|
|
alertThreshold?: number
|
|
pauseStatuses?: string[]
|
|
}
|
|
|
|
type ExportedSlaSnapshot = {
|
|
categoryId?: string
|
|
categoryName?: string
|
|
priority?: string
|
|
responseTargetMinutes?: number
|
|
responseMode?: string
|
|
solutionTargetMinutes?: number
|
|
solutionMode?: string
|
|
alertThreshold?: number
|
|
pauseStatuses?: string[]
|
|
}
|
|
|
|
function serializeSlaSnapshot(snapshot?: TicketSlaSnapshotRecord | null): ExportedSlaSnapshot | undefined {
|
|
if (!snapshot) return undefined
|
|
const exported = pruneUndefined<ExportedSlaSnapshot>({
|
|
categoryId: snapshot.categoryId ? String(snapshot.categoryId) : undefined,
|
|
categoryName: snapshot.categoryName,
|
|
priority: snapshot.priority,
|
|
responseTargetMinutes: snapshot.responseTargetMinutes,
|
|
responseMode: snapshot.responseMode,
|
|
solutionTargetMinutes: snapshot.solutionTargetMinutes,
|
|
solutionMode: snapshot.solutionMode,
|
|
alertThreshold: snapshot.alertThreshold,
|
|
pauseStatuses: snapshot.pauseStatuses && snapshot.pauseStatuses.length > 0 ? snapshot.pauseStatuses : undefined,
|
|
})
|
|
return Object.keys(exported).length > 0 ? exported : undefined
|
|
}
|
|
|
|
function normalizeImportedSlaSnapshot(snapshot: unknown): TicketSlaSnapshotRecord | undefined {
|
|
if (!snapshot || typeof snapshot !== "object") return undefined
|
|
const record = snapshot as Record<string, unknown>
|
|
const pauseStatuses = Array.isArray(record.pauseStatuses)
|
|
? record.pauseStatuses.filter((value): value is string => typeof value === "string")
|
|
: undefined
|
|
|
|
const normalized = pruneUndefined<TicketSlaSnapshotRecord>({
|
|
categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined,
|
|
priority: typeof record.priority === "string" ? record.priority : undefined,
|
|
responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined,
|
|
responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined,
|
|
solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined,
|
|
solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined,
|
|
alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined,
|
|
pauseStatuses: pauseStatuses && pauseStatuses.length > 0 ? pauseStatuses : undefined,
|
|
})
|
|
|
|
return Object.keys(normalized).length > 0 ? normalized : undefined
|
|
}
|
|
|
|
async function ensureUser(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
data: ImportedUser,
|
|
cache: Map<string, Id<"users">>,
|
|
companyCache: Map<string, Id<"companies">>
|
|
) {
|
|
if (cache.has(data.email)) {
|
|
return cache.get(data.email)!
|
|
}
|
|
const existing = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email))
|
|
.first()
|
|
|
|
const role = normalizeRole(data.role)
|
|
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
|
|
const record = existing
|
|
? (() => {
|
|
const needsPatch =
|
|
existing.name !== data.name ||
|
|
existing.role !== role ||
|
|
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
|
|
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
|
|
(existing.companyId ?? undefined) !== companyId
|
|
if (needsPatch) {
|
|
return ctx.db.patch(existing._id, {
|
|
name: data.name,
|
|
role,
|
|
avatarUrl: data.avatarUrl ?? undefined,
|
|
teams: data.teams ?? undefined,
|
|
tenantId,
|
|
companyId,
|
|
})
|
|
}
|
|
return Promise.resolve()
|
|
})()
|
|
: ctx.db.insert("users", {
|
|
tenantId,
|
|
email: data.email,
|
|
name: data.name,
|
|
role,
|
|
avatarUrl: data.avatarUrl ?? undefined,
|
|
teams: data.teams ?? undefined,
|
|
companyId,
|
|
})
|
|
|
|
const id = existing ? existing._id : ((await record) as Id<"users">)
|
|
cache.set(data.email, id)
|
|
return id
|
|
}
|
|
|
|
async function ensureQueue(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
data: ImportedQueue,
|
|
cache: Map<string, Id<"queues">>
|
|
) {
|
|
const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name)
|
|
if (cache.has(slug)) return cache.get(slug)!
|
|
|
|
const bySlug = await ctx.db
|
|
.query("queues")
|
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
|
.first()
|
|
if (bySlug) {
|
|
if (bySlug.name !== data.name) {
|
|
await ctx.db.patch(bySlug._id, { name: data.name })
|
|
}
|
|
cache.set(slug, bySlug._id)
|
|
return bySlug._id
|
|
}
|
|
|
|
const byName = await ctx.db
|
|
.query("queues")
|
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", data.name))
|
|
.first()
|
|
if (byName) {
|
|
if (byName.slug !== slug) {
|
|
await ctx.db.patch(byName._id, { slug })
|
|
}
|
|
cache.set(slug, byName._id)
|
|
return byName._id
|
|
}
|
|
|
|
const id = await ctx.db.insert("queues", {
|
|
tenantId,
|
|
name: data.name,
|
|
slug,
|
|
teamId: undefined,
|
|
})
|
|
cache.set(slug, id)
|
|
return id
|
|
}
|
|
|
|
async function ensureCompany(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
data: ImportedCompany,
|
|
cache: Map<string, Id<"companies">>
|
|
) {
|
|
const slug = data.slug || slugify(data.name)
|
|
if (cache.has(slug)) {
|
|
return cache.get(slug)!
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
|
.first()
|
|
|
|
const payload = pruneUndefined({
|
|
tenantId,
|
|
name: data.name,
|
|
slug,
|
|
provisioningCode: data.provisioningCode ?? existing?.provisioningCode ?? generateProvisioningCode(),
|
|
isAvulso: data.isAvulso ?? undefined,
|
|
cnpj: data.cnpj ?? undefined,
|
|
domain: data.domain ?? undefined,
|
|
phone: data.phone ?? undefined,
|
|
description: data.description ?? undefined,
|
|
address: data.address ?? undefined,
|
|
createdAt: data.createdAt ?? Date.now(),
|
|
updatedAt: data.updatedAt ?? Date.now(),
|
|
})
|
|
|
|
let id: Id<"companies">
|
|
if (existing) {
|
|
const existingIsAvulso = existing.isAvulso ?? undefined
|
|
const targetIsAvulso = payload.isAvulso ?? existingIsAvulso
|
|
const targetCnpj = payload.cnpj ?? undefined
|
|
const targetDomain = payload.domain ?? undefined
|
|
const targetPhone = payload.phone ?? undefined
|
|
const targetDescription = payload.description ?? undefined
|
|
const targetAddress = payload.address ?? undefined
|
|
|
|
const needsPatch =
|
|
existing.name !== payload.name ||
|
|
existingIsAvulso !== targetIsAvulso ||
|
|
(existing.cnpj ?? undefined) !== targetCnpj ||
|
|
(existing.domain ?? undefined) !== targetDomain ||
|
|
(existing.phone ?? undefined) !== targetPhone ||
|
|
(existing.description ?? undefined) !== targetDescription ||
|
|
(existing.address ?? undefined) !== targetAddress ||
|
|
existing.provisioningCode !== payload.provisioningCode
|
|
if (needsPatch) {
|
|
await ctx.db.patch(existing._id, {
|
|
name: payload.name,
|
|
isAvulso: targetIsAvulso,
|
|
cnpj: targetCnpj,
|
|
domain: targetDomain,
|
|
phone: targetPhone,
|
|
description: targetDescription,
|
|
address: targetAddress,
|
|
provisioningCode: payload.provisioningCode,
|
|
updatedAt: Date.now(),
|
|
})
|
|
}
|
|
id = existing._id
|
|
} else {
|
|
id = await ctx.db.insert("companies", payload)
|
|
}
|
|
|
|
cache.set(slug, id)
|
|
return id
|
|
}
|
|
|
|
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
|
|
return ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(2000)
|
|
}
|
|
|
|
async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
|
|
return ctx.db
|
|
.query("queues")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(500)
|
|
}
|
|
|
|
async function getTenantCompanies(ctx: QueryCtx, tenantId: string) {
|
|
return ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(1000)
|
|
}
|
|
|
|
export const exportTenantSnapshot = query({
|
|
args: {
|
|
secret: v.string(),
|
|
tenantId: v.string(),
|
|
},
|
|
handler: async (ctx, { secret, tenantId }) => {
|
|
if (secret !== SECRET) {
|
|
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
|
}
|
|
|
|
const [users, queues, companies] = await Promise.all([
|
|
getTenantUsers(ctx, tenantId),
|
|
getTenantQueues(ctx, tenantId),
|
|
getTenantCompanies(ctx, tenantId),
|
|
])
|
|
|
|
const userMap = new Map(users.map((user) => [user._id, user]))
|
|
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
|
|
const companyMap = new Map(companies.map((company) => [company._id, company]))
|
|
|
|
const tickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(5000)
|
|
|
|
const ticketsWithRelations = []
|
|
|
|
for (const ticket of tickets) {
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
|
.take(500)
|
|
|
|
const events = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
|
.take(500)
|
|
|
|
const requester = userMap.get(ticket.requesterId)
|
|
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
|
|
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
|
|
const company = ticket.companyId
|
|
? companyMap.get(ticket.companyId)
|
|
: requester?.companyId
|
|
? companyMap.get(requester.companyId)
|
|
: undefined
|
|
|
|
if (!requester) {
|
|
continue
|
|
}
|
|
|
|
ticketsWithRelations.push({
|
|
reference: ticket.reference,
|
|
subject: ticket.subject,
|
|
summary: ticket.summary ?? null,
|
|
status: ticket.status,
|
|
priority: ticket.priority,
|
|
channel: ticket.channel,
|
|
queueSlug: queue?.slug ?? undefined,
|
|
requesterEmail: requester.email,
|
|
assigneeEmail: assignee?.email ?? undefined,
|
|
companySlug: company?.slug ?? undefined,
|
|
dueAt: ticket.dueAt ?? undefined,
|
|
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
|
resolvedAt: ticket.resolvedAt ?? undefined,
|
|
closedAt: ticket.closedAt ?? undefined,
|
|
createdAt: ticket.createdAt,
|
|
updatedAt: ticket.updatedAt,
|
|
tags: ticket.tags ?? [],
|
|
slaSnapshot: serializeSlaSnapshot(ticket.slaSnapshot as TicketSlaSnapshotRecord | null),
|
|
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
|
|
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
|
|
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
|
|
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
|
|
slaPausedAt: ticket.slaPausedAt ?? undefined,
|
|
slaPausedBy: ticket.slaPausedBy ?? undefined,
|
|
slaPausedMs: ticket.slaPausedMs ?? undefined,
|
|
comments: comments
|
|
.map((comment) => {
|
|
const author = userMap.get(comment.authorId)
|
|
if (!author) {
|
|
return null
|
|
}
|
|
return {
|
|
authorEmail: author.email,
|
|
visibility: comment.visibility,
|
|
body: comment.body,
|
|
createdAt: comment.createdAt,
|
|
updatedAt: comment.updatedAt,
|
|
}
|
|
})
|
|
.filter((value): value is {
|
|
authorEmail: string
|
|
visibility: string
|
|
body: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
} => value !== null),
|
|
events: events.map((event) => ({
|
|
type: event.type,
|
|
payload: event.payload ?? {},
|
|
createdAt: event.createdAt,
|
|
})),
|
|
})
|
|
}
|
|
|
|
return {
|
|
tenantId,
|
|
companies: companies.map((company) => ({
|
|
slug: company.slug,
|
|
name: company.name,
|
|
isAvulso: company.isAvulso ?? false,
|
|
cnpj: company.cnpj ?? null,
|
|
domain: company.domain ?? null,
|
|
phone: company.phone ?? null,
|
|
description: company.description ?? null,
|
|
address: company.address ?? null,
|
|
createdAt: company.createdAt,
|
|
updatedAt: company.updatedAt,
|
|
})),
|
|
users: users.map((user) => ({
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role ?? null,
|
|
avatarUrl: user.avatarUrl ?? null,
|
|
teams: user.teams ?? [],
|
|
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
|
|
})),
|
|
queues: queues.map((queue) => ({
|
|
name: queue.name,
|
|
slug: queue.slug,
|
|
})),
|
|
tickets: ticketsWithRelations,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const importPrismaSnapshot = mutation({
|
|
args: {
|
|
secret: v.string(),
|
|
snapshot: v.object({
|
|
tenantId: v.string(),
|
|
companies: v.array(
|
|
v.object({
|
|
slug: v.string(),
|
|
name: v.string(),
|
|
cnpj: v.optional(v.string()),
|
|
domain: v.optional(v.string()),
|
|
phone: v.optional(v.string()),
|
|
description: v.optional(v.string()),
|
|
address: v.optional(v.string()),
|
|
createdAt: v.optional(v.number()),
|
|
updatedAt: v.optional(v.number()),
|
|
})
|
|
),
|
|
users: v.array(
|
|
v.object({
|
|
email: v.string(),
|
|
name: v.string(),
|
|
role: v.optional(v.string()),
|
|
avatarUrl: v.optional(v.string()),
|
|
teams: v.optional(v.array(v.string())),
|
|
companySlug: v.optional(v.string()),
|
|
})
|
|
),
|
|
queues: v.array(
|
|
v.object({
|
|
name: v.string(),
|
|
slug: v.optional(v.string()),
|
|
})
|
|
),
|
|
tickets: v.array(
|
|
v.object({
|
|
reference: v.number(),
|
|
subject: v.string(),
|
|
summary: v.optional(v.string()),
|
|
status: v.string(),
|
|
priority: v.string(),
|
|
channel: v.string(),
|
|
queueSlug: v.optional(v.string()),
|
|
requesterEmail: v.string(),
|
|
assigneeEmail: v.optional(v.string()),
|
|
companySlug: v.optional(v.string()),
|
|
dueAt: v.optional(v.number()),
|
|
firstResponseAt: v.optional(v.number()),
|
|
resolvedAt: v.optional(v.number()),
|
|
closedAt: v.optional(v.number()),
|
|
createdAt: v.number(),
|
|
updatedAt: v.number(),
|
|
tags: v.optional(v.array(v.string())),
|
|
slaSnapshot: v.optional(v.any()),
|
|
slaResponseDueAt: v.optional(v.number()),
|
|
slaSolutionDueAt: v.optional(v.number()),
|
|
slaResponseStatus: v.optional(v.string()),
|
|
slaSolutionStatus: v.optional(v.string()),
|
|
slaPausedAt: v.optional(v.number()),
|
|
slaPausedBy: v.optional(v.string()),
|
|
slaPausedMs: v.optional(v.number()),
|
|
comments: v.array(
|
|
v.object({
|
|
authorEmail: v.string(),
|
|
visibility: v.string(),
|
|
body: v.string(),
|
|
createdAt: v.number(),
|
|
updatedAt: v.number(),
|
|
})
|
|
),
|
|
events: v.array(
|
|
v.object({
|
|
type: v.string(),
|
|
payload: v.optional(v.any()),
|
|
createdAt: v.number(),
|
|
})
|
|
),
|
|
})
|
|
),
|
|
}),
|
|
},
|
|
handler: async (ctx, { secret, snapshot }) => {
|
|
if (!SECRET) {
|
|
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
|
}
|
|
if (secret !== SECRET) {
|
|
throw new ConvexError("Segredo inválido para sincronização")
|
|
}
|
|
|
|
const companyCache = new Map<string, Id<"companies">>()
|
|
const userCache = new Map<string, Id<"users">>()
|
|
const queueCache = new Map<string, Id<"queues">>()
|
|
|
|
for (const company of snapshot.companies) {
|
|
await ensureCompany(ctx, snapshot.tenantId, company, companyCache)
|
|
}
|
|
|
|
for (const user of snapshot.users) {
|
|
await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
|
|
}
|
|
|
|
for (const queue of snapshot.queues) {
|
|
await ensureQueue(ctx, snapshot.tenantId, queue, queueCache)
|
|
}
|
|
|
|
const snapshotStaffEmails = new Set(
|
|
snapshot.users
|
|
.filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
|
|
.map((user) => normalizeEmail(user.email))
|
|
)
|
|
|
|
const existingTenantUsers = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId))
|
|
.take(2000)
|
|
|
|
for (const user of existingTenantUsers) {
|
|
const role = normalizeRole(user.role ?? null)
|
|
if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
|
|
await ctx.db.delete(user._id)
|
|
}
|
|
}
|
|
|
|
let ticketsUpserted = 0
|
|
let commentsInserted = 0
|
|
let eventsInserted = 0
|
|
|
|
for (const ticket of snapshot.tickets) {
|
|
const normalizedSnapshot = normalizeImportedSlaSnapshot(ticket.slaSnapshot)
|
|
const slaPausedMs = typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined
|
|
|
|
const requesterId = await ensureUser(
|
|
ctx,
|
|
snapshot.tenantId,
|
|
{
|
|
email: ticket.requesterEmail,
|
|
name: ticket.requesterEmail,
|
|
},
|
|
userCache,
|
|
companyCache
|
|
)
|
|
const assigneeId = ticket.assigneeEmail
|
|
? await ensureUser(
|
|
ctx,
|
|
snapshot.tenantId,
|
|
{
|
|
email: ticket.assigneeEmail,
|
|
name: ticket.assigneeEmail,
|
|
},
|
|
userCache,
|
|
companyCache
|
|
)
|
|
: undefined
|
|
|
|
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
|
|
const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined
|
|
|
|
const existing = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference))
|
|
.first()
|
|
|
|
const payload = pruneUndefined({
|
|
tenantId: snapshot.tenantId,
|
|
reference: ticket.reference,
|
|
subject: ticket.subject,
|
|
summary: ticket.summary ?? undefined,
|
|
status: ticket.status,
|
|
priority: ticket.priority,
|
|
channel: ticket.channel,
|
|
queueId: queueId as Id<"queues"> | undefined,
|
|
categoryId: undefined,
|
|
subcategoryId: undefined,
|
|
requesterId,
|
|
assigneeId: assigneeId as Id<"users"> | undefined,
|
|
working: false,
|
|
slaPolicyId: undefined,
|
|
companyId: companyId as Id<"companies"> | undefined,
|
|
dueAt: ticket.dueAt ?? undefined,
|
|
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
|
resolvedAt: ticket.resolvedAt ?? undefined,
|
|
closedAt: ticket.closedAt ?? undefined,
|
|
updatedAt: ticket.updatedAt,
|
|
createdAt: ticket.createdAt,
|
|
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
|
|
slaSnapshot: normalizedSnapshot,
|
|
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
|
|
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
|
|
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
|
|
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
|
|
slaPausedAt: ticket.slaPausedAt ?? undefined,
|
|
slaPausedBy: ticket.slaPausedBy ?? undefined,
|
|
slaPausedMs,
|
|
customFields: undefined,
|
|
totalWorkedMs: undefined,
|
|
activeSessionId: undefined,
|
|
})
|
|
|
|
let ticketId: Id<"tickets">
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, payload)
|
|
ticketId = existing._id
|
|
} else {
|
|
ticketId = await ctx.db.insert("tickets", payload)
|
|
}
|
|
|
|
ticketsUpserted += 1
|
|
|
|
const existingComments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.take(500)
|
|
for (const comment of existingComments) {
|
|
await ctx.db.delete(comment._id)
|
|
}
|
|
|
|
const existingEvents = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.take(500)
|
|
for (const event of existingEvents) {
|
|
await ctx.db.delete(event._id)
|
|
}
|
|
|
|
for (const comment of ticket.comments) {
|
|
const authorId = await ensureUser(
|
|
ctx,
|
|
snapshot.tenantId,
|
|
{
|
|
email: comment.authorEmail,
|
|
name: comment.authorEmail,
|
|
},
|
|
userCache,
|
|
companyCache
|
|
)
|
|
await ctx.db.insert("ticketComments", {
|
|
ticketId,
|
|
authorId,
|
|
visibility: comment.visibility,
|
|
body: comment.body,
|
|
attachments: [],
|
|
createdAt: comment.createdAt,
|
|
updatedAt: comment.updatedAt,
|
|
})
|
|
commentsInserted += 1
|
|
}
|
|
|
|
for (const event of ticket.events) {
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: event.type,
|
|
payload: event.payload ?? {},
|
|
createdAt: event.createdAt,
|
|
})
|
|
eventsInserted += 1
|
|
}
|
|
}
|
|
|
|
return {
|
|
usersProcessed: userCache.size,
|
|
queuesProcessed: queueCache.size,
|
|
ticketsUpserted,
|
|
commentsInserted,
|
|
eventsInserted,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const backfillTicketCommentAuthorSnapshots = mutation({
|
|
args: {
|
|
limit: v.optional(v.number()),
|
|
dryRun: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, { limit, dryRun }) => {
|
|
const effectiveDryRun = Boolean(dryRun)
|
|
const maxUpdates = limit && limit > 0 ? limit : null
|
|
// Limita a 2000 comentarios por execucao para evitar OOM
|
|
// Se precisar processar mais, rode novamente a migracao
|
|
const comments = await ctx.db.query("ticketComments").take(2000)
|
|
|
|
let updated = 0
|
|
let skippedExisting = 0
|
|
let missingAuthors = 0
|
|
|
|
for (const comment of comments) {
|
|
if (comment.authorSnapshot) {
|
|
skippedExisting += 1
|
|
continue
|
|
}
|
|
if (maxUpdates !== null && updated >= maxUpdates) {
|
|
break
|
|
}
|
|
|
|
const author = await ctx.db.get(comment.authorId)
|
|
let name: string | null = author?.name ?? null
|
|
const email: string | null = author?.email ?? null
|
|
let avatarUrl: string | null = author?.avatarUrl ?? null
|
|
const teams: string[] | undefined = (author?.teams ?? undefined) as string[] | undefined
|
|
|
|
if (!author) {
|
|
missingAuthors += 1
|
|
const events = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
|
|
.take(100)
|
|
const matchingEvent = events.find(
|
|
(event) => event.type === "COMMENT_ADDED" && event.createdAt === comment.createdAt,
|
|
)
|
|
if (matchingEvent && matchingEvent.payload && typeof matchingEvent.payload === "object") {
|
|
const payload = matchingEvent.payload as { authorName?: string; authorAvatar?: string }
|
|
if (typeof payload.authorName === "string" && payload.authorName.trim().length > 0) {
|
|
name = payload.authorName.trim()
|
|
}
|
|
if (typeof payload.authorAvatar === "string" && payload.authorAvatar.trim().length > 0) {
|
|
avatarUrl = payload.authorAvatar
|
|
}
|
|
}
|
|
}
|
|
|
|
const snapshot = pruneUndefined({
|
|
name: name && name.trim().length > 0 ? name : "Usuário removido",
|
|
email: email ?? undefined,
|
|
avatarUrl: avatarUrl ?? undefined,
|
|
teams: teams && teams.length > 0 ? teams : undefined,
|
|
})
|
|
|
|
if (!effectiveDryRun) {
|
|
await ctx.db.patch(comment._id, { authorSnapshot: snapshot })
|
|
}
|
|
updated += 1
|
|
}
|
|
|
|
return {
|
|
dryRun: effectiveDryRun,
|
|
totalComments: comments.length,
|
|
updated,
|
|
skippedExisting,
|
|
missingAuthors,
|
|
limit: maxUpdates,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const syncMachineCompanyReferences = mutation({
|
|
args: {
|
|
tenantId: v.optional(v.string()),
|
|
dryRun: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, { tenantId, dryRun }) => {
|
|
const effectiveDryRun = Boolean(dryRun)
|
|
|
|
// Limita a 1000 maquinas por execucao para evitar OOM
|
|
const machines = tenantId && tenantId.trim().length > 0
|
|
? await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(1000)
|
|
: await ctx.db.query("machines").take(1000)
|
|
|
|
const slugCache = new Map<string, Id<"companies"> | null>()
|
|
const summary = {
|
|
total: machines.length,
|
|
updated: 0,
|
|
skippedMissingSlug: 0,
|
|
skippedMissingCompany: 0,
|
|
alreadyLinked: 0,
|
|
}
|
|
|
|
for (const machine of machines) {
|
|
const slug = machine.companySlug ?? null
|
|
if (!slug) {
|
|
summary.skippedMissingSlug += 1
|
|
continue
|
|
}
|
|
|
|
const cacheKey = `${machine.tenantId}::${slug}`
|
|
let companyId = slugCache.get(cacheKey)
|
|
if (companyId === undefined) {
|
|
const company = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", slug))
|
|
.unique()
|
|
companyId = company?._id ?? null
|
|
slugCache.set(cacheKey, companyId)
|
|
}
|
|
|
|
if (!companyId) {
|
|
summary.skippedMissingCompany += 1
|
|
continue
|
|
}
|
|
|
|
if (machine.companyId === companyId) {
|
|
summary.alreadyLinked += 1
|
|
continue
|
|
}
|
|
|
|
if (!effectiveDryRun) {
|
|
await ctx.db.patch(machine._id, { companyId })
|
|
}
|
|
summary.updated += 1
|
|
}
|
|
|
|
return {
|
|
dryRun: effectiveDryRun,
|
|
...summary,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const backfillTicketSnapshots = mutation({
|
|
args: { tenantId: v.string(), limit: v.optional(v.number()) },
|
|
handler: async (ctx, { tenantId, limit }) => {
|
|
// Limita a 1000 tickets por execucao para evitar OOM
|
|
const effectiveLimit = limit && limit > 0 ? Math.min(limit, 1000) : 1000
|
|
const tickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.take(effectiveLimit)
|
|
|
|
let processed = 0
|
|
for (const t of tickets) {
|
|
if (limit && processed >= limit) break
|
|
const patch: Record<string, unknown> = {}
|
|
if (!t.requesterSnapshot) {
|
|
const requester = await ctx.db.get(t.requesterId)
|
|
if (requester) {
|
|
patch.requesterSnapshot = {
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl ?? undefined,
|
|
teams: requester.teams ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
if (t.assigneeId && !t.assigneeSnapshot) {
|
|
const assignee = await ctx.db.get(t.assigneeId)
|
|
if (assignee) {
|
|
patch.assigneeSnapshot = {
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl ?? undefined,
|
|
teams: assignee.teams ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
if (!t.companySnapshot) {
|
|
const companyId = t.companyId
|
|
if (companyId) {
|
|
const company = await ctx.db.get(companyId)
|
|
if (company) {
|
|
patch.companySnapshot = {
|
|
name: company.name,
|
|
slug: company.slug,
|
|
isAvulso: company.isAvulso ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
await ctx.db.patch(t._id, patch)
|
|
}
|
|
processed += 1
|
|
}
|
|
return { processed }
|
|
},
|
|
})
|