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:
parent
409cbea7b9
commit
854887f499
16 changed files with 955 additions and 126 deletions
|
|
@ -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" }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue