From 45c25b17c1c50ec8ef05da63b7dd5c7ba2751a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 22:53:59 +0200 Subject: [PATCH] refactor(api): extract assistant country read slice --- docs/architecture-hardening-backlog.md | 3 +- packages/api/src/router/assistant-tools.ts | 74 ++---------- .../assistant-tools/country-readmodels.ts | 111 ++++++++++++++++++ 3 files changed, 124 insertions(+), 64 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/country-readmodels.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index cfac09e..88feee3 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -45,12 +45,13 @@ - the audit-history assistant helpers now live in their own domain module, keeping controller-side change-history reads out of the monolithic assistant router without changing the assistant contract - the import/export and staged Dispo assistant helpers now live in their own domain module, keeping file-bound export/import and batch-staging orchestration out of the monolithic assistant router without changing the assistant contract - the remaining estimate search, planning lookup, self-service timeline read, and navigation assistant helpers now live in their own domain module, keeping another mixed read-only cluster out of the monolithic assistant router without changing the assistant contract +- the country listing and country detail assistant helpers now live in their own domain module, keeping the remaining geo/readmodel lookups 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 leftover block such as the remaining country read helpers or other small read-only assistant clusters still living in the monolithic router. +The next clean slice should stay adjacent to the extracted domains and target one cohesive leftover block such as the remaining workflow helpers or other small assistant clusters 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 f2ee33e..5ab1225 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -85,6 +85,10 @@ import { countryMetroAdminToolDefinitions, createCountryMetroAdminExecutors, } from "./assistant-tools/country-metro-admin.js"; +import { + countryReadmodelToolDefinitions, + createCountryReadmodelExecutors, +} from "./assistant-tools/country-readmodels.js"; import { createUserSelfServiceExecutors, userSelfServiceToolDefinitions, @@ -431,7 +435,6 @@ const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial - country.code.toLowerCase().includes(normalizedSearch) - || country.name.toLowerCase().includes(normalizedSearch)) - : countries; - - return { - count: filteredCountries.length, - countries: filteredCountries.map(formatCountry), - }; - }, - - async get_country(params: { identifier: string }, ctx: ToolContext) { - const caller = createCountryCaller(createScopedCallerContext(ctx)); - let country; - try { - country = await caller.getByIdentifier({ identifier: params.identifier }); - } catch (error) { - const mapped = toAssistantCountryNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - return formatCountry(country); - }, + ...createCountryReadmodelExecutors({ + createCountryCaller, + createScopedCallerContext, + formatCountry, + toAssistantCountryNotFoundError, + }), ...createCountryMetroAdminExecutors({ createCountryCaller, createScopedCallerContext, diff --git a/packages/api/src/router/assistant-tools/country-readmodels.ts b/packages/api/src/router/assistant-tools/country-readmodels.ts new file mode 100644 index 0000000..cec59b2 --- /dev/null +++ b/packages/api/src/router/assistant-tools/country-readmodels.ts @@ -0,0 +1,111 @@ +import type { Prisma } from "@capakraken/db"; +import type { TRPCContext } from "../../trpc.js"; +import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type CountryRecord = { + id: string; + code: string; + name: string; + dailyWorkingHours: number; + scheduleRules?: Prisma.JsonValue | null; + isActive?: boolean | null; + metroCities?: Array<{ id: string; name: string }> | null; + _count?: { resources?: number | null } | null; +}; + +type CountryReadmodelsDeps = { + createCountryCaller: (ctx: TRPCContext) => { + list: (params?: { isActive: boolean }) => Promise; + getByIdentifier: (params: { identifier: string }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + formatCountry: (country: CountryRecord) => unknown; + toAssistantCountryNotFoundError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const countryReadmodelToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "list_countries", + description: "List countries including working hours, schedule rules, active flag, and metro cities.", + parameters: { + type: "object", + properties: { + includeInactive: { type: "boolean", description: "Include inactive countries. Default: false." }, + search: { type: "string", description: "Optional country code or name search." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_country", + description: "Get one country with schedule rules, active flag, metro cities, and resource count. Accepts ID, code, or name.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Country ID, code, or name." }, + }, + required: ["identifier"], + }, + }, + }, +], { + get_country: { + requiresResourceOverview: true, + }, +}); + +export function createCountryReadmodelExecutors( + deps: CountryReadmodelsDeps, +): Record { + return { + async list_countries( + params: { includeInactive?: boolean; search?: string }, + ctx: ToolContext, + ) { + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + const countries = await caller.list( + params.includeInactive + ? undefined + : { isActive: true }, + ); + const normalizedSearch = params.search?.trim().toLowerCase(); + const filteredCountries = normalizedSearch + ? countries.filter((country) => + country.code.toLowerCase().includes(normalizedSearch) + || country.name.toLowerCase().includes(normalizedSearch)) + : countries; + + return { + count: filteredCountries.length, + countries: filteredCountries.map(deps.formatCountry), + }; + }, + + async get_country( + params: { identifier: string }, + ctx: ToolContext, + ) { + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + let country; + try { + country = await caller.getByIdentifier({ identifier: params.identifier }); + } catch (error) { + const mapped = deps.toAssistantCountryNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return deps.formatCountry(country); + }, + }; +}