diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 9bbd7f3..9e16cfd 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -31,13 +31,14 @@ - 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 remaining assistant user-admin helper cluster now lives in its own domain module, covering admin listing, user lifecycle mutations, permission overrides, resource linking, and MFA overrides 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 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. +The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as the embedded notification/task helpers, or the remaining 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 57dbc5b..03fd758 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -99,6 +99,10 @@ import { createUserSelfServiceExecutors, userSelfServiceToolDefinitions, } from "./assistant-tools/user-self-service.js"; +import { + createUserAdminExecutors, + userAdminToolDefinitions, +} from "./assistant-tools/user-admin.js"; import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js"; import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js"; @@ -2785,170 +2789,8 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, ...countryMetroAdminToolDefinitions, ...configReadmodelToolDefinitions, - { - type: "function", - function: { - name: "list_users", - description: "List all system users via the admin user router, including role and MFA state. Admin role required.", - parameters: { - type: "object", - properties: { - limit: { type: "integer", description: "Max results. Default: 50" }, - }, - }, - }, - }, + ...userAdminToolDefinitions, ...userSelfServiceToolDefinitions, - { - type: "function", - function: { - name: "create_user", - description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - email: { type: "string", description: "User email address." }, - name: { type: "string", description: "Display name." }, - systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." }, - password: { type: "string", description: "Initial password, minimum 8 characters." }, - }, - required: ["email", "name", "password"], - }, - }, - }, - { - type: "function", - function: { - name: "set_user_password", - description: "Reset a user's password. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - password: { type: "string", description: "New password, minimum 8 characters." }, - }, - required: ["userId", "password"], - }, - }, - }, - { - type: "function", - function: { - name: "update_user_role", - description: "Change a user's system role. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "User ID." }, - systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - }, - required: ["id", "systemRole"], - }, - }, - }, - { - type: "function", - function: { - name: "update_user_name", - description: "Rename a user. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "User ID." }, - name: { type: "string", description: "New display name." }, - }, - required: ["id", "name"], - }, - }, - }, - { - type: "function", - function: { - name: "link_user_resource", - description: "Link or unlink a user to a resource. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." }, - }, - required: ["userId"], - }, - }, - }, - { - type: "function", - function: { - name: "auto_link_users_by_email", - description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "set_user_permissions", - description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - overrides: { - type: ["object", "null"], - properties: { - granted: { type: "array", items: { type: "string" } }, - denied: { type: "array", items: { type: "string" } }, - chapterIds: { type: "array", items: { type: "string" } }, - }, - description: "Permission override object or null to clear.", - }, - }, - required: ["userId"], - }, - }, - }, - { - type: "function", - function: { - name: "reset_user_permissions", - description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - }, - required: ["userId"], - }, - }, - }, - { - type: "function", - function: { - name: "get_effective_user_permissions", - description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - }, - required: ["userId"], - }, - }, - }, - { - type: "function", - function: { - name: "disable_user_totp", - description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - userId: { type: "string", description: "User ID." }, - }, - required: ["userId"], - }, - }, - }, { type: "function", function: { @@ -5335,237 +5177,18 @@ const executors = { createExperienceMultiplierCaller, createScopedCallerContext, }), - - async list_users(params: { limit?: number }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const users = await caller.list(); - return users.slice(0, Math.min(params.limit ?? 50, 100)); - }, + ...createUserAdminExecutors({ + createUserCaller, + createScopedCallerContext, + toAssistantUserMutationError, + toAssistantUserResourceLinkError, + }), ...createUserSelfServiceExecutors({ createUserCaller, createScopedCallerContext, toAssistantTotpEnableError, }), - async create_user(params: { - email: string; - name: string; - systemRole?: SystemRole; - password: string; - }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let user; - try { - user = await caller.create({ - email: params.email, - name: params.name, - password: params.password, - ...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}), - }); - } catch (error) { - const mapped = toAssistantUserMutationError(error, "create"); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user", "resource"], - success: true, - user, - userId: user.id, - message: `Created user ${user.name}.`, - }; - }, - - async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.setPassword(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error, "password"); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - ...result, - message: `Reset password for user ${params.userId}.`, - }; - }, - - async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let user; - try { - user = await caller.updateRole(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - user, - userId: user.id, - message: `Updated role for ${user.name} to ${user.systemRole}.`, - }; - }, - - async update_user_name(params: { id: string; name: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let user; - try { - user = await caller.updateName(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - user, - userId: user.id, - message: `Updated user name to ${user.name}.`, - }; - }, - - async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.linkResource({ - userId: params.userId, - resourceId: params.resourceId ?? null, - }); - } catch (error) { - const mapped = toAssistantUserResourceLinkError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user", "resource"], - ...result, - message: params.resourceId ? "Linked user to resource." : "Unlinked user resource.", - }; - }, - - async auto_link_users_by_email(_params: Record, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - const result = await caller.autoLinkAllByEmail(); - return { - __action: "invalidate", - scope: ["user", "resource"], - success: true, - ...result, - message: `Auto-linked ${result.linked} user(s) by email.`, - }; - }, - - async set_user_permissions(params: { - userId: string; - overrides?: { - granted?: string[]; - denied?: string[]; - chapterIds?: string[]; - } | null; - }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let user; - try { - user = await caller.setPermissions({ - userId: params.userId, - overrides: params.overrides ?? null, - }); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - user, - userId: user.id, - message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.", - }; - }, - - async reset_user_permissions(params: { userId: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let user; - try { - user = await caller.resetPermissions(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - user, - userId: user.id, - message: "Reset user permissions to role defaults.", - }; - }, - - async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - try { - return await caller.getEffectivePermissions(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - }, - - async disable_user_totp(params: { userId: string }, ctx: ToolContext) { - const caller = createUserCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.disableTotp(params); - } catch (error) { - const mapped = toAssistantUserMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["user"], - success: true, - ...result, - message: `Disabled TOTP for user ${params.userId}.`, - }; - }, - async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) { const caller = createNotificationCaller(createScopedCallerContext(ctx)); return caller.list({ diff --git a/packages/api/src/router/assistant-tools/user-admin.ts b/packages/api/src/router/assistant-tools/user-admin.ts new file mode 100644 index 0000000..2cd2965 --- /dev/null +++ b/packages/api/src/router/assistant-tools/user-admin.ts @@ -0,0 +1,446 @@ +import { SystemRole } from "@capakraken/shared"; +import type { TRPCContext } from "../../trpc.js"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type UserRecord = { + id: string; + name: string | null; + systemRole?: SystemRole | string; +}; + +type UserAdminDeps = { + createUserCaller: (ctx: TRPCContext) => { + list: () => Promise; + create: (params: { + email: string; + name: string; + systemRole?: SystemRole; + password: string; + }) => Promise; + setPassword: (params: { userId: string; password: string }) => Promise>; + updateRole: (params: { id: string; systemRole: SystemRole }) => Promise; + updateName: (params: { id: string; name: string }) => Promise; + linkResource: ( + params: { userId: string; resourceId: string | null }, + ) => Promise>; + autoLinkAllByEmail: () => Promise & { linked: number }>; + setPermissions: (params: { + userId: string; + overrides: { + granted?: string[]; + denied?: string[]; + chapterIds?: string[]; + } | null; + }) => Promise; + resetPermissions: (params: { userId: string }) => Promise; + getEffectivePermissions: (params: { userId: string }) => Promise; + disableTotp: (params: { userId: string }) => Promise>; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + toAssistantUserMutationError: ( + error: unknown, + mode?: "create" | "password", + ) => AssistantToolErrorResult | null; + toAssistantUserResourceLinkError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const userAdminToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "list_users", + description: "List all system users via the admin user router, including role and MFA state. Admin role required.", + parameters: { + type: "object", + properties: { + limit: { type: "integer", description: "Max results. Default: 50" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "create_user", + description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + email: { type: "string", description: "User email address." }, + name: { type: "string", description: "Display name." }, + systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." }, + password: { type: "string", description: "Initial password, minimum 8 characters." }, + }, + required: ["email", "name", "password"], + }, + }, + }, + { + type: "function", + function: { + name: "set_user_password", + description: "Reset a user's password. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + password: { type: "string", description: "New password, minimum 8 characters." }, + }, + required: ["userId", "password"], + }, + }, + }, + { + type: "function", + function: { + name: "update_user_role", + description: "Change a user's system role. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "User ID." }, + systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + }, + required: ["id", "systemRole"], + }, + }, + }, + { + type: "function", + function: { + name: "update_user_name", + description: "Rename a user. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "User ID." }, + name: { type: "string", description: "New display name." }, + }, + required: ["id", "name"], + }, + }, + }, + { + type: "function", + function: { + name: "link_user_resource", + description: "Link or unlink a user to a resource. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "auto_link_users_by_email", + description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "set_user_permissions", + description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + overrides: { + type: ["object", "null"], + properties: { + granted: { type: "array", items: { type: "string" } }, + denied: { type: "array", items: { type: "string" } }, + chapterIds: { type: "array", items: { type: "string" } }, + }, + description: "Permission override object or null to clear.", + }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "reset_user_permissions", + description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_effective_user_permissions", + description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "disable_user_totp", + description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, +]; + +export function createUserAdminExecutors( + deps: UserAdminDeps, +): Record { + return { + async list_users(params: { limit?: number }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const users = await caller.list(); + return users.slice(0, Math.min(params.limit ?? 50, 100)); + }, + + async create_user(params: { + email: string; + name: string; + systemRole?: SystemRole; + password: string; + }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let user; + try { + user = await caller.create({ + email: params.email, + name: params.name, + password: params.password, + ...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}), + }); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error, "create"); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user", "resource"], + success: true, + user, + userId: user.id, + message: `Created user ${user.name}.`, + }; + }, + + async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let result; + try { + result = await caller.setPassword(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error, "password"); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + ...result, + message: `Reset password for user ${params.userId}.`, + }; + }, + + async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let user; + try { + user = await caller.updateRole(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + user, + userId: user.id, + message: `Updated role for ${user.name} to ${user.systemRole}.`, + }; + }, + + async update_user_name(params: { id: string; name: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let user; + try { + user = await caller.updateName(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + user, + userId: user.id, + message: `Updated user name to ${user.name}.`, + }; + }, + + async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let result; + try { + result = await caller.linkResource({ + userId: params.userId, + resourceId: params.resourceId ?? null, + }); + } catch (error) { + const mapped = deps.toAssistantUserResourceLinkError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user", "resource"], + ...result, + message: params.resourceId ? "Linked user to resource." : "Unlinked user resource.", + }; + }, + + async auto_link_users_by_email(_params: Record, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + const result = await caller.autoLinkAllByEmail(); + return { + __action: "invalidate" as const, + scope: ["user", "resource"], + success: true, + ...result, + message: `Auto-linked ${result.linked} user(s) by email.`, + }; + }, + + async set_user_permissions(params: { + userId: string; + overrides?: { + granted?: string[]; + denied?: string[]; + chapterIds?: string[]; + } | null; + }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let user; + try { + user = await caller.setPermissions({ + userId: params.userId, + overrides: params.overrides ?? null, + }); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + user, + userId: user.id, + message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.", + }; + }, + + async reset_user_permissions(params: { userId: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let user; + try { + user = await caller.resetPermissions(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + user, + userId: user.id, + message: "Reset user permissions to role defaults.", + }; + }, + + async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + try { + return await caller.getEffectivePermissions(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + }, + + async disable_user_totp(params: { userId: string }, ctx: ToolContext) { + const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx)); + let result; + try { + result = await caller.disableTotp(params); + } catch (error) { + const mapped = deps.toAssistantUserMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + return { + __action: "invalidate" as const, + scope: ["user"], + success: true, + ...result, + message: `Disabled TOTP for user ${params.userId}.`, + }; + }, + }; +}