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 = { 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)) .take(5000); // 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)) .take(5000); 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() 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)) .take(10000); 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)) .take(10000); 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)) .take(1000); 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 }, })