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:
parent
ff674d5bb5
commit
7946b8d017
46 changed files with 2564 additions and 178 deletions
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js";
|
|||
import type * as categories from "../categories.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
|
@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{
|
|||
categories: typeof categories;
|
||||
files: typeof files;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
seed: typeof seed;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
|
|
|
|||
53
web/convex/rbac.ts
Normal file
53
web/convex/rbac.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { ConvexError } from "convex/values"
|
||||
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const CUSTOMER_ROLE = "CUSTOMER"
|
||||
|
||||
type Ctx = QueryCtx | MutationCtx
|
||||
|
||||
function normalizeRole(role?: string | null) {
|
||||
return role?.toUpperCase() ?? null
|
||||
}
|
||||
|
||||
async function getUser(ctx: Ctx, userId: Id<"users">) {
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user) {
|
||||
throw new ConvexError("Usuário não encontrado")
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const user = await getUser(ctx, userId)
|
||||
if (tenantId && user.tenantId !== tenantId) {
|
||||
throw new ConvexError("Usuário não pertence a este tenant")
|
||||
}
|
||||
return { user, role: normalizeRole(user.role) }
|
||||
}
|
||||
|
||||
export async function requireStaff(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (!result.role || !STAFF_ROLES.has(result.role)) {
|
||||
throw new ConvexError("Acesso restrito à equipe interna")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireStaff(ctx, userId, tenantId)
|
||||
if (result.role !== "ADMIN") {
|
||||
throw new ConvexError("Apenas administradores podem executar esta ação")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (result.role !== CUSTOMER_ROLE) {
|
||||
throw new ConvexError("Acesso restrito ao portal do cliente")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue