229 lines
7 KiB
TypeScript
229 lines
7 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" | "CLOSED";
|
|
|
|
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: "CLOSED",
|
|
};
|
|
|
|
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", (q) => q.eq("tenantId", tenantId))
|
|
.filter((q) => q.eq(q.field("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))
|
|
.collect();
|
|
|
|
const teams = await ctx.db
|
|
.query("teams")
|
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
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)).collect();
|
|
const result = await Promise.all(
|
|
queues.map(async (qItem) => {
|
|
const pending = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
|
|
.collect();
|
|
const waiting = pending.filter((t) => {
|
|
const status = normalizeStatus(t.status);
|
|
return status === "PENDING" || status === "PAUSED";
|
|
}).length;
|
|
const open = pending.filter((t) => {
|
|
const status = normalizeStatus(t.status);
|
|
return status !== "RESOLVED" && status !== "CLOSED";
|
|
}).length;
|
|
const breached = 0;
|
|
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, 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);
|
|
},
|
|
});
|