/** * `dench crm analytics …` * * Thin dispatcher over the Convex functions the analytics strip itself * uses (`functions/crm/analytics`), so the CLI, the `/api/v1` surface and * the UI share one contract: `computePanels` is the only chart math, and a * dashboard belongs to the object or to ONE saved view (`--view`). * * get [--view V] read config (+ the view's own dashboard) * compute [--view V] [--period P] [--panel ID ...] [--panels JSON] [--filters JSON] * [--assigned-to me|unassigned|] [--assignee-field F] personal scope * save --panel JSON [--view V] create / replace one panel * update --patch JSON [--view V] edit part of one panel (null clears) * hide [--view V] keep it off the strip (full view only) * show [--view V] back onto the strip * resize [--view V] * delete [--view V] * reorder … [--view V] full order * roles --roles JSON [--merge] field roles shared by every chart + view * collapse | expand the strip above the table * scope --mode own|shared view-owned vs shared dashboard * * `--period` is expanded SERVER-SIDE (`computePanels.period`) into the same * extra + compare filters the strip's pill builds, so a CLI number and the * UI number for "this month" always agree. Kept out of `crm.ts` for size. */ import { makeFunctionReference } from "convex/server"; import { assertNoUnknownFlags, type CrmCliContext, CrmCliError, callMutation, callQuery, out, resolveAssigneeScopeFlags, shift, } from "./crm"; import { getFlag, hasFlag, parseJson } from "./lib/cli-args"; const api = { getConfig: makeFunctionReference<"query">( "functions/crm/analytics:getAnalyticsConfig", ), compute: makeFunctionReference<"query">( "functions/crm/analytics:computePanels", ), savePanel: makeFunctionReference<"mutation">( "functions/crm/analytics:saveAnalyticsPanel", ), updatePanel: makeFunctionReference<"mutation">( "functions/crm/analytics:updateAnalyticsPanel", ), deletePanel: makeFunctionReference<"mutation">( "functions/crm/analytics:deleteAnalyticsPanel", ), reorder: makeFunctionReference<"mutation">( "functions/crm/analytics:reorderAnalyticsPanels", ), setRoles: makeFunctionReference<"mutation">( "functions/crm/analytics:setAnalyticsFieldRoles", ), setCollapsed: makeFunctionReference<"mutation">( "functions/crm/analytics:setAnalyticsStripCollapsed", ), setViewMode: makeFunctionReference<"mutation">( "functions/crm/analytics:setViewAnalyticsMode", ), }; /** Same ids as ANALYTICS_PERIODS in convex/lib/crm/analyticsPeriod.ts. */ export const ANALYTICS_CLI_PERIODS = [ "all", "this_week", "this_month", "last_month", "this_quarter", "last_quarter", "this_year", ] as const; const PANEL_TYPES = [ "stat", "bar", "line", "area", "donut", "funnel", "leaderboard", "forecast", "map", ] as const; /** * `resize` widths: tracks of the 6-track grid, by number or by name. * `default` clears the stored span so the type's default applies again. */ export const ANALYTICS_CLI_WIDTHS: Record = { "2": 2, "3": 3, "4": 4, "6": 6, third: 2, "1/3": 2, half: 3, "1/2": 3, "two-thirds": 4, "2/3": 4, full: 6, default: null, }; export function parseWidth(raw: string): 2 | 3 | 4 | 6 | null { const key = raw.trim().toLowerCase(); if (!(key in ANALYTICS_CLI_WIDTHS)) { throw new CrmCliError( `Width must be one of third | half | two-thirds | full | default (or 2 | 3 | 4 | 6 tracks), got "${raw}".`, ); } return ANALYTICS_CLI_WIDTHS[key]; } function viewFlag(args: string[]): { viewName: string } | Record { const view = getFlag(args, "--view"); return view?.trim() ? { viewName: view.trim() } : {}; } function parsePeriod(raw: string | undefined): string | undefined { if (raw === undefined) return undefined; const period = raw.trim(); if (!(ANALYTICS_CLI_PERIODS as readonly string[]).includes(period)) { throw new CrmCliError( `--period must be one of ${ANALYTICS_CLI_PERIODS.join(" | ")}, got "${raw}".`, ); } return period === "all" ? undefined : period; } function parseObjectFlag( args: string[], name: string, ): Record | undefined { const raw = getFlag(args, name); if (raw === undefined) return undefined; const parsed = parseJson(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new CrmCliError(`${name} must be a JSON object.`); } return parsed as Record; } function parseArrayFlag( args: string[], name: string, ): Array> | undefined { const raw = getFlag(args, name); if (raw === undefined) return undefined; const parsed = parseJson(raw); if (!Array.isArray(parsed)) { throw new CrmCliError(`${name} must be a JSON array.`); } return parsed as Array>; } /** Repeated `--panel ` flags (compute) → panelIds. */ function collectPanelIds(args: string[]): string[] { const ids: string[] = []; for (;;) { const value = getFlag(args, "--panel"); if (value === undefined) break; if (value.trim()) ids.push(value.trim()); } return ids; } /** * Normalize an ad-hoc panel from the CLI: fill `id`, default `sortOrder` * and `metric` so a quick `--panel '{"title":"Leads by source","type": * "donut","groupBy":"Source"}'` is enough. Validation proper happens in * the Convex validator. */ export function normalizePanelInput( raw: Record, opts: { id?: string; index?: number } = {}, ): Record { const id = opts.id ?? (typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `adhoc:${String(raw.title ?? raw.type ?? "panel") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace( /(^-|-$)/g, "", )}${opts.index !== undefined ? `-${opts.index}` : ""}`); const type = typeof raw.type === "string" ? raw.type : "stat"; if (!(PANEL_TYPES as readonly string[]).includes(type)) { throw new CrmCliError( `Panel type must be one of ${PANEL_TYPES.join(" | ")}, got "${type}".`, ); } return { ...raw, id, type, title: typeof raw.title === "string" ? raw.title : id, metric: typeof raw.metric === "string" ? raw.metric : "count", sortOrder: typeof raw.sortOrder === "number" ? raw.sortOrder : (opts.index ?? 0), }; } /** * Relative deep link to the dashboard the result came from — the same * link the in-chat card's Open uses (`buildAnalyticsLink` in the web app). */ export function analyticsDeepLink(args: { objectName: string; viewName?: string; period?: string; /** Personal assignee scope the numbers were computed under (`?assignee=`). */ assignedTo?: string; assigneeField?: string; host?: string; workspaceSlug?: string; }): { href: string; url?: string } { const params = new URLSearchParams(); params.set("path", args.objectName); if (args.viewName) params.set("view", args.viewName); params.set("analytics", "1"); if (args.period && args.period !== "all") params.set("analyticsPeriod", args.period); if (args.assignedTo) { params.set("assignee", args.assignedTo); if (args.assigneeField) params.set("assigneeField", args.assigneeField); } const href = `/?${params.toString()}`; const slug = args.workspaceSlug?.trim(); const host = args.host?.trim(); if (!slug || !host) return { href }; const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`; try { return { href, url: `${new URL(withProtocol).origin}/${encodeURIComponent(slug)}?${params.toString()}`, }; } catch { return { href }; } } export async function runAnalyticsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "get": { const objectName = shift(ctx.args, "object name"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics get"); out(ctx, await callQuery(ctx, api.getConfig, { objectName, ...view })); return; } case "compute": { const objectName = shift(ctx.args, "object name"); const view = viewFlag(ctx.args); const period = parsePeriod(getFlag(ctx.args, "--period")); const panelIds = collectPanelIds(ctx.args); const adhoc = parseArrayFlag(ctx.args, "--panels"); const extraFilters = parseObjectFlag(ctx.args, "--filters"); const compareFilters = parseObjectFlag(ctx.args, "--compare-filters"); const rolesOverride = parseObjectFlag(ctx.args, "--roles"); const assigneeScopeArgs = resolveAssigneeScopeFlags(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics compute"); if (panelIds.length > 0 && adhoc) { throw new CrmCliError( "Pass either --panel (stored panels) or --panels (ad-hoc), not both.", ); } const result = (await callQuery(ctx, api.compute, { objectName, ...view, ...(panelIds.length > 0 ? { panelIds } : {}), ...(adhoc ? { panels: adhoc.map((p, i) => normalizePanelInput(p, { index: i })), } : {}), ...(period ? { period } : {}), ...(extraFilters ? { extraFilters } : {}), ...(compareFilters ? { compareFilters } : {}), ...(rolesOverride ? { fieldRolesOverride: rolesOverride } : {}), ...assigneeScopeArgs, })) as Record; const link = analyticsDeepLink({ objectName, viewName: "viewName" in view ? view.viewName : undefined, period, assignedTo: assigneeScopeArgs.assignedTo, assigneeField: assigneeScopeArgs.assigneeField, host: ctx.host, workspaceSlug: ctx.workspaceSlug, }); out(ctx, { ...result, ...(period ? { period } : {}), ...(assigneeScopeArgs.assignedTo ? { assignedTo: assigneeScopeArgs.assignedTo } : {}), link: link.url ?? link.href, }); return; } case "save": { const objectName = shift(ctx.args, "object name"); const panelId = shift(ctx.args, "panel id"); const raw = parseObjectFlag(ctx.args, "--panel"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics save"); if (!raw) throw new CrmCliError("--panel is required."); out( ctx, await callMutation(ctx, api.savePanel, { objectName, panel: normalizePanelInput(raw, { id: panelId }), ...view, }), ); return; } case "update": { const objectName = shift(ctx.args, "object name"); const panelId = shift(ctx.args, "panel id"); const patch = parseObjectFlag(ctx.args, "--patch"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics update"); if (!patch || Object.keys(patch).length === 0) { throw new CrmCliError( '--patch is required, e.g. --patch \'{"title":"Won by rep","span":2,"hidden":false}\' (null clears a key).', ); } out( ctx, await callMutation(ctx, api.updatePanel, { objectName, panelId, patch, ...view, }), ); return; } case "hide": case "show": { const objectName = shift(ctx.args, "object name"); const panelId = shift(ctx.args, "panel id"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, `crm analytics ${verb}`); out( ctx, await callMutation(ctx, api.updatePanel, { objectName, panelId, patch: { hidden: verb === "hide" }, ...view, }), ); return; } case "resize": { const objectName = shift(ctx.args, "object name"); const panelId = shift(ctx.args, "panel id"); const span = parseWidth(shift(ctx.args, "width")); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics resize"); out( ctx, await callMutation(ctx, api.updatePanel, { objectName, panelId, patch: { span }, ...view, }), ); return; } case "delete": { const objectName = shift(ctx.args, "object name"); const panelId = shift(ctx.args, "panel id"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics delete"); out( ctx, await callMutation(ctx, api.deletePanel, { objectName, panelId, ...view, }), ); return; } case "reorder": { const objectName = shift(ctx.args, "object name"); const view = viewFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm analytics reorder"); const panelIds = ctx.args.splice(0).filter((id) => id.trim()); if (panelIds.length === 0) { throw new CrmCliError( "Pass every panel id in the new order: dench crm analytics reorder …", ); } out( ctx, await callMutation(ctx, api.reorder, { objectName, panelIds, ...view }), ); return; } case "roles": { const objectName = shift(ctx.args, "object name"); const fieldRoles = parseObjectFlag(ctx.args, "--roles"); const merge = hasFlag(ctx.args, "--merge"); assertNoUnknownFlags(ctx.args, "crm analytics roles"); if (!fieldRoles) throw new CrmCliError("--roles is required."); out( ctx, await callMutation(ctx, api.setRoles, { objectName, fieldRoles, ...(merge ? { merge: true } : {}), }), ); return; } case "collapse": case "expand": { const objectName = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, `crm analytics ${verb}`); out( ctx, await callMutation(ctx, api.setCollapsed, { objectName, collapsed: verb === "collapse", }), ); return; } case "scope": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); const mode = getFlag(ctx.args, "--mode"); const own = hasFlag(ctx.args, "--own"); const shared = hasFlag(ctx.args, "--shared"); assertNoUnknownFlags(ctx.args, "crm analytics scope"); const resolved = mode ?? (own ? "own" : shared ? "shared" : undefined); if (resolved !== "own" && resolved !== "shared") { throw new CrmCliError("--mode own|shared is required."); } out( ctx, await callMutation(ctx, api.setViewMode, { objectName, viewName, mode: resolved, }), ); return; } default: throw new CrmCliError( `Unknown analytics verb: ${verb ?? "(none)"}. Use get | compute | save | update | hide | show | resize | delete | reorder | roles | collapse | expand | scope.`, ); } }