feat: add company management and manager role support

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-06 21:26:43 -03:00
parent 409cbea7b9
commit 854887f499
16 changed files with 955 additions and 126 deletions

View file

@ -1,11 +1,42 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireCustomer, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx,
manager: Doc<"users">,
ticket: Doc<"tickets">,
): Promise<Doc<"users"> | null> {
if (!manager.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (ticket.companyId && ticket.companyId === manager.companyId) {
return null
}
const requester = await ctx.db.get(ticket.requesterId)
if (!requester || requester.companyId !== manager.companyId) {
throw new ConvexError("Acesso restrito à empresa")
}
return requester as Doc<"users">
}
async function requireTicketStaff(
ctx: MutationCtx | QueryCtx,
actorId: Id<"users">,
ticket: Doc<"tickets">
) {
const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
if (viewer.role === "MANAGER") {
await ensureManagerTicketAccess(ctx, viewer.user, ticket)
}
return viewer
}
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
@ -188,11 +219,19 @@ export const list = query({
if (!args.viewerId) {
return []
}
const { role } = await requireUser(ctx, args.viewerId, args.tenantId)
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];
if (args.status) {
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
} else if (args.status) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
@ -212,6 +251,11 @@ export const list = query({
if (role === "CUSTOMER") {
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
} else if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
}
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
@ -314,13 +358,19 @@ export const list = query({
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { tenantId, id, viewerId }) => {
const { role } = await requireUser(ctx, viewerId, tenantId)
const { user, role } = await requireUser(ctx, viewerId, tenantId)
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
throw new ConvexError("Acesso restrito ao solicitante")
}
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
let requester: Doc<"users"> | null = null
if (role === "MANAGER") {
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
}
if (!requester) {
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
}
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const queueName = normalizeQueueName(queue);
@ -479,7 +529,7 @@ export const create = mutation({
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
}
if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) {
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
throw new ConvexError("Somente a equipe interna pode definir o responsável")
}
@ -497,7 +547,7 @@ export const create = mutation({
}
initialAssigneeId = assignee._id
initialAssignee = assignee
} else if (role && STAFF_ROLES.has(role)) {
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
initialAssigneeId = actorUser._id
initialAssignee = actorUser
}
@ -515,6 +565,19 @@ export const create = mutation({
throw new ConvexError("Subcategoria inválida");
}
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
if (!requester || requester.tenantId !== args.tenantId) {
throw new ConvexError("Solicitante inválido")
}
if (role === "MANAGER") {
if (!actorUser.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (requester.companyId !== actorUser.companyId) {
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
}
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
@ -537,6 +600,7 @@ export const create = mutation({
subcategoryId: args.subcategoryId,
requesterId: args.requesterId,
assigneeId: initialAssigneeId,
companyId: requester.companyId ?? undefined,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
@ -550,7 +614,6 @@ export const create = mutation({
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
@ -593,27 +656,35 @@ export const addComment = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
if (!author || author.tenantId !== ticket.tenantId) {
if (!author || author.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
if (ticket.requesterId === args.authorId) {
if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, author, ticketDoc)
if (args.visibility !== "PUBLIC") {
throw new ConvexError("Gestores só podem registrar comentários públicos")
}
}
if (ticketDoc.requesterId === args.authorId) {
if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, args.authorId, ticket.tenantId)
await requireCustomer(ctx, args.authorId, ticketDoc.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)
await requireTicketStaff(ctx, args.authorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para comentar")
}
} else {
await requireStaff(ctx, args.authorId, ticket.tenantId)
await requireTicketStaff(ctx, args.authorId, ticketDoc)
}
const now = Date.now();
@ -650,6 +721,7 @@ export const updateComment = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
@ -657,10 +729,10 @@ export const updateComment = mutation({
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const now = Date.now();
@ -698,6 +770,7 @@ export const removeCommentAttachment = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
@ -706,10 +779,10 @@ export const removeCommentAttachment = mutation({
throw new ConvexError("Você não pode alterar anexos de outro usuário")
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const attachments = comment.attachments ?? [];
@ -751,7 +824,8 @@ export const updateStatus = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
@ -778,11 +852,15 @@ export const changeAssignee = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticket.tenantId) {
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Responsável inválido")
}
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
await ctx.db.insert("ticketEvents", {
@ -801,9 +879,13 @@ export const changeQueue = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a fila do chamado")
}
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticket.tenantId) {
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now();
@ -830,13 +912,17 @@ export const updateCategories = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
}
if (categoryId === null) {
if (subcategoryId !== null) {
throw new ConvexError("Subcategoria inválida")
}
if (!ticket.categoryId && !ticket.subcategoryId) {
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
return { status: "unchanged" }
}
const now = Date.now()
@ -864,20 +950,20 @@ export const updateCategories = mutation({
}
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticket.tenantId) {
if (!category || category.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Categoria inválida")
}
let subcategoryName: string | null = null
if (subcategoryId !== null) {
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Subcategoria inválida")
}
subcategoryName = subcategory.name
}
if (ticket.categoryId === categoryId && (ticket.subcategoryId ?? null) === subcategoryId) {
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
return { status: "unchanged" }
}