diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 7c2abf2..9bbd7f3 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -31,12 +31,13 @@ - the adjacent chargeability/computation read helpers now live in their own domain module, keeping the advanced financial transparency read models out of the monolithic router without changing the assistant contract - the neighboring country and metro-city admin mutations now live in their own domain module, keeping more settings-side CRUD wiring out of the monolithic router without changing the assistant contract - the adjacent management-level, utilization, calculation-rule, effort-rule, and experience-multiplier read helpers now live in their own domain module, further shrinking the monolithic assistant router without changing the assistant contract +- the authenticated user self-service assistant helpers now live in their own domain module, covering assignable users, dashboard preferences, favorites, column preferences, and MFA self-service 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 block such as the authenticated user/self-service assistant tools, or the remaining estimate and project admin helper clusters that are still embedded in the monolithic router. +The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as the remaining admin-user helper cluster around `list_users` and user mutations, or the embedded estimate and project admin helper clusters that are still 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 aacdb19..57dbc5b 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -95,6 +95,10 @@ import { countryMetroAdminToolDefinitions, createCountryMetroAdminExecutors, } from "./assistant-tools/country-metro-admin.js"; +import { + createUserSelfServiceExecutors, + userSelfServiceToolDefinitions, +} from "./assistant-tools/user-self-service.js"; import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js"; import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js"; @@ -2794,152 +2798,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, - { - type: "function", - function: { - name: "list_assignable_users", - description: "List lightweight users available for assignment workflows. Manager or admin role required.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "get_current_user", - description: "Get the authenticated user's own profile, role, and permission overrides.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "get_dashboard_layout", - description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "save_dashboard_layout", - description: "Save the authenticated user's dashboard layout. Always confirm first.", - parameters: { - type: "object", - properties: { - layout: { - type: "array", - description: "Dashboard layout items as stored by the user router.", - items: { type: "object" }, - }, - }, - required: ["layout"], - }, - }, - }, - { - type: "function", - function: { - name: "get_favorite_project_ids", - description: "Get the authenticated user's favorite project IDs.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "toggle_favorite_project", - description: "Add or remove a project from the authenticated user's favorites. Always confirm first.", - parameters: { - type: "object", - properties: { - projectId: { type: "string", description: "Project ID." }, - }, - required: ["projectId"], - }, - }, - }, - { - type: "function", - function: { - name: "get_column_preferences", - description: "Get the authenticated user's saved table column preferences for all supported views.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "set_column_preferences", - description: "Update the authenticated user's table column preferences for one view. Always confirm first.", - parameters: { - type: "object", - properties: { - view: { - type: "string", - enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"], - description: "View key to update.", - }, - visible: { - type: "array", - items: { type: "string" }, - description: "Visible column IDs.", - }, - sort: { - type: ["object", "null"], - properties: { - field: { type: "string" }, - dir: { type: "string", enum: ["asc", "desc"] }, - }, - description: "Sort state. Use null to clear it.", - }, - rowOrder: { - type: ["array", "null"], - items: { type: "string" }, - description: "Optional row order. Use null to clear it.", - }, - }, - required: ["view"], - }, - }, - }, - { - type: "function", - function: { - name: "generate_totp_secret", - description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "verify_and_enable_totp", - description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.", - parameters: { - type: "object", - properties: { - token: { type: "string", description: "6-digit TOTP token." }, - }, - required: ["token"], - }, - }, - }, - { - type: "function", - function: { - name: "get_mfa_status", - description: "Get the authenticated user's MFA status.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "get_active_user_count", - description: "Get the number of users active in the last five minutes. Admin role required.", - parameters: { type: "object", properties: {} }, - }, - }, + ...userSelfServiceToolDefinitions, { type: "function", function: { @@ -5482,120 +5341,11 @@ const executors = { const users = await caller.list(); return users.slice(0, Math.min(params.limit ?? 50, 100)); }, - - async list_assignable_users(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.listAssignable(); - }, - - async get_current_user(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.me(); - }, - - async get_dashboard_layout(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.getDashboardLayout(); - }, - - async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.saveDashboardLayout({ layout: params.layout }); - return { - __action: "invalidate", - scope: ["dashboard"], - success: true, - ...result, - message: "Saved dashboard layout.", - }; - }, - - async get_favorite_project_ids(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.getFavoriteProjectIds(); - }, - - async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.toggleFavoriteProject({ projectId: params.projectId }); - return { - __action: "invalidate", - scope: ["project"], - success: true, - ...result, - message: result.added ? "Added project to favorites." : "Removed project from favorites.", - }; - }, - - async get_column_preferences(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.getColumnPreferences(); - }, - - async set_column_preferences(params: { - view: "resources" | "projects" | "allocations" | "vacations" | "roles" | "users" | "blueprints"; - visible?: string[]; - sort?: { field: string; dir: "asc" | "desc" } | null; - rowOrder?: string[] | null; - }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.setColumnPreferences({ - view: params.view, - ...(params.visible !== undefined ? { visible: params.visible } : {}), - ...(params.sort !== undefined ? { sort: params.sort } : {}), - ...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}), - }); - return { - __action: "invalidate", - scope: ["user"], - success: true, - ...result, - message: `Updated column preferences for ${params.view}.`, - }; - }, - - async generate_totp_secret(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.generateTotpSecret(); - return { - __action: "invalidate", - scope: ["user"], - success: true, - ...result, - message: "Generated a new MFA TOTP secret.", - }; - }, - - async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.verifyAndEnableTotp({ token: params.token }); - } catch (error) { - const mapped = toAssistantTotpEnableError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - ...result, - message: "Enabled MFA TOTP.", - }; - }, - - async get_mfa_status(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.getMfaStatus(); - }, - - async get_active_user_count(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - return caller.activeCount(); - }, + ...createUserSelfServiceExecutors({ + createUserCaller, + createScopedCallerContext, + toAssistantTotpEnableError, + }), async create_user(params: { email: string; diff --git a/packages/api/src/router/assistant-tools/user-self-service.ts b/packages/api/src/router/assistant-tools/user-self-service.ts new file mode 100644 index 0000000..83b90e1 --- /dev/null +++ b/packages/api/src/router/assistant-tools/user-self-service.ts @@ -0,0 +1,310 @@ +import type { TRPCContext } from "../../trpc.js"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ColumnPreferenceView = + | "resources" + | "projects" + | "allocations" + | "vacations" + | "roles" + | "users" + | "blueprints"; + +type UserSelfServiceDeps = { + createUserCaller: (ctx: TRPCContext) => { + listAssignable: () => Promise; + me: () => Promise; + getDashboardLayout: () => Promise; + saveDashboardLayout: (params: { layout: unknown[] }) => Promise>; + getFavoriteProjectIds: () => Promise; + toggleFavoriteProject: ( + params: { projectId: string }, + ) => Promise & { added: boolean }>; + getColumnPreferences: () => Promise; + setColumnPreferences: (params: { + view: ColumnPreferenceView; + visible?: string[]; + sort?: { field: string; dir: "asc" | "desc" } | null; + rowOrder?: string[] | null; + }) => Promise>; + generateTotpSecret: () => Promise>; + verifyAndEnableTotp: (params: { token: string }) => Promise>; + getMfaStatus: () => Promise; + activeCount: () => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + toAssistantTotpEnableError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const userSelfServiceToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "list_assignable_users", + description: "List lightweight users available for assignment workflows. Manager or admin role required.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_current_user", + description: "Get the authenticated user's own profile, role, and permission overrides.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_dashboard_layout", + description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "save_dashboard_layout", + description: "Save the authenticated user's dashboard layout. Always confirm first.", + parameters: { + type: "object", + properties: { + layout: { + type: "array", + description: "Dashboard layout items as stored by the user router.", + items: { type: "object" }, + }, + }, + required: ["layout"], + }, + }, + }, + { + type: "function", + function: { + name: "get_favorite_project_ids", + description: "Get the authenticated user's favorite project IDs.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "toggle_favorite_project", + description: "Add or remove a project from the authenticated user's favorites. Always confirm first.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Project ID." }, + }, + required: ["projectId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_column_preferences", + description: "Get the authenticated user's saved table column preferences for all supported views.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "set_column_preferences", + description: "Update the authenticated user's table column preferences for one view. Always confirm first.", + parameters: { + type: "object", + properties: { + view: { + type: "string", + enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"], + description: "View key to update.", + }, + visible: { + type: "array", + items: { type: "string" }, + description: "Visible column IDs.", + }, + sort: { + type: ["object", "null"], + properties: { + field: { type: "string" }, + dir: { type: "string", enum: ["asc", "desc"] }, + }, + description: "Sort state. Use null to clear it.", + }, + rowOrder: { + type: ["array", "null"], + items: { type: "string" }, + description: "Optional row order. Use null to clear it.", + }, + }, + required: ["view"], + }, + }, + }, + { + type: "function", + function: { + name: "generate_totp_secret", + description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "verify_and_enable_totp", + description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.", + parameters: { + type: "object", + properties: { + token: { type: "string", description: "6-digit TOTP token." }, + }, + required: ["token"], + }, + }, + }, + { + type: "function", + function: { + name: "get_mfa_status", + description: "Get the authenticated user's MFA status.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_active_user_count", + description: "Get the number of users active in the last five minutes. Admin role required.", + parameters: { type: "object", properties: {} }, + }, + }, +]; + +export function createUserSelfServiceExecutors( + deps: UserSelfServiceDeps, +): Record { + return { + async list_assignable_users(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.listAssignable(); + }, + + async get_current_user(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.me(); + }, + + async get_dashboard_layout(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.getDashboardLayout(); + }, + + async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.saveDashboardLayout({ layout: params.layout }); + return { + __action: "invalidate" as const, + scope: ["dashboard"], + success: true, + ...result, + message: "Saved dashboard layout.", + }; + }, + + async get_favorite_project_ids(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.getFavoriteProjectIds(); + }, + + async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.toggleFavoriteProject({ projectId: params.projectId }); + return { + __action: "invalidate" as const, + scope: ["project"], + success: true, + ...result, + message: result.added ? "Added project to favorites." : "Removed project from favorites.", + }; + }, + + async get_column_preferences(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.getColumnPreferences(); + }, + + async set_column_preferences(params: { + view: ColumnPreferenceView; + visible?: string[]; + sort?: { field: string; dir: "asc" | "desc" } | null; + rowOrder?: string[] | null; + }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.setColumnPreferences({ + view: params.view, + ...(params.visible !== undefined ? { visible: params.visible } : {}), + ...(params.sort !== undefined ? { sort: params.sort } : {}), + ...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}), + }); + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + ...result, + message: `Updated column preferences for ${params.view}.`, + }; + }, + + async generate_totp_secret(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.generateTotpSecret(); + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + ...result, + message: "Generated a new MFA TOTP secret.", + }; + }, + + async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let result; + try { + result = await caller.verifyAndEnableTotp({ token: params.token }); + } catch (error) { + const mapped = deps.toAssistantTotpEnableError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + ...result, + message: "Enabled MFA TOTP.", + }; + }, + + async get_mfa_status(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.getMfaStatus(); + }, + + async get_active_user_count(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + return caller.activeCount(); + }, + }; +}