import { ConvexError, v } from "convex/values" import type { Doc, Id } from "./_generated/dataModel" import { mutation, query } from "./_generated/server" import { requireStaff } from "./rbac" const WIDGET_TYPES = [ "kpi", "bar", "line", "area", "pie", "radar", "gauge", "table", "text", ] as const type WidgetType = (typeof WIDGET_TYPES)[number] const gridItemValidator = v.object({ i: v.string(), x: v.number(), y: v.number(), w: v.number(), h: v.number(), minW: v.optional(v.number()), minH: v.optional(v.number()), static: v.optional(v.boolean()), }) const widgetLayoutValidator = v.object({ x: v.number(), y: v.number(), w: v.number(), h: v.number(), minW: v.optional(v.number()), minH: v.optional(v.number()), static: v.optional(v.boolean()), }) function assertWidgetType(type: string): asserts type is WidgetType { if (!WIDGET_TYPES.includes(type as WidgetType)) { throw new ConvexError(`Tipo de widget inválido: ${type}`) } } function sanitizeTitle(input?: string | null): string | undefined { if (!input) return undefined const trimmed = input.trim() return trimmed.length > 0 ? trimmed : undefined } function generateWidgetKey(dashboardId: Id<"dashboards">) { const rand = Math.random().toString(36).slice(2, 8) return `${dashboardId.toString().slice(-6)}-${rand}` } function normalizeWidgetConfig(type: WidgetType, config: unknown) { if (config && typeof config === "object") { return config } switch (type) { case "kpi": return { type: "kpi", title: "Novo KPI", dataSource: { metricKey: "tickets.waiting_action_now" }, options: { trend: "tickets.waiting_action_last_7d" }, } case "bar": return { type: "bar", title: "Abertos x Resolvidos", dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, encoding: { x: "date", y: [ { field: "opened", label: "Abertos" }, { field: "resolved", label: "Resolvidos" }, ], }, options: { legend: true }, } case "line": return { type: "line", title: "Resoluções por dia", dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, encoding: { x: "date", y: [{ field: "resolved", label: "Resolvidos" }], }, options: { legend: false }, } case "area": return { type: "area", title: "Volume acumulado", dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, encoding: { x: "date", y: [ { field: "opened", label: "Abertos" }, { field: "resolved", label: "Resolvidos" }, ], stacked: true, }, options: { legend: true }, } case "pie": return { type: "pie", title: "Backlog por prioridade", dataSource: { metricKey: "tickets.open_by_priority", params: { range: "30d" } }, encoding: { category: "priority", value: "total" }, options: { legend: true }, } case "radar": return { type: "radar", title: "SLA por fila", dataSource: { metricKey: "tickets.sla_compliance_by_queue", params: { range: "30d" } }, encoding: { angle: "queue", radius: "compliance" }, options: {}, } case "gauge": return { type: "gauge", title: "Cumprimento de SLA", dataSource: { metricKey: "tickets.sla_rate", params: { range: "7d" } }, options: { min: 0, max: 1, thresholds: [0.5, 0.8] }, } case "table": return { type: "table", title: "Tickets recentes", dataSource: { metricKey: "tickets.awaiting_table", params: { limit: 20 } }, columns: [ { field: "reference", label: "Ref." }, { field: "subject", label: "Assunto" }, { field: "status", label: "Status" }, { field: "priority", label: "Prioridade" }, { field: "updatedAt", label: "Atualizado em" }, ], options: { downloadCSV: true }, } case "text": default: return { type: "text", title: "Notas", content: "Use este espaço para destacar insights ou orientações.", } } } function sanitizeDashboard(dashboard: Doc<"dashboards">) { return { id: dashboard._id, tenantId: dashboard.tenantId, name: dashboard.name, description: dashboard.description ?? null, aspectRatio: dashboard.aspectRatio ?? "16:9", theme: dashboard.theme ?? "system", filters: dashboard.filters ?? {}, layout: dashboard.layout ?? [], sections: dashboard.sections ?? [], tvIntervalSeconds: dashboard.tvIntervalSeconds ?? 30, readySelector: dashboard.readySelector ?? null, createdBy: dashboard.createdBy, updatedBy: dashboard.updatedBy ?? null, createdAt: dashboard.createdAt, updatedAt: dashboard.updatedAt, isArchived: dashboard.isArchived ?? false, } } function normalizeOrder(index: number) { return index >= 0 ? index : 0 } export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), includeArchived: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, viewerId, includeArchived }) => { await requireStaff(ctx, viewerId, tenantId) const dashboards = await ctx.db .query("dashboards") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() const filtered = (includeArchived ? dashboards : dashboards.filter((d) => !(d.isArchived ?? false))).sort( (a, b) => b.updatedAt - a.updatedAt, ) return Promise.all( filtered.map(async (dashboard) => { const widgets = await ctx.db .query("dashboardWidgets") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboard._id)) .collect() return { ...sanitizeDashboard(dashboard), widgetsCount: widgets.length, } }), ) }, }) export const get = query({ args: { tenantId: v.string(), viewerId: v.id("users"), dashboardId: v.id("dashboards"), }, handler: async (ctx, { tenantId, viewerId, dashboardId }) => { await requireStaff(ctx, viewerId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } const widgets = await ctx.db .query("dashboardWidgets") .withIndex("by_dashboard_order", (q) => q.eq("dashboardId", dashboardId)) .collect() widgets.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) const shares = await ctx.db .query("dashboardShares") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) .collect() return { dashboard: sanitizeDashboard(dashboard), widgets: widgets.map((widget) => ({ id: widget._id, dashboardId: widget.dashboardId, widgetKey: widget.widgetKey, title: widget.title ?? null, type: widget.type, config: widget.config, layout: widget.layout ?? null, order: widget.order, isHidden: widget.isHidden ?? false, createdAt: widget.createdAt, updatedAt: widget.updatedAt, })), shares: shares.map((share) => ({ id: share._id, audience: share.audience, token: share.token ?? null, expiresAt: share.expiresAt ?? null, canEdit: share.canEdit, createdBy: share.createdBy, createdAt: share.createdAt, lastAccessAt: share.lastAccessAt ?? null, })), } }, }) export const create = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), aspectRatio: v.optional(v.string()), theme: v.optional(v.string()), }, handler: async (ctx, { tenantId, actorId, name, description, aspectRatio, theme }) => { await requireStaff(ctx, actorId, tenantId) const trimmedName = name.trim() if (trimmedName.length === 0) { throw new ConvexError("Nome do dashboard inválido") } const now = Date.now() const dashboardId = await ctx.db.insert("dashboards", { tenantId, name: trimmedName, description: sanitizeTitle(description), aspectRatio: aspectRatio?.trim() || "16:9", theme: theme?.trim() || "system", filters: {}, layout: [], sections: [], tvIntervalSeconds: 30, readySelector: "[data-dashboard-ready=true]", createdBy: actorId, updatedBy: actorId, createdAt: now, updatedAt: now, isArchived: false, }) return { id: dashboardId } }, }) export const updateMetadata = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), name: v.optional(v.string()), description: v.optional(v.string()), aspectRatio: v.optional(v.string()), theme: v.optional(v.string()), readySelector: v.optional(v.string()), tvIntervalSeconds: v.optional(v.number()), }, handler: async (ctx, { tenantId, actorId, dashboardId, name, description, aspectRatio, theme, readySelector, tvIntervalSeconds }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } const patch: Partial> = {} if (typeof name === "string") { const trimmed = name.trim() if (!trimmed) throw new ConvexError("Nome do dashboard inválido") patch.name = trimmed } if (typeof description !== "undefined") { patch.description = sanitizeTitle(description) } if (typeof aspectRatio === "string") { patch.aspectRatio = aspectRatio.trim() || "16:9" } if (typeof theme === "string") { patch.theme = theme.trim() || "system" } if (typeof readySelector === "string") { patch.readySelector = readySelector.trim() || "[data-dashboard-ready=true]" } if (typeof tvIntervalSeconds === "number" && Number.isFinite(tvIntervalSeconds) && tvIntervalSeconds > 0) { patch.tvIntervalSeconds = Math.max(5, Math.round(tvIntervalSeconds)) } patch.updatedAt = Date.now() patch.updatedBy = actorId await ctx.db.patch(dashboardId, patch) return { ok: true } }, }) export const updateFilters = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), filters: v.any(), }, handler: async (ctx, { tenantId, actorId, dashboardId, filters }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } await ctx.db.patch(dashboardId, { filters, updatedAt: Date.now(), updatedBy: actorId, }) return { ok: true } }, }) export const updateSections = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), sections: v.array( v.object({ id: v.string(), title: v.optional(v.string()), description: v.optional(v.string()), widgetKeys: v.array(v.string()), durationSeconds: v.optional(v.number()), }), ), }, handler: async (ctx, { tenantId, actorId, dashboardId, sections }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } const normalized = sections.map((section) => ({ ...section, title: sanitizeTitle(section.title), description: sanitizeTitle(section.description), durationSeconds: typeof section.durationSeconds === "number" && Number.isFinite(section.durationSeconds) ? Math.max(5, Math.round(section.durationSeconds)) : undefined, })) await ctx.db.patch(dashboardId, { sections: normalized, updatedAt: Date.now(), updatedBy: actorId, }) return { ok: true } }, }) export const updateLayout = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), layout: v.array(gridItemValidator), }, handler: async (ctx, { tenantId, actorId, dashboardId, layout }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } const widgets = await ctx.db .query("dashboardWidgets") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) .collect() const byKey = new Map>() widgets.forEach((widget) => byKey.set(widget.widgetKey, widget)) const now = Date.now() await ctx.db.patch(dashboardId, { layout, updatedAt: now, updatedBy: actorId, }) for (let index = 0; index < layout.length; index++) { const item = layout[index] const widget = byKey.get(item.i) if (!widget) { throw new ConvexError(`Widget ${item.i} não encontrado neste dashboard`) } await ctx.db.patch(widget._id, { layout: { x: item.x, y: item.y, w: item.w, h: item.h, minW: item.minW, minH: item.minH, static: item.static, }, order: normalizeOrder(index), updatedAt: now, updatedBy: actorId, }) } return { ok: true } }, }) export const addWidget = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), type: v.string(), title: v.optional(v.string()), config: v.optional(v.any()), layout: v.optional(widgetLayoutValidator), }, handler: async (ctx, { tenantId, actorId, dashboardId, type, title, config, layout }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } assertWidgetType(type) const widgetKey = generateWidgetKey(dashboardId) const now = Date.now() const existingWidgets = await ctx.db .query("dashboardWidgets") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) .collect() const widgetId = await ctx.db.insert("dashboardWidgets", { tenantId, dashboardId, widgetKey, title: sanitizeTitle(title), type, config: normalizeWidgetConfig(type, config), layout: layout ?? undefined, order: existingWidgets.length, createdBy: actorId, updatedBy: actorId, createdAt: now, updatedAt: now, isHidden: false, }) const nextLayout = [...(dashboard.layout ?? [])] if (layout) { nextLayout.push({ i: widgetKey, ...layout }) } else { const baseY = Math.max(0, nextLayout.length * 4) nextLayout.push({ i: widgetKey, x: 0, y: baseY, w: 6, h: 6 }) } await ctx.db.patch(dashboardId, { layout: nextLayout, updatedAt: now, updatedBy: actorId, }) return { id: widgetId, widgetKey } }, }) export const updateWidget = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), widgetId: v.id("dashboardWidgets"), title: v.optional(v.string()), type: v.optional(v.string()), config: v.optional(v.any()), layout: v.optional(widgetLayoutValidator), hidden: v.optional(v.boolean()), order: v.optional(v.number()), }, handler: async (ctx, { tenantId, actorId, widgetId, title, type, config, layout, hidden, order }) => { await requireStaff(ctx, actorId, tenantId) const widget = await ctx.db.get(widgetId) if (!widget || widget.tenantId !== tenantId) { throw new ConvexError("Widget não encontrado") } const patch: Partial> = {} if (typeof title !== "undefined") { patch.title = sanitizeTitle(title) } if (typeof type === "string") { assertWidgetType(type) patch.type = type patch.config = normalizeWidgetConfig(type, config ?? widget.config) } else if (typeof config !== "undefined") { patch.config = config } if (typeof layout !== "undefined") { patch.layout = layout } if (typeof hidden === "boolean") { patch.isHidden = hidden } if (typeof order === "number" && Number.isFinite(order)) { patch.order = Math.max(0, Math.round(order)) } patch.updatedAt = Date.now() patch.updatedBy = actorId await ctx.db.patch(widgetId, patch) return { ok: true } }, }) export const duplicateWidget = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), widgetId: v.id("dashboardWidgets"), }, handler: async (ctx, { tenantId, actorId, widgetId }) => { await requireStaff(ctx, actorId, tenantId) const widget = await ctx.db.get(widgetId) if (!widget || widget.tenantId !== tenantId) { throw new ConvexError("Widget não encontrado") } const dashboard = await ctx.db.get(widget.dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } assertWidgetType(widget.type) const now = Date.now() const widgetKey = generateWidgetKey(widget.dashboardId) const newWidgetId = await ctx.db.insert("dashboardWidgets", { tenantId, dashboardId: widget.dashboardId, widgetKey, title: sanitizeTitle(widget.title), type: widget.type, config: widget.config, layout: widget.layout ?? undefined, order: widget.order + 1, createdBy: actorId, updatedBy: actorId, createdAt: now, updatedAt: now, isHidden: widget.isHidden ?? false, }) const duplicateLayout = widget.layout ? { x: widget.layout.x, y: (widget.layout.y ?? 0) + (widget.layout.h ?? 6) + 1, w: widget.layout.w, h: widget.layout.h ?? 6, minW: widget.layout.minW, minH: widget.layout.minH, static: widget.layout.static, } : { x: 0, y: Math.max(0, (dashboard.layout?.length ?? 0) * 4), w: 6, h: 6 } const nextLayout = [...(dashboard.layout ?? []), { i: widgetKey, ...duplicateLayout }] await ctx.db.patch(dashboard._id, { layout: nextLayout, updatedAt: now, updatedBy: actorId, }) return { id: newWidgetId, widgetKey } }, }) export const removeWidget = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), widgetId: v.id("dashboardWidgets"), }, handler: async (ctx, { tenantId, actorId, widgetId }) => { await requireStaff(ctx, actorId, tenantId) const widget = await ctx.db.get(widgetId) if (!widget || widget.tenantId !== tenantId) { throw new ConvexError("Widget não encontrado") } const dashboard = await ctx.db.get(widget.dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } await ctx.db.delete(widgetId) const filteredLayout = (dashboard.layout ?? []).filter((item) => item.i !== widget.widgetKey) await ctx.db.patch(dashboard._id, { layout: filteredLayout, updatedAt: Date.now(), updatedBy: actorId, }) return { ok: true } }, }) export const archive = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), archived: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, actorId, dashboardId, archived }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } await ctx.db.patch(dashboardId, { isArchived: archived ?? !(dashboard.isArchived ?? false), updatedAt: Date.now(), updatedBy: actorId, }) return { ok: true } }, }) export const upsertShare = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), audience: v.union(v.literal("private"), v.literal("tenant"), v.literal("public-link")), canEdit: v.boolean(), expiresAt: v.optional(v.union(v.number(), v.null())), token: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, { tenantId, actorId, dashboardId, audience, canEdit, expiresAt, token }) => { await requireStaff(ctx, actorId, tenantId) const dashboard = await ctx.db.get(dashboardId) if (!dashboard || dashboard.tenantId !== tenantId) { throw new ConvexError("Dashboard não encontrado") } const existingShares = await ctx.db .query("dashboardShares") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) .collect() const now = Date.now() let shareDoc = existingShares.find((share) => share.audience === audience) const normalizedExpiresAt = typeof expiresAt === "number" && Number.isFinite(expiresAt) ? Math.max(now, Math.round(expiresAt)) : undefined const normalizedToken = typeof token === "string" && token.trim().length > 0 ? token.trim() : undefined if (!shareDoc) { const generatedToken = audience === "public-link" ? normalizedToken ?? cryptoToken() : undefined await ctx.db.insert("dashboardShares", { tenantId, dashboardId, audience, token: generatedToken, canEdit, expiresAt: normalizedExpiresAt, createdBy: actorId, createdAt: now, lastAccessAt: undefined, }) } else { await ctx.db.patch(shareDoc._id, { canEdit, token: audience === "public-link" ? normalizedToken ?? shareDoc.token ?? cryptoToken() : undefined, expiresAt: normalizedExpiresAt, lastAccessAt: shareDoc.lastAccessAt, }) } await ctx.db.patch(dashboardId, { updatedAt: now, updatedBy: actorId }) return { ok: true } }, }) export const revokeShareToken = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), dashboardId: v.id("dashboards"), }, handler: async (ctx, { tenantId, actorId, dashboardId }) => { await requireStaff(ctx, actorId, tenantId) const shares = await ctx.db .query("dashboardShares") .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) .collect() for (const share of shares) { if (share.audience === "public-link") { await ctx.db.patch(share._id, { token: cryptoToken(), lastAccessAt: undefined, }) } } await ctx.db.patch(dashboardId, { updatedAt: Date.now(), updatedBy: actorId }) return { ok: true } }, }) function cryptoToken() { return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6) }