import { mutation, query } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { ConvexError, v } from "convex/values"; import { requireAdmin } from "./rbac"; function normalizeName(value: string) { return value.trim(); } type AnyCtx = QueryCtx | MutationCtx; async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"teams">) { const existing = await ctx.db .query("teams") .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) .first(); if (existing && (!excludeId || existing._id !== excludeId)) { throw new ConvexError("Já existe um time com este nome"); } } export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { await requireAdmin(ctx, viewerId, tenantId); const teams = await ctx.db .query("teams") .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) .collect(); const users = await ctx.db .query("users") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const queues = await ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); return teams.map((team) => { const members = users .filter((user) => (user.teams ?? []).includes(team.name)) .map((user) => ({ id: user._id, name: user.name, email: user.email, role: user.role ?? "AGENT", })); const linkedQueues = queues.filter((queue) => queue.teamId === team._id); return { id: team._id, name: team.name, description: team.description ?? "", members, queueCount: linkedQueues.length, createdAt: team._creationTime, }; }); }, }); export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), }, handler: async (ctx, { tenantId, actorId, name, description }) => { await requireAdmin(ctx, actorId, tenantId); const trimmed = normalizeName(name); if (trimmed.length < 2) { throw new ConvexError("Informe um nome válido para o time"); } await ensureUniqueName(ctx, tenantId, trimmed); const id = await ctx.db.insert("teams", { tenantId, name: trimmed, description, }); return id; }, }); export const update = mutation({ args: { teamId: v.id("teams"), tenantId: v.string(), actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), }, handler: async (ctx, { teamId, tenantId, actorId, name, description }) => { await requireAdmin(ctx, actorId, tenantId); const team = await ctx.db.get(teamId); if (!team || team.tenantId !== tenantId) { throw new ConvexError("Time não encontrado"); } const trimmed = normalizeName(name); if (trimmed.length < 2) { throw new ConvexError("Informe um nome válido para o time"); } await ensureUniqueName(ctx, tenantId, trimmed, teamId); if (team.name !== trimmed) { const users = await ctx.db .query("users") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const now = users .filter((user) => (user.teams ?? []).includes(team.name)) .map(async (user) => { const teams = (user.teams ?? []).map((entry) => (entry === team.name ? trimmed : entry)); await ctx.db.patch(user._id, { teams }); }); await Promise.all(now); } await ctx.db.patch(teamId, { name: trimmed, description }); }, }); export const remove = mutation({ args: { teamId: v.id("teams"), tenantId: v.string(), actorId: v.id("users"), }, handler: async (ctx, { teamId, tenantId, actorId }) => { await requireAdmin(ctx, actorId, tenantId); const team = await ctx.db.get(teamId); if (!team || team.tenantId !== tenantId) { throw new ConvexError("Time não encontrado"); } const queuesLinked = await ctx.db .query("queues") .withIndex("by_tenant_team", (q) => q.eq("tenantId", tenantId).eq("teamId", teamId)) .first(); if (queuesLinked) { throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time"); } const users = await ctx.db .query("users") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); await Promise.all( users .filter((user) => (user.teams ?? []).includes(team.name)) .map((user) => { const teams = (user.teams ?? []).filter((entry) => entry !== team.name); return ctx.db.patch(user._id, { teams }); }) ); await ctx.db.delete(teamId); }, }); export const setMembers = mutation({ args: { teamId: v.id("teams"), tenantId: v.string(), actorId: v.id("users"), memberIds: v.array(v.id("users")), }, handler: async (ctx, { teamId, tenantId, actorId, memberIds }) => { await requireAdmin(ctx, actorId, tenantId); const team = await ctx.db.get(teamId); if (!team || team.tenantId !== tenantId) { throw new ConvexError("Time não encontrado"); } const users = await ctx.db .query("users") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const tenantUserIds = new Set(users.map((user) => user._id)); for (const memberId of memberIds) { if (!tenantUserIds.has(memberId)) { throw new ConvexError("Usuário inválido para este tenant"); } } const target = new Set(memberIds); await Promise.all( users.map(async (user) => { const teams = new Set(user.teams ?? []); const hasTeam = teams.has(team.name); const shouldHave = target.has(user._id); if (shouldHave && !hasTeam) { teams.add(team.name); await ctx.db.patch(user._id, { teams: Array.from(teams) }); } if (!shouldHave && hasTeam) { teams.delete(team.name); await ctx.db.patch(user._id, { teams: Array.from(teams) }); } }) ); }, }); export const directory = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { await requireAdmin(ctx, viewerId, tenantId); const users = await ctx.db .query("users") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); return users.map((user) => ({ id: user._id, name: user.name, email: user.email, role: user.role ?? "AGENT", teams: user.teams ?? [], })); }, });