From 1270054d35529bfc9e0bf19a446317cbb49a8097 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:15:27 +0200
Subject: [PATCH] feat(ai): dashboard & view building (#22411)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.
## What changed
### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
`objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
still win when both are given.
### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
surgical single-entry edits.
### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
widget object.
### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
`DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
dashboard questions are answered directly without loading skills.
### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
a stale/blank stored label.
## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
`Allowed values: …` message at creation time (shared
`getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
returning an untyped, cast-heavy object.
## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
`create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.
## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
and a table of the top 10 open opportunities" → plans, waits for
confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error
---
.../hooks/useRecordFilterField.ts | 13 +-
.../build-mcp-server-instructions.util.ts | 9 +-
...data-tool-excluded-field-names.constant.ts | 5 +
.../providers/view-tool.provider.ts | 5 +-
.../tool-provider/tool-provider.module.ts | 5 +
.../tool-provider/tools/load-skill.tool.ts | 2 +-
.../constants/chat-system-prompts.const.ts | 6 +-
.../field-metadata/field-metadata.module.ts | 1 +
.../tools/field-metadata-tools.factory.ts | 70 ++-
...d-select-filter-option-values.util.spec.ts | 103 +++++
...ld-field-by-object-id-and-name-key.util.ts | 4 +
.../utils/build-field-id-by-name-maps.util.ts | 38 ++
...nvalid-select-filter-option-values.util.ts | 72 +++
.../get-object-metadata-id-by-name.util.ts | 17 +
...lete-view-children-flat-operations.util.ts | 161 +++++++
.../object-metadata/object-metadata.module.ts | 3 +-
.../object-metadata.service.ts | 15 +-
.../tools/object-metadata-tools.factory.ts | 95 +++-
...art-configuration-field-references.util.ts | 59 ++-
.../__tests__/view-tools.factory.spec.ts | 267 ++++++++++-
.../services/complete-view-upsert.service.ts | 374 +++++++++++++++
.../view/tools/view-tools.factory.ts | 431 +++++++++++++++++-
.../metadata-modules/view/view.module.ts | 2 +
...reate-standard-flat-skill-metadata.util.ts | 104 +++--
.../flat-view-filter-validator.service.ts | 85 ++++
.../update-dashboard-widget.tool.spec.ts | 177 +++++++
.../tools/add-dashboard-widget.tool.ts | 41 +-
.../tools/create-complete-dashboard.tool.ts | 36 +-
.../dashboard/tools/schemas/widget.schema.ts | 182 +++++++-
.../tools/types/chart-filter-input.type.ts | 5 +
.../types/dashboard-identifier-maps.type.ts | 7 +
.../types/widget-configuration-input.type.ts | 7 +
.../types/widget-identifiers-input.type.ts | 14 +
.../types/widget-with-metadata-ids.type.ts | 13 +
.../tools/update-dashboard-widget.tool.ts | 73 ++-
...t-field-names-to-metadata-ids.util.spec.ts | 215 +++++++++
.../compute-dashboard-identifier-maps.util.ts | 34 ++
...widget-field-names-to-metadata-ids.util.ts | 364 +++++++++++++++
...rs-side-effect-v2.integration-spec.ts.snap | 26 --
...filters-side-effect-v2.integration-spec.ts | 35 +-
40 files changed, 3013 insertions(+), 162 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/tools/services/complete-view-upsert.service.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/__tests__/update-dashboard-widget.tool.spec.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/chart-filter-input.type.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/dashboard-identifier-maps.type.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/widget-configuration-input.type.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/widget-identifiers-input.type.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/types/widget-with-metadata-ids.type.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/resolve-widget-field-names-to-metadata-ids.util.spec.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util.ts
diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts
index be77823ed9..660173291c 100644
--- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts
+++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts
@@ -21,6 +21,9 @@ export const useRecordFilterField = (recordFilterId: string) => {
recordFilter?.fieldMetadataId ?? '',
);
+ const { fieldMetadataItem: relationTargetFieldMetadataItem } =
+ useFieldMetadataItemById(recordFilter?.relationTargetFieldMetadataId ?? '');
+
const { getIcon } = useIcons();
const icon = isDefined(fieldMetadataItem?.icon)
@@ -38,9 +41,15 @@ export const useRecordFilterField = (recordFilterId: string) => {
)
: '';
+ const fieldLabel = fieldMetadataItem?.label ?? '';
+
+ const baseLabel = isDefined(relationTargetFieldMetadataItem)
+ ? `${fieldLabel} → ${relationTargetFieldMetadataItem.label}`
+ : fieldLabel;
+
const label = isNonEmptyString(subFieldLabel)
- ? `${recordFilter?.label} / ${subFieldLabel}`
- : (recordFilter?.label ?? '');
+ ? `${baseLabel} / ${subFieldLabel}`
+ : baseLabel;
return {
label,
diff --git a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts
index ddf6dee1b3..2acbea5e93 100644
--- a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts
+++ b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts
@@ -38,10 +38,11 @@ export const buildMcpServerInstructions = (
` For complex tasks (workflows, metadata), load the matching skill BEFORE calling tools.`,
` ⚠️ Never call workflow or metadata tools without loading their skill first.`,
``,
- `Dashboards (coming soon):`,
- ` Building or editing dashboards through the AI is not available yet — it is a coming soon feature.`,
- ` If asked to create/build/modify a dashboard, do not attempt it: say AI-assisted dashboards are coming soon,`,
- ` and offer alternatives (create views, run analytics with group_by_{objects}, or build workflows).`,
+ `Dashboards:`,
+ ` To create/build/modify a dashboard, load the dashboard-building skill first, then resolve metadata and build.`,
+ ` Once metadata is resolved, emit create_complete_dashboard in the same turn — never stop on a "now let me…" preamble.`,
+ ` If a referenced field is missing (e.g. lead source, won/lost stage), pick a sensible default and state the assumption instead of stalling.`,
+ ` Informational dashboard questions ("what is a dashboard?") are NOT build requests — answer directly, do not load skill.`,
``,
`Route by intent:`,
` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`,
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts
new file mode 100644
index 0000000000..106096d05c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts
@@ -0,0 +1,5 @@
+export const METADATA_TOOL_EXCLUDED_FIELD_NAMES = new Set([
+ 'searchVector',
+ 'position',
+ 'updatedBy',
+]);
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts
index 3cc8443165..5657bcc663 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts
@@ -58,11 +58,12 @@ export class ViewToolProvider implements ToolProvider {
private async buildToolSet(context: ToolProviderContext): Promise {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
+ const userWorkspaceId = context.userWorkspaceId;
const readTools = {
...this.viewToolsFactory.generateReadTools(
context.workspaceId,
- workspaceMemberId ?? undefined,
+ userWorkspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFieldToolsFactory.generateReadTools(context.workspaceId),
@@ -84,7 +85,7 @@ export class ViewToolProvider implements ToolProvider {
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
- workspaceMemberId ?? undefined,
+ userWorkspaceId,
),
...this.viewFieldToolsFactory.generateWriteTools(context.workspaceId),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts
index 94978b2c83..633778ce59 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
+import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
@@ -68,6 +69,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ToolIndexResolver,
ToolExecutorService,
ActionToolProvider,
+ DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NavigationMenuItemToolProvider,
@@ -90,6 +92,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
viewProvider: ViewToolProvider,
webhookProvider: WebhookToolProvider,
workflowProvider: WorkflowToolProvider,
+ dashboardProvider: DashboardToolProvider,
) => [
actionProvider,
databaseProvider,
@@ -99,6 +102,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
viewProvider,
webhookProvider,
workflowProvider,
+ dashboardProvider,
],
inject: [
ActionToolProvider,
@@ -109,6 +113,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewToolProvider,
WebhookToolProvider,
WorkflowToolProvider,
+ DashboardToolProvider,
],
},
ToolRegistryService,
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts
index 6a7f8f2654..b3340d996c 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts
@@ -58,7 +58,7 @@ export const createLoadSkillTool = (
label: skill.label,
content: skill.content,
})),
- message: `Loaded ${skills.map((skill) => skill.label).join(', ')}`,
+ message: `Loaded ${skills.map((skill) => skill.label).join(', ')}.`,
};
},
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
index 6040d86a01..97cf026104 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
@@ -20,9 +20,11 @@ Examples:
For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it.
-## Dashboards (coming soon)
+## Dashboards
-Building or editing dashboards through the AI is not available yet — it is a coming soon feature. If the user asks you to create, build, or modify a dashboard, do NOT attempt it: let them know that AI-assisted dashboards are coming soon, and offer the alternatives you can help with today (e.g. creating views, running analytics with \`group_by_*\`, or building workflows).
+When the user asks to create, build, or modify a dashboard, load the \`dashboard-building\` skill and follow the Plan → Skill → Learn → Execute flow.
+
+Intent gate: purely informational dashboard questions (e.g. "what is a dashboard in Twenty?", "how do I export a dashboard?", "can I share a dashboard with a client?") are NOT build requests. Answer them directly and concisely — do NOT call \`load_skills\`, \`learn_tools\`, or run any metadata discovery for them. Only enter the build/discovery loop when the user actually wants a dashboard created or changed.
## Skills vs Tools
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
index c7923cbf41..ab236fb6a2 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
@@ -47,6 +47,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
+ WorkspaceManyOrAllFlatEntityMapsCacheModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts
index 9bb470ff5b..2dc4643e43 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts
@@ -4,12 +4,14 @@ import { type ToolSet } from 'ai';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { z } from 'zod';
+import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
+import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
+import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
-
-const EXCLUDED_FIELD_NAMES = new Set(['searchVector', 'position', 'updatedBy']);
+import { isDefined } from 'twenty-shared/utils';
const FIELD_STRIP_WHEN_NULLISH = [
'options',
@@ -26,16 +28,14 @@ const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName'];
const FIELD_STRIP_WHEN_TRUE = ['isUIEditable'];
const GetFieldMetadataInputSchema = z.object({
- id: z
+ id: z.uuid().optional().describe('Field ID. Returns one field if set.'),
+ objectMetadataId: z.uuid().optional().describe('Filter by object ID.'),
+ objectName: z
.string()
- .uuid()
.optional()
- .describe('Field ID. Returns one field if set.'),
- objectMetadataId: z
- .string()
- .uuid()
- .optional()
- .describe('Filter by object ID.'),
+ .describe(
+ 'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Convenient alternative to objectMetadataId so you do not need to resolve the object id first.',
+ ),
includeFullSystemFields: z
.boolean()
.default(false)
@@ -134,7 +134,36 @@ const CreateManyRelationFieldsInputSchema = z.object({
@Injectable()
export class FieldMetadataToolsFactory {
- constructor(private readonly fieldMetadataService: FieldMetadataService) {}
+ constructor(
+ private readonly fieldMetadataService: FieldMetadataService,
+ private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
+ ) {}
+
+ private async getObjectMetadataIdOrThrow(
+ workspaceId: string,
+ objectName: string,
+ ): Promise {
+ const { flatObjectMetadataMaps } =
+ await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
+ {
+ workspaceId,
+ flatMapsKeys: ['flatObjectMetadataMaps'],
+ },
+ );
+
+ const objectMetadataId = getObjectMetadataIdByName({
+ flatObjectMetadataMaps,
+ objectName,
+ });
+
+ if (!isDefined(objectMetadataId)) {
+ throw new Error(
+ `Object "${objectName}" not found. Use get_object_metadata to list available objects.`,
+ );
+ }
+
+ return objectMetadataId;
+ }
generateTools(workspaceId: string): ToolSet {
return {
@@ -145,16 +174,26 @@ export class FieldMetadataToolsFactory {
execute: async (parameters: {
id?: string;
objectMetadataId?: string;
+ objectName?: string;
includeFullSystemFields?: boolean;
limit?: number;
}) => {
+ const objectMetadataId =
+ parameters.objectMetadataId ??
+ (parameters.objectName
+ ? await this.getObjectMetadataIdOrThrow(
+ workspaceId,
+ parameters.objectName,
+ )
+ : undefined);
+
const rawResults = await this.fieldMetadataService.query({
filter: {
workspaceId: { eq: workspaceId },
...(parameters.id ? { id: { eq: parameters.id } } : {}),
- ...(parameters.objectMetadataId
+ ...(isDefined(objectMetadataId)
? {
- objectMetadataId: { eq: parameters.objectMetadataId },
+ objectMetadataId: { eq: objectMetadataId },
}
: {}),
},
@@ -164,7 +203,10 @@ export class FieldMetadataToolsFactory {
const compactedFields = (
rawResults as unknown as Record[]
)
- .filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string))
+ .filter(
+ (field) =>
+ !METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(field.name as string),
+ )
.map((field) => {
if (field.isSystem && !parameters.includeFullSystemFields) {
return {
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts
new file mode 100644
index 0000000000..b132e61c7e
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts
@@ -0,0 +1,103 @@
+import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
+
+import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
+
+const OBJECT_ID = '11111111-1111-4111-8111-111111111111';
+
+const selectField = getFlatFieldMetadataMock({
+ id: '33333333-3333-4333-8333-333333333333',
+ universalIdentifier: '33333333-3333-4333-8333-333333333333',
+ objectMetadataId: OBJECT_ID,
+ type: FieldMetadataType.SELECT,
+ name: 'stage',
+ label: 'Stage',
+ options: [
+ { id: 'opt-won', color: 'green', label: 'Won', value: 'WON', position: 0 },
+ { id: 'opt-lost', color: 'red', label: 'Lost', value: 'LOST', position: 1 },
+ ],
+}) as FlatFieldMetadata;
+
+describe('getInvalidSelectFilterOptionValues', () => {
+ it('returns invalid values for a SELECT field when an option does not exist', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: ['WON', 'NOT_A_REAL_OPTION'],
+ }),
+ ).toEqual(['NOT_A_REAL_OPTION']);
+ });
+
+ it('returns an empty array when all SELECT values are valid options', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: ['WON', 'LOST'],
+ }),
+ ).toEqual([]);
+ });
+
+ it('supports a plain string array value', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: ['NOPE'],
+ }),
+ ).toEqual(['NOPE']);
+ });
+
+ it('parses a JSON-stringified array value (chart filter format)', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: '["WON"]',
+ }),
+ ).toEqual([]);
+ });
+
+ it('parses a JSON-stringified multi-value array and reports invalid options', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: '["WON","NOPE"]',
+ }),
+ ).toEqual(['NOPE']);
+ });
+
+ it('treats a non-JSON plain string as a single option value', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ value: 'WON',
+ }),
+ ).toEqual([]);
+ });
+
+ it('ignores value-less operands like IS_EMPTY', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS_EMPTY,
+ value: '',
+ }),
+ ).toEqual([]);
+ });
+
+ it('ignores subfield filters', () => {
+ expect(
+ getInvalidSelectFilterOptionValues({
+ fieldMetadata: selectField,
+ operand: ViewFilterOperand.IS,
+ subFieldName: 'someSubField',
+ value: JSON.stringify(['NOPE']),
+ }),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts
new file mode 100644
index 0000000000..66797596b3
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts
@@ -0,0 +1,4 @@
+export const buildFieldByObjectIdAndNameKey = (
+ objectMetadataId: string,
+ fieldName: string,
+): string => `${objectMetadataId}:${fieldName}`;
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts
new file mode 100644
index 0000000000..ece07e724c
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts
@@ -0,0 +1,38 @@
+import { type FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util';
+
+export const buildFieldIdByNameMaps = (
+ flatFieldMetadataMaps: FlatEntityMaps,
+): {
+ fieldIdByObjectIdAndName: Map;
+ fieldById: Map;
+} => {
+ const fieldIdByObjectIdAndName = new Map();
+ const fieldById = new Map();
+
+ for (const fieldMetadata of Object.values(
+ flatFieldMetadataMaps.byUniversalIdentifier,
+ )) {
+ if (!isDefined(fieldMetadata)) {
+ continue;
+ }
+
+ fieldIdByObjectIdAndName.set(
+ buildFieldByObjectIdAndNameKey(
+ fieldMetadata.objectMetadataId,
+ fieldMetadata.name,
+ ),
+ fieldMetadata.id,
+ );
+
+ fieldById.set(fieldMetadata.id, {
+ type: fieldMetadata.type,
+ });
+ }
+
+ return { fieldIdByObjectIdAndName, fieldById };
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts
new file mode 100644
index 0000000000..17584705ab
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts
@@ -0,0 +1,72 @@
+import { isArray, isNonEmptyString } from '@sniptt/guards';
+import { FieldMetadataType, type ViewFilterOperand } from 'twenty-shared/types';
+import {
+ isDefined,
+ isRecordFilterOperandExpectingValue,
+} from 'twenty-shared/utils';
+
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
+
+const normalizeSelectFilterValues = (
+ value: ViewFilterValue | null,
+): string[] => {
+ if (isArray(value)) {
+ return value.filter(isNonEmptyString);
+ }
+
+ if (!isNonEmptyString(value)) {
+ return [];
+ }
+
+ try {
+ const parsedValue: unknown = JSON.parse(value);
+
+ if (isArray(parsedValue)) {
+ return parsedValue.filter(isNonEmptyString);
+ }
+ } catch {
+ // Not a JSON-stringified array — treat the raw string as a single value.
+ }
+
+ return [value];
+};
+
+export const getInvalidSelectFilterOptionValues = ({
+ fieldMetadata,
+ operand,
+ subFieldName,
+ value,
+}: {
+ fieldMetadata: Pick<
+ FlatFieldMetadata<
+ FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
+ >,
+ 'type' | 'options'
+ >;
+ operand: ViewFilterOperand;
+ subFieldName?: string | null;
+ value: ViewFilterValue | null;
+}): string[] => {
+ if (!isRecordFilterOperandExpectingValue(operand)) {
+ return [];
+ }
+
+ if (isNonEmptyString(subFieldName)) {
+ return [];
+ }
+
+ const filterValues = normalizeSelectFilterValues(value);
+
+ if (filterValues.length === 0) {
+ return [];
+ }
+
+ if (!isDefined(fieldMetadata.options)) {
+ return filterValues;
+ }
+
+ return filterValues.filter((filterValue) =>
+ fieldMetadata.options.every((option) => option.value !== filterValue),
+ );
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts
new file mode 100644
index 0000000000..4bd7213e58
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts
@@ -0,0 +1,17 @@
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
+import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
+
+export const getObjectMetadataIdByName = ({
+ flatObjectMetadataMaps,
+ objectName,
+}: {
+ flatObjectMetadataMaps: FlatEntityMaps;
+ objectName: string;
+}): string | undefined => {
+ const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps(
+ flatObjectMetadataMaps,
+ );
+
+ return idByNameSingular[objectName] ?? idByNamePlural[objectName];
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts
new file mode 100644
index 0000000000..d92242d249
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts
@@ -0,0 +1,161 @@
+import {
+ type ViewFilterOperand,
+ type ViewSortDirection,
+} from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
+import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
+import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
+import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
+import { fromCreateViewFilterInputToFlatViewFilterToCreate } from 'src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util';
+import { fromCreateViewSortInputToFlatViewSortToCreate } from 'src/engine/metadata-modules/flat-view-sort/utils/from-create-view-sort-input-to-flat-view-sort-to-create.util';
+import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
+
+// Resolved children specs (field names already resolved to fieldMetadataId)
+export type CompleteViewFieldSpec = {
+ fieldMetadataId: string;
+ isVisible: boolean;
+ size: number;
+};
+
+export type CompleteViewFilterSpec = {
+ fieldMetadataId: string;
+ operand: ViewFilterOperand;
+ value: ViewFilterValue;
+ subFieldName?: string;
+};
+
+export type CompleteViewSortSpec = {
+ fieldMetadataId: string;
+ direction: ViewSortDirection;
+};
+
+type BuildCompleteViewChildrenFlatOperationsArgs = {
+ viewId: string;
+ flatApplication: FlatApplication;
+ fields?: CompleteViewFieldSpec[];
+ filters?: CompleteViewFilterSpec[];
+ sorts?: CompleteViewSortSpec[];
+} & Pick<
+ AllFlatEntityMaps,
+ | 'flatFieldMetadataMaps'
+ | 'flatViewMaps'
+ | 'flatViewFieldMaps'
+ | 'flatViewFilterMaps'
+ | 'flatViewSortMaps'
+ | 'flatViewFieldGroupMaps'
+ | 'flatViewFilterGroupMaps'
+>;
+
+type CompleteViewChildrenFlatOperations = {
+ viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>;
+ viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>;
+ viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>;
+};
+
+export const buildCompleteViewChildrenFlatOperations = ({
+ viewId,
+ flatApplication,
+ flatFieldMetadataMaps,
+ flatViewMaps,
+ flatViewFieldMaps,
+ flatViewFilterMaps,
+ flatViewSortMaps,
+ flatViewFieldGroupMaps,
+ flatViewFilterGroupMaps,
+ fields,
+ filters,
+ sorts,
+}: BuildCompleteViewChildrenFlatOperationsArgs): CompleteViewChildrenFlatOperations => {
+ const operations: CompleteViewChildrenFlatOperations = {};
+
+ if (isDefined(fields)) {
+ const existingFlatViewFields = Object.values(
+ flatViewFieldMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter((flatViewField) => flatViewField.viewId === viewId);
+
+ const flatViewFieldsToCreate = fields.map((field, index) =>
+ fromCreateViewFieldInputToFlatViewFieldToCreate({
+ createViewFieldInput: {
+ viewId,
+ fieldMetadataId: field.fieldMetadataId,
+ isVisible: field.isVisible,
+ size: field.size,
+ position: index,
+ },
+ flatApplication,
+ flatFieldMetadataMaps,
+ flatViewMaps,
+ flatViewFieldGroupMaps,
+ }),
+ );
+
+ operations.viewField = {
+ flatEntityToCreate: flatViewFieldsToCreate,
+ flatEntityToDelete: existingFlatViewFields,
+ flatEntityToUpdate: [],
+ };
+ }
+
+ if (isDefined(filters)) {
+ const existingFlatViewFilters = Object.values(
+ flatViewFilterMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter((flatViewFilter) => flatViewFilter.viewId === viewId);
+
+ const flatViewFiltersToCreate = filters.map((filter) =>
+ fromCreateViewFilterInputToFlatViewFilterToCreate({
+ createViewFilterInput: {
+ viewId,
+ fieldMetadataId: filter.fieldMetadataId,
+ operand: filter.operand,
+ value: filter.value,
+ subFieldName: filter.subFieldName,
+ },
+ flatApplication,
+ flatFieldMetadataMaps,
+ flatViewMaps,
+ flatViewFilterGroupMaps,
+ }),
+ );
+
+ operations.viewFilter = {
+ flatEntityToCreate: flatViewFiltersToCreate,
+ flatEntityToDelete: existingFlatViewFilters,
+ flatEntityToUpdate: [],
+ };
+ }
+
+ if (isDefined(sorts)) {
+ const existingFlatViewSorts = Object.values(
+ flatViewSortMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter((flatViewSort) => flatViewSort.viewId === viewId);
+
+ const flatViewSortsToCreate = sorts.map((sort) =>
+ fromCreateViewSortInputToFlatViewSortToCreate({
+ createViewSortInput: {
+ viewId,
+ fieldMetadataId: sort.fieldMetadataId,
+ direction: sort.direction,
+ },
+ flatApplication,
+ flatFieldMetadataMaps,
+ flatViewMaps,
+ }),
+ );
+
+ operations.viewSort = {
+ flatEntityToCreate: flatViewSortsToCreate,
+ flatEntityToDelete: existingFlatViewSorts,
+ flatEntityToUpdate: [],
+ };
+ }
+
+ return operations;
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts
index c525adb460..63a538ac87 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts
@@ -9,8 +9,8 @@ import {
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
-import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
+import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -48,6 +48,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FeatureFlagModule,
ApplicationModule,
ApplicationTranslationModule,
+ WorkspaceManyOrAllFlatEntityMapsCacheModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
TypeORMModule,
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
index 09536993d4..47ca8267ba 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
@@ -989,12 +989,19 @@ export class ObjectMetadataService extends TypeOrmQueryService,
): Promise {
+ const whereWithWorkspaceId = Array.isArray(options?.where)
+ ? options.where.map((whereCondition) => ({
+ ...whereCondition,
+ workspaceId,
+ }))
+ : {
+ ...options?.where,
+ workspaceId,
+ };
+
const objectMetadataEntities = await this.objectMetadataRepository.find({
...options,
- where: {
- ...options?.where,
- workspaceId,
- },
+ where: whereWithWorkspaceId,
order: {
...options?.order,
},
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts
index 1679a807b9..820d0caf83 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts
@@ -1,14 +1,25 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
+import { type FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
+import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
+import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
+type InlinedObjectFieldSummary = {
+ id: string;
+ name: string;
+ type: FieldMetadataType;
+ label: string;
+};
+
const OBJECT_STRIP_WHEN_NULLISH = [
'overrides',
'color',
@@ -20,11 +31,19 @@ const OBJECT_STRIP_WHEN_NULLISH = [
];
const GetObjectMetadataInputSchema = z.object({
- id: z
+ id: z.uuid().optional().describe('Object ID. Returns one object if set.'),
+ objectName: z
.string()
- .uuid()
.optional()
- .describe('Object ID. Returns one object if set.'),
+ .describe(
+ 'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Lets you locate an object by name in one call without scanning the full list.',
+ ),
+ includeFields: z
+ .boolean()
+ .default(false)
+ .describe(
+ 'When true, each returned object includes its fields as a compact array of {id, name, type, label}. Use this to fetch an object and all the field ids you need in a single call (e.g. before building a dashboard) instead of a separate get_field_metadata call.',
+ ),
includeFullSystemObjects: z
.boolean()
.default(false)
@@ -103,16 +122,59 @@ const UpdateManyObjectMetadataInputSchema = z.object({
@Injectable()
export class ObjectMetadataToolsFactory {
- constructor(private readonly objectMetadataService: ObjectMetadataService) {}
+ constructor(
+ private readonly objectMetadataService: ObjectMetadataService,
+ private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
+ ) {}
+
+ private async buildFieldsByObjectId(
+ workspaceId: string,
+ ): Promise