sistema-de-chamados/convex/queues.ts
rever-tecnologia bddce33217
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 3m46s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m39s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 2m13s
fix(dashboard): "Em andamento" conta apenas tickets com play ativo
- Tickets com status AWAITING_ATTENDANCE mas sem play ativo
  agora contam como "Em aberto"
- "Em andamento" mostra apenas tickets onde working === true

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 14:39:39 -03:00

285 lines
8.4 KiB
TypeScript

import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { requireAdmin, requireStaff } from "./rbac";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "RESOLVED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
chamados: "Chamados",
"Suporte N2": "Laboratório",
"suporte-n2": "Laboratório",
laboratorio: "Laboratório",
Laboratorio: "Laboratório",
visitas: "Visitas",
};
function renameQueueString(value: string) {
const direct = QUEUE_RENAME_LOOKUP[value];
if (direct) return direct;
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este identificador");
}
}
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(50);
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(50);
return queues.map((queue) => {
const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null;
return {
id: queue._id,
name: queue.name,
slug: queue.slug,
team: team
? {
id: team._id,
name: team.name,
}
: null,
};
});
},
});
export const listForStaff = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId)
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(50)
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(50)
return queues.map((queue) => {
const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null
return {
id: queue._id,
name: queue.name,
slug: queue.slug,
team: team
? {
id: team._id,
name: team.name,
}
: null,
}
})
},
})
export const summary = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).take(50);
const result = await Promise.all(
queues.map(async (qItem) => {
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
.take(50);
let pending = 0;
let inProgress = 0;
let paused = 0;
let breached = 0;
const now = Date.now();
for (const ticket of tickets) {
const status = normalizeStatus(ticket.status);
const isWorking = ticket.working === true;
if (status === "PENDING") {
pending += 1;
} else if (status === "AWAITING_ATTENDANCE") {
// "Em andamento" conta apenas tickets com play ativo
if (isWorking) {
inProgress += 1;
} else {
// Tickets em atendimento sem play ativo contam como "Em aberto"
pending += 1;
}
} else if (status === "PAUSED") {
paused += 1;
}
if (status !== "RESOLVED") {
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null;
if (dueAt && dueAt < now) {
breached += 1;
}
}
}
return {
id: qItem._id,
name: renameQueueString(qItem.name),
pending,
inProgress,
paused,
breached,
};
})
);
return result;
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed);
const slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug);
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
const id = await ctx.db.insert("queues", {
tenantId,
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
return id;
},
});
export const update = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed, queueId);
let slug = queue.slug;
if (queue.name !== trimmed) {
slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug, queueId);
}
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
await ctx.db.patch(queueId, {
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
},
});
export const remove = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { queueId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const ticketUsingQueue = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.first();
if (ticketUsingQueue) {
throw new ConvexError("Não é possível remover uma fila vinculada a tickets");
}
await ctx.db.delete(queueId);
},
});