feat: migrate auth stack and admin portal

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-05 17:25:57 -03:00
parent ff674d5bb5
commit 7946b8d017
46 changed files with 2564 additions and 178 deletions

View file

@ -2,6 +2,8 @@ import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
@ -37,6 +39,7 @@ function normalizeTeams(teams?: string[] | null): string[] {
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
tenantId: v.string(),
status: v.optional(v.string()),
priority: v.optional(v.string()),
@ -46,6 +49,11 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!args.viewerId) {
return []
}
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) {
@ -65,6 +73,10 @@ export const list = query({
.collect();
}
let filtered = base;
if (role === "CUSTOMER") {
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
}
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
@ -164,10 +176,14 @@ export const list = query({
});
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets") },
handler: async (ctx, { tenantId, id }) => {
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 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;
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;
@ -297,6 +313,7 @@ export const getById = query({
export const create = mutation({
args: {
actorId: v.id("users"),
tenantId: v.string(),
subject: v.string(),
summary: v.optional(v.string()),
@ -308,6 +325,11 @@ export const create = mutation({
subcategoryId: v.id("ticketSubcategories"),
},
handler: async (ctx, args) => {
const { 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")
}
const subject = args.subject.trim();
if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
@ -382,6 +404,20 @@ export const addComment = mutation({
),
},
handler: async (ctx, args) => {
const ticket = await ctx.db.get(args.ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
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")
}
} else {
await requireStaff(ctx, args.authorId, ticket.tenantId)
}
const now = Date.now();
const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId,
@ -413,6 +449,10 @@ export const updateComment = mutation({
body: v.string(),
},
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
@ -420,6 +460,11 @@ 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)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
}
const now = Date.now();
await ctx.db.patch(commentId, {
@ -452,10 +497,23 @@ export const removeCommentAttachment = mutation({
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não pode alterar anexos de outro usuário")
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
}
const attachments = comment.attachments ?? [];
const target = attachments.find((att) => att.storageId === attachmentId);
@ -492,6 +550,11 @@ export const removeCommentAttachment = mutation({
export const updateStatus = mutation({
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, status, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
@ -514,13 +577,21 @@ export const updateStatus = mutation({
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticket.tenantId) {
throw new ConvexError("Responsável inválido")
}
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: user?.name, actorId },
payload: { assigneeId, assigneeName: assignee.name, actorId },
createdAt: now,
});
},
@ -529,9 +600,17 @@ export const changeAssignee = mutation({
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticket.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
const queueName = normalizeQueueName(queue);
await ctx.db.insert("ticketEvents", {
ticketId,
@ -554,6 +633,7 @@ export const updateCategories = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticket.tenantId) {
throw new ConvexError("Categoria inválida")
@ -595,10 +675,11 @@ export const updateCategories = mutation({
})
export const workSummary = query({
args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId }) => {
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { ticketId, viewerId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) return null
await requireStaff(ctx, viewerId, ticket.tenantId)
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
return {
@ -618,6 +699,11 @@ export const workSummary = query({
export const updatePriority = mutation({
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, priority, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const now = Date.now();
await ctx.db.patch(ticketId, { priority, updatedAt: now });
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
@ -637,6 +723,7 @@ export const startWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
if (ticket.activeSessionId) {
return { status: "already_started", sessionId: ticket.activeSessionId }
}
@ -673,6 +760,7 @@ export const pauseWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
if (!ticket.activeSessionId) {
return { status: "already_paused" }
}
@ -721,7 +809,10 @@ export const updateSubject = mutation({
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
const trimmed = subject.trim();
if (trimmed.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
@ -742,7 +833,10 @@ export const updateSummary = mutation({
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
@ -761,6 +855,7 @@ export const playNext = mutation({
agentId: v.id("users"),
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
const agent = await requireStaff(ctx, agentId, tenantId)
// Find eligible tickets: not resolved/closed and not assigned
let candidates: Doc<"tickets">[] = []
if (queueId) {
@ -793,7 +888,6 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",