From 73fdf1c6ab76c9dee3b5cd21502bfeb6dec2d9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 22:23:05 +0200 Subject: [PATCH] refactor(api): extract assistant dashboard insights slice --- docs/architecture-hardening-backlog.md | 3 +- packages/api/src/router/assistant-tools.ts | 214 +------------- .../dashboard-insights-reports.ts | 261 ++++++++++++++++++ 3 files changed, 275 insertions(+), 203 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/dashboard-insights-reports.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index dcbcdf6..2b400ae 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -39,12 +39,13 @@ - the demand, staffing-suggestion, capacity, and resource-availability assistant helpers now live in their own domain module, keeping staffing orchestration out of the monolithic assistant router without changing the assistant contract - the resource search, detail, and lifecycle assistant helpers now live in their own domain module, keeping resource CRUD orchestration out of the monolithic assistant router without changing the assistant contract - the blueprint and rate-card read helpers now live in their own domain module, keeping reference-data and pricing lookups out of the monolithic assistant router without changing the assistant contract +- the dashboard detail, insight summary, anomaly detection, and dynamic report read helpers now live in their own domain module, keeping controller-side analytics reads out of the monolithic assistant router without changing the assistant contract ## Next Up Pin the next structural cleanup on the API side: continue splitting `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract. -The next clean slice should stay adjacent to the extracted domains and target one cohesive read-model block such as dashboard/insight/report helpers or another tightly bound cluster still in the monolithic router. +The next clean slice should stay adjacent to the extracted domains and target one cohesive collaboration or analytics block such as comments/audit helpers or scenario/narrative/rate-analysis helpers still living in the monolithic router. ## Remaining Major Themes diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 08be140..9b8bd92 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -125,6 +125,10 @@ import { blueprintsRateCardsToolDefinitions, createBlueprintsRateCardsExecutors, } from "./assistant-tools/blueprints-rate-cards.js"; +import { + createDashboardInsightsReportsExecutors, + dashboardInsightsReportsToolDefinitions, +} from "./assistant-tools/dashboard-insights-reports.js"; import { withToolAccess, type ToolAccessRequirements, @@ -413,16 +417,9 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial, ctx: ToolContext) { - const caller = createInsightsCaller(createScopedCallerContext(ctx)); - return caller.getAnomalyDetail(); - }, - - async get_skill_gaps(_params: Record, ctx: ToolContext) { - const caller = createDashboardCaller(createScopedCallerContext(ctx)); - return caller.getSkillGapSummary(); - }, - - async get_project_health(_params: Record, ctx: ToolContext) { - const caller = createDashboardCaller(createScopedCallerContext(ctx)); - return caller.getProjectHealthDetail(); - }, - - async get_budget_forecast(_params: Record, ctx: ToolContext) { - assertPermission(ctx, "viewCosts" as PermissionKey); - - const caller = createDashboardCaller(createScopedCallerContext(ctx)); - return caller.getBudgetForecastDetail(); - }, - - async get_insights_summary(_params: Record, ctx: ToolContext) { - const caller = createInsightsCaller(createScopedCallerContext(ctx)); - return caller.getInsightsSummary(); - }, - - async run_report(params: { - entity: string; - columns: string[]; - filters?: Array<{ - field: string; - op: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in"; - value: string; - }>; - periodMonth?: string; - groupBy?: string; - sortBy?: string; - sortDir?: "asc" | "desc"; - limit?: number; - }, ctx: ToolContext) { - const entity = params.entity as "resource" | "project" | "assignment" | "resource_month"; - if (!["resource", "project", "assignment", "resource_month"].includes(entity)) { - return { - error: - `Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`, - }; - } - const caller = createReportCaller(createScopedCallerContext(ctx)); - const result = await caller.getReportData({ - entity, - columns: params.columns, - filters: params.filters ?? [], - periodMonth: params.periodMonth, - groupBy: params.groupBy, - sortBy: params.sortBy, - sortDir: params.sortDir ?? "asc", - limit: Math.min(params.limit ?? 50, 200), - offset: 0, - }); - - return { - rows: result.rows, - rowCount: result.rows.length, - totalCount: result.totalCount, - columns: result.columns, - groups: result.groups, - }; - }, - async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) { const caller = createCommentCaller(createScopedCallerContext(ctx)); const comments = await caller.list({ diff --git a/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts b/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts new file mode 100644 index 0000000..e39a5c7 --- /dev/null +++ b/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts @@ -0,0 +1,261 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import type { TRPCContext } from "../../trpc.js"; +import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; + +type ReportEntity = "resource" | "project" | "assignment" | "resource_month"; +type ReportFilterOperator = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in"; + +type ReportFilter = { + field: string; + op: ReportFilterOperator; + value: string; +}; + +type ReportQueryResult = { + rows: unknown[]; + totalCount: number; + columns: unknown; + groups: unknown; +}; + +type DashboardInsightsReportsDeps = { + assertPermission: (ctx: ToolContext, perm: PermissionKey) => void; + createDashboardCaller: (ctx: TRPCContext) => { + getDetail: (params: { section?: string }) => Promise; + getSkillGapSummary: () => Promise; + getProjectHealthDetail: () => Promise; + getBudgetForecastDetail: () => Promise; + }; + createInsightsCaller: (ctx: TRPCContext) => { + getAnomalyDetail: () => Promise; + getInsightsSummary: () => Promise; + }; + createReportCaller: (ctx: TRPCContext) => { + getReportData: (params: { + entity: ReportEntity; + columns: string[]; + filters: ReportFilter[]; + periodMonth?: string; + groupBy?: string; + sortBy?: string; + sortDir: "asc" | "desc"; + limit: number; + offset: number; + }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; +}; + +const REPORT_ENTITIES: ReportEntity[] = ["resource", "project", "assignment", "resource_month"]; + +export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "get_dashboard_detail", + description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.", + parameters: { + type: "object", + properties: { + section: { + type: "string", + description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all", + }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "detect_anomalies", + description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_skill_gaps", + description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_project_health", + description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_budget_forecast", + description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_insights_summary", + description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "run_report", + description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.", + parameters: { + type: "object", + properties: { + entity: { + type: "string", + enum: ["resource", "project", "assignment", "resource_month"], + description: "Entity type to query", + }, + columns: { + type: "array", + items: { type: "string" }, + description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')", + }, + filters: { + type: "array", + items: { + type: "object", + properties: { + field: { type: "string", description: "Field to filter on" }, + op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" }, + value: { type: "string", description: "Filter value (string)" }, + }, + required: ["field", "op", "value"], + }, + description: "Filters to apply", + }, + periodMonth: { + type: "string", + description: "Required for resource_month reports. Format: YYYY-MM", + }, + groupBy: { + type: "string", + description: "Optional scalar field used to group result rows into labeled sections.", + }, + sortBy: { + type: "string", + description: "Optional scalar field used to sort rows within the grouped result.", + }, + sortDir: { + type: "string", + enum: ["asc", "desc"], + description: "Sort direction for sortBy. Default: asc", + }, + limit: { type: "integer", description: "Max results. Default: 50" }, + }, + required: ["entity", "columns"], + }, + }, + }, +], { + get_dashboard_detail: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + detect_anomalies: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + get_skill_gaps: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + get_project_health: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + get_budget_forecast: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + get_insights_summary: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + run_report: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, +}); + +export function createDashboardInsightsReportsExecutors( + deps: DashboardInsightsReportsDeps, +): Record { + return { + async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) { + const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx)); + return caller.getDetail({ ...(params.section ? { section: params.section } : {}) }); + }, + + async detect_anomalies(_params: Record, ctx: ToolContext) { + const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx)); + return caller.getAnomalyDetail(); + }, + + async get_skill_gaps(_params: Record, ctx: ToolContext) { + const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx)); + return caller.getSkillGapSummary(); + }, + + async get_project_health(_params: Record, ctx: ToolContext) { + const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx)); + return caller.getProjectHealthDetail(); + }, + + async get_budget_forecast(_params: Record, ctx: ToolContext) { + deps.assertPermission(ctx, PermissionKey.VIEW_COSTS); + const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx)); + return caller.getBudgetForecastDetail(); + }, + + async get_insights_summary(_params: Record, ctx: ToolContext) { + const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx)); + return caller.getInsightsSummary(); + }, + + async run_report(params: { + entity: string; + columns: string[]; + filters?: ReportFilter[]; + periodMonth?: string; + groupBy?: string; + sortBy?: string; + sortDir?: "asc" | "desc"; + limit?: number; + }, ctx: ToolContext) { + const entity = params.entity as ReportEntity; + if (!REPORT_ENTITIES.includes(entity)) { + return { + error: + `Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`, + }; + } + + const caller = deps.createReportCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.getReportData({ + entity, + columns: params.columns, + filters: params.filters ?? [], + ...(params.periodMonth !== undefined ? { periodMonth: params.periodMonth } : {}), + ...(params.groupBy !== undefined ? { groupBy: params.groupBy } : {}), + ...(params.sortBy !== undefined ? { sortBy: params.sortBy } : {}), + sortDir: params.sortDir ?? "asc", + limit: Math.min(params.limit ?? 50, 200), + offset: 0, + }); + + return { + rows: result.rows, + rowCount: result.rows.length, + totalCount: result.totalCount, + columns: result.columns, + groups: result.groups, + }; + }, + }; +}