import { mutation, query } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { requireAdmin } from "./rbac"; const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); 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")), }, 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 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); if (shouldPatch) { await ctx.db.patch(record._id, { 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, }); 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 { const anyTenant = (await ctx.db.query("users").collect()).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, }); 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 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 }); } } } 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 }, })