- liveChat.ts: limit sessions/messages queries (take 50-500) - tickets.ts: batch delete operations, limit playNext/reassign (take 100-2000) - reports.ts: limit ticket/user/machine queries (take 500-2000) - machines.ts: limit machine queries for registration/listing (take 500) - metrics.ts: limit device health summary (take 200) - users.ts: limit user search in claimInvite (take 5000) - alerts.ts: limit company/alert queries (take 500-1000) - migrations.ts: limit batch operations (take 1000-2000) These changes prevent the Convex backend from loading entire tables into memory, which was causing OOM kills at 16GB and WebSocket disconnections (code 1006). Expected RAM reduction: 60-80% at peak usage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { mutation, query } from "./_generated/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { requireAdmin, requireStaff } from "./rbac";
|
|
|
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
|
const CUSTOMER_ROLES = new Set(["COLLABORATOR", "MANAGER"]);
|
|
|
|
export const ensureUser = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
email: v.string(),
|
|
name: v.string(),
|
|
avatarUrl: v.optional(v.string()),
|
|
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
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
|
|
.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) ||
|
|
jobTitleChanged ||
|
|
managerChanged;
|
|
|
|
if (shouldPatch) {
|
|
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;
|
|
}
|
|
}
|
|
return record;
|
|
};
|
|
|
|
if (existing) {
|
|
const reconciled = await reconcile(existing);
|
|
if (reconciled) {
|
|
return reconciled;
|
|
}
|
|
} else {
|
|
// Busca por email em todos os tenants (usando limite para evitar OOM)
|
|
// Nota: isso e ineficiente sem indice global por email
|
|
const users = await ctx.db.query("users").take(5000);
|
|
const anyTenant = users.find((user) => user.email === args.email);
|
|
if (anyTenant) {
|
|
const reconciled = await reconcile(anyTenant);
|
|
if (reconciled) {
|
|
return reconciled;
|
|
}
|
|
}
|
|
}
|
|
const id = await ctx.db.insert("users", {
|
|
tenantId: args.tenantId,
|
|
email: args.email,
|
|
name: args.name,
|
|
avatarUrl: args.avatarUrl,
|
|
role: args.role ?? "AGENT",
|
|
teams: args.teams ?? [],
|
|
companyId: args.companyId,
|
|
jobTitle: args.jobTitle,
|
|
managerId: args.managerId,
|
|
});
|
|
return await ctx.db.get(id);
|
|
},
|
|
});
|
|
|
|
export const listAgents = query({
|
|
args: { tenantId: v.string() },
|
|
handler: async (ctx, { tenantId }) => {
|
|
const users = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
// Only internal staff (ADMIN/AGENT) should appear as responsáveis
|
|
return users
|
|
.filter((user) => INTERNAL_STAFF_ROLES.has((user.role ?? "AGENT").toUpperCase()))
|
|
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
|
|
},
|
|
});
|
|
|
|
export const listCustomers = query({
|
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
|
handler: async (ctx, { tenantId, viewerId }) => {
|
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
|
const viewerRole = (viewer.role ?? "AGENT").toUpperCase()
|
|
let managerCompanyId: Id<"companies"> | null = null
|
|
if (viewerRole === "MANAGER") {
|
|
managerCompanyId = viewer.user.companyId ?? null
|
|
if (!managerCompanyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
}
|
|
|
|
const users = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
const allowed = users.filter((user) => {
|
|
const role = (user.role ?? "COLLABORATOR").toUpperCase()
|
|
if (!CUSTOMER_ROLES.has(role)) return false
|
|
if (managerCompanyId && user.companyId !== managerCompanyId) return false
|
|
return true
|
|
})
|
|
|
|
const companyIds = Array.from(
|
|
new Set(
|
|
allowed
|
|
.map((user) => user.companyId)
|
|
.filter((companyId): companyId is Id<"companies"> => Boolean(companyId))
|
|
)
|
|
)
|
|
|
|
const companyMap = new Map<string, { name: string; isAvulso?: boolean | null }>()
|
|
if (companyIds.length > 0) {
|
|
await Promise.all(
|
|
companyIds.map(async (companyId) => {
|
|
const company = await ctx.db.get(companyId)
|
|
if (company) {
|
|
companyMap.set(String(companyId), {
|
|
name: company.name,
|
|
isAvulso: company.isAvulso ?? undefined,
|
|
})
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
return allowed
|
|
.map((user) => {
|
|
const companyId = user.companyId ? String(user.companyId) : null
|
|
const company = companyId ? companyMap.get(companyId) ?? null : null
|
|
return {
|
|
id: String(user._id),
|
|
name: user.name,
|
|
email: user.email,
|
|
role: (user.role ?? "COLLABORATOR").toUpperCase(),
|
|
companyId,
|
|
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"))
|
|
},
|
|
})
|
|
|
|
|
|
export const findByEmail = query({
|
|
args: { tenantId: v.string(), email: v.string() },
|
|
handler: async (ctx, { tenantId, email }) => {
|
|
const record = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
|
.first();
|
|
return record ?? null;
|
|
},
|
|
});
|
|
|
|
export const deleteUser = mutation({
|
|
args: { userId: v.id("users"), actorId: v.id("users") },
|
|
handler: async (ctx, { userId, actorId }) => {
|
|
const user = await ctx.db.get(userId);
|
|
if (!user) {
|
|
return { status: "not_found" };
|
|
}
|
|
|
|
await requireAdmin(ctx, actorId, user.tenantId);
|
|
|
|
const assignedTickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", userId))
|
|
.take(1);
|
|
|
|
if (assignedTickets.length > 0) {
|
|
throw new ConvexError("Usuário ainda está atribuído a tickets");
|
|
}
|
|
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_author", (q) => q.eq("authorId", userId))
|
|
.collect();
|
|
if (comments.length > 0) {
|
|
const authorSnapshot = {
|
|
name: user.name,
|
|
email: user.email,
|
|
avatarUrl: user.avatarUrl ?? undefined,
|
|
teams: user.teams ?? undefined,
|
|
};
|
|
await Promise.all(
|
|
comments.map(async (comment) => {
|
|
const existingSnapshot = comment.authorSnapshot;
|
|
const shouldUpdate =
|
|
!existingSnapshot ||
|
|
existingSnapshot.name !== authorSnapshot.name ||
|
|
existingSnapshot.email !== authorSnapshot.email ||
|
|
existingSnapshot.avatarUrl !== authorSnapshot.avatarUrl ||
|
|
JSON.stringify(existingSnapshot.teams ?? []) !== JSON.stringify(authorSnapshot.teams ?? []);
|
|
if (shouldUpdate) {
|
|
await ctx.db.patch(comment._id, { authorSnapshot });
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Preserve requester snapshot on tickets where this user is the requester
|
|
const requesterTickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", userId))
|
|
.collect();
|
|
if (requesterTickets.length > 0) {
|
|
const requesterSnapshot = {
|
|
name: user.name,
|
|
email: user.email,
|
|
avatarUrl: user.avatarUrl ?? undefined,
|
|
teams: user.teams ?? undefined,
|
|
};
|
|
for (const t of requesterTickets) {
|
|
const needsPatch = !t.requesterSnapshot ||
|
|
t.requesterSnapshot.name !== requesterSnapshot.name ||
|
|
t.requesterSnapshot.email !== requesterSnapshot.email ||
|
|
t.requesterSnapshot.avatarUrl !== requesterSnapshot.avatarUrl ||
|
|
JSON.stringify(t.requesterSnapshot.teams ?? []) !== JSON.stringify(requesterSnapshot.teams ?? []);
|
|
if (needsPatch) {
|
|
await ctx.db.patch(t._id, { requesterSnapshot });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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" };
|
|
},
|
|
});
|
|
|
|
export const assignCompany = mutation({
|
|
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
|
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
const user = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
|
.first()
|
|
if (!user) throw new ConvexError("Usuário não encontrado no Convex")
|
|
await ctx.db.patch(user._id, { companyId })
|
|
const updated = await ctx.db.get(user._id)
|
|
return updated
|
|
},
|
|
})
|