feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings

This commit is contained in:
Esdras Renan 2025-10-07 13:42:45 -03:00
parent addd4ce6e8
commit 3bafcc5a0a
45 changed files with 1401 additions and 256 deletions

View file

@ -3,7 +3,7 @@ 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";
import { requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
@ -13,14 +13,13 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
IN_PROCEDURE: "Em procedimento",
};
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
};
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
@ -31,7 +30,7 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
CLOSED: "RESOLVED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
@ -277,9 +276,7 @@ export const list = query({
}
let filtered = base;
if (role === "CUSTOMER") {
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
} else if (role === "MANAGER") {
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
@ -310,6 +307,7 @@ export const list = query({
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;
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const queueName = normalizeQueueName(queue);
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
@ -342,6 +340,7 @@ export const list = query({
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
requester: requester && {
id: requester._id,
name: requester.name,
@ -371,11 +370,14 @@ export const list = query({
subcategory: subcategorySummary,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
workType: (activeSession as any).workType ?? "INTERNAL",
}
: null,
},
@ -393,9 +395,7 @@ export const getById = query({
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")
}
// no customer role; managers are constrained to company via ensureManagerTicketAccess
let requester: Doc<"users"> | null = null
if (role === "MANAGER") {
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
@ -405,6 +405,7 @@ export const getById = query({
}
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 company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const queueName = normalizeQueueName(queue);
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
@ -463,6 +464,7 @@ export const getById = query({
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
requester: requester && {
id: requester._id,
name: requester.name,
@ -503,11 +505,14 @@ export const getById = query({
: null,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
workType: (activeSession as any).workType ?? "INTERNAL",
}
: null,
},
@ -557,9 +562,7 @@ export const create = mutation({
},
handler: async (ctx, args) => {
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")
}
// no customer role; managers validated below
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
throw new ConvexError("Somente a equipe interna pode definir o responsável")
@ -706,12 +709,9 @@ export const addComment = mutation({
}
if (ticketDoc.requesterId === args.authorId) {
if (normalizedRole === "CUSTOMER") {
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)) {
// requester commenting: managers restricted to PUBLIC (handled above);
// internal staff require staff permission
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, args.authorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para comentar")
@ -768,9 +768,7 @@ export const updateComment = mutation({
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) {
if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else if (STAFF_ROLES.has(normalizedRole)) {
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para editar")
@ -828,9 +826,7 @@ export const removeCommentAttachment = mutation({
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) {
if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else if (STAFF_ROLES.has(normalizedRole)) {
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para alterar anexos")
@ -1051,11 +1047,14 @@ export const workSummary = query({
return {
ticketId,
totalWorkedMs: ticket.totalWorkedMs ?? 0,
internalWorkedMs: (ticket as any).internalWorkedMs ?? 0,
externalWorkedMs: (ticket as any).externalWorkedMs ?? 0,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
workType: (activeSession as any).workType ?? "INTERNAL",
}
: null,
}
@ -1083,8 +1082,8 @@ export const updatePriority = mutation({
});
export const startWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) },
handler: async (ctx, { ticketId, actorId, workType }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
@ -1098,6 +1097,7 @@ export const startWork = mutation({
const sessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: actorId,
workType: (workType ?? "INTERNAL").toUpperCase(),
startedAt: now,
})
@ -1111,7 +1111,7 @@ export const startWork = mutation({
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId },
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase() },
createdAt: now,
})
@ -1156,10 +1156,16 @@ export const pauseWork = mutation({
pauseNote: note ?? "",
})
const sessionType = ((session as any).workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
await ctx.db.patch(ticketId, {
working: false,
activeSessionId: undefined,
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: ((ticket as any).externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now,
})
@ -1173,6 +1179,7 @@ export const pauseWork = mutation({
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
workType: sessionType,
pauseReason: reason,
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
pauseNote: note ?? "",
@ -1256,7 +1263,7 @@ export const playNext = mutation({
}
candidates = candidates.filter(
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
(t) => t.status !== "RESOLVED" && !t.assigneeId
);
if (candidates.length === 0) return null;