diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 6d9da05..6d0dc03 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -5,6 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel"; import { requireCustomer, requireStaff, requireUser } from "./rbac"; +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); + const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", @@ -459,6 +461,7 @@ export const create = mutation({ channel: v.string(), queueId: v.optional(v.id("queues")), requesterId: v.id("users"), + assigneeId: v.optional(v.id("users")), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), customFields: v.optional( @@ -471,11 +474,34 @@ export const create = mutation({ ), }, handler: async (ctx, args) => { - const { role } = await requireUser(ctx, args.actorId, args.tenantId) + const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) if (role === "CUSTOMER" && args.requesterId !== args.actorId) { throw new ConvexError("Clientes só podem abrir chamados para si mesmos") } + if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) { + throw new ConvexError("Somente a equipe interna pode definir o responsável") + } + + let initialAssigneeId: Id<"users"> | undefined + let initialAssignee: Doc<"users"> | null = null + + if (args.assigneeId) { + const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null + if (!assignee || assignee.tenantId !== args.tenantId) { + throw new ConvexError("Responsável inválido") + } + const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase() + if (!STAFF_ROLES.has(normalizedAssigneeRole)) { + throw new ConvexError("Responsável inválido") + } + initialAssigneeId = assignee._id + initialAssignee = assignee + } else if (role && STAFF_ROLES.has(role)) { + initialAssigneeId = actorUser._id + initialAssignee = actorUser + } + const subject = args.subject.trim(); if (subject.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); @@ -510,7 +536,7 @@ export const create = mutation({ categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, - assigneeId: undefined, + assigneeId: initialAssigneeId, working: false, activeSessionId: undefined, totalWorkedMs: 0, @@ -531,6 +557,16 @@ export const create = mutation({ payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, createdAt: now, }); + + if (initialAssigneeId && initialAssignee) { + await ctx.db.insert("ticketEvents", { + ticketId: id, + type: "ASSIGNEE_CHANGED", + payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId }, + createdAt: now, + }) + } + return id; }, }); @@ -558,10 +594,23 @@ export const addComment = mutation({ throw new ConvexError("Ticket não encontrado") } + const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null + if (!author || author.tenantId !== ticket.tenantId) { + throw new ConvexError("Autor do comentário inválido") + } + + const normalizedRole = (author.role ?? "AGENT").toUpperCase() + if (ticket.requesterId === args.authorId) { - await requireCustomer(ctx, args.authorId, ticket.tenantId) - if (args.visibility !== "PUBLIC") { - throw new ConvexError("Clientes só podem registrar comentários públicos") + if (normalizedRole === "CUSTOMER") { + await requireCustomer(ctx, args.authorId, ticket.tenantId) + if (args.visibility !== "PUBLIC") { + throw new ConvexError("Clientes só podem registrar comentários públicos") + } + } else if (STAFF_ROLES.has(normalizedRole)) { + await requireStaff(ctx, args.authorId, ticket.tenantId) + } else { + throw new ConvexError("Autor não possui permissão para comentar") } } else { await requireStaff(ctx, args.authorId, ticket.tenantId) @@ -577,11 +626,10 @@ export const addComment = mutation({ createdAt: now, updatedAt: now, }); - const author = await ctx.db.get(args.authorId); await ctx.db.insert("ticketEvents", { ticketId: args.ticketId, type: "COMMENT_ADDED", - payload: { authorId: args.authorId, authorName: author?.name, authorAvatar: author?.avatarUrl }, + payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, createdAt: now, }); // bump ticket updatedAt diff --git a/web/convex/users.ts b/web/convex/users.ts index a739fb8..7dadbd8 100644 --- a/web/convex/users.ts +++ b/web/convex/users.ts @@ -1,5 +1,8 @@ import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; +import { requireAdmin } from "./rbac"; + +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); export const ensureUser = mutation({ args: { @@ -69,11 +72,41 @@ export const ensureUser = mutation({ export const listAgents = query({ args: { tenantId: v.string() }, handler: async (ctx, { tenantId }) => { - const agents = await ctx.db + const users = await ctx.db .query("users") - .withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT")) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); - return agents; + + return users + .filter((user) => { + const normalizedRole = (user.role ?? "AGENT").toUpperCase(); + return STAFF_ROLES.has(normalizedRole); + }) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); + }, +}); + +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"); + } + + await ctx.db.delete(userId); + return { status: "deleted" }; }, }); diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index 868099d..a38b4bf 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -1,10 +1,10 @@ "use client" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -import type { Id } from "@/convex/_generated/dataModel" +import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" @@ -40,12 +40,18 @@ export default function NewTicketPage() { const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) + const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined + const staff = useMemo( + () => (staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), + [staffRaw] + ) const [subject, setSubject] = useState("") const [summary, setSummary] = useState("") const [priority, setPriority] = useState("MEDIUM") const [channel, setChannel] = useState("MANUAL") const [queueName, setQueueName] = useState(null) + const [assigneeId, setAssigneeId] = useState(null) const [description, setDescription] = useState("") const [loading, setLoading] = useState(false) const [subjectError, setSubjectError] = useState(null) @@ -53,8 +59,17 @@ export default function NewTicketPage() { const [subcategoryId, setSubcategoryId] = useState(null) const [categoryError, setCategoryError] = useState(null) const [subcategoryError, setSubcategoryError] = useState(null) + const [assigneeInitialized, setAssigneeInitialized] = useState(false) const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) + const assigneeSelectValue = assigneeId ?? "NONE" + + useEffect(() => { + if (assigneeInitialized) return + if (!convexUserId) return + setAssigneeId(convexUserId) + setAssigneeInitialized(true) + }, [assigneeInitialized, convexUserId]) async function submit(event: React.FormEvent) { event.preventDefault() @@ -81,6 +96,7 @@ export default function NewTicketPage() { try { const selQueue = queues.find((q) => q.name === queueName) const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined + const assigneeToSend = assigneeId ? (assigneeId as Id<"users">) : undefined const id = await create({ actorId: convexUserId as Id<"users">, tenantId: DEFAULT_TENANT_ID, @@ -90,6 +106,7 @@ export default function NewTicketPage() { channel, queueId, requesterId: convexUserId as Id<"users">, + assigneeId: assigneeToSend, categoryId: categoryId as Id<"ticketCategories">, subcategoryId: subcategoryId as Id<"ticketSubcategories">, }) @@ -178,7 +195,7 @@ export default function NewTicketPage() { ) : null} -
+
Prioridade
+
+ Responsável + +