diff --git a/backend/migrations/tenant/20250131000001_add_layout_type_to_page_layouts.js b/backend/migrations/tenant/20250131000001_add_layout_type_to_page_layouts.js new file mode 100644 index 0000000..4ae3db3 --- /dev/null +++ b/backend/migrations/tenant/20250131000001_add_layout_type_to_page_layouts.js @@ -0,0 +1,95 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + // Check if layout_type column already exists (in case of partial migration) + const hasLayoutType = await knex.schema.hasColumn('page_layouts', 'layout_type'); + + // Check if the old index exists + const [indexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_is_default_index'`); + const hasOldIndex = indexes.length > 0; + + // Check if foreign key exists + const [fks] = await knex.raw(` + SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'page_layouts' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign' + `); + const hasForeignKey = fks.length > 0; + + if (hasOldIndex) { + // First, drop the foreign key constraint that depends on the index (if it exists) + if (hasForeignKey) { + await knex.schema.alterTable('page_layouts', (table) => { + table.dropForeign(['object_id']); + }); + } + + // Now we can safely drop the old index + await knex.schema.alterTable('page_layouts', (table) => { + table.dropIndex(['object_id', 'is_default']); + }); + } + + // Add layout_type column if it doesn't exist + if (!hasLayoutType) { + await knex.schema.alterTable('page_layouts', (table) => { + // Add layout_type column to distinguish between detail/edit layouts and list view layouts + // Default to 'detail' for existing layouts + table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name'); + }); + } + + // Check if new index exists + const [newIndexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_layout_type_is_default_index'`); + const hasNewIndex = newIndexes.length > 0; + + if (!hasNewIndex) { + // Create new index including layout_type + await knex.schema.alterTable('page_layouts', (table) => { + table.index(['object_id', 'layout_type', 'is_default']); + }); + } + + // Re-check if foreign key exists (may have been dropped above or in previous attempt) + const [fksAfter] = await knex.raw(` + SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'page_layouts' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign' + `); + + if (fksAfter.length === 0) { + // Re-add the foreign key constraint + await knex.schema.alterTable('page_layouts', (table) => { + table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE'); + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + // Drop the foreign key first + await knex.schema.alterTable('page_layouts', (table) => { + table.dropForeign(['object_id']); + }); + + // Drop the new index and column, restore old index + await knex.schema.alterTable('page_layouts', (table) => { + table.dropIndex(['object_id', 'layout_type', 'is_default']); + table.dropColumn('layout_type'); + table.index(['object_id', 'is_default']); + }); + + // Re-add the foreign key constraint + await knex.schema.alterTable('page_layouts', (table) => { + table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE'); + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index a3299e1..ce98490 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@casl/ability": "^6.7.5", "@fastify/websocket": "^10.0.1", - "@langchain/core": "^1.1.12", + "@langchain/core": "^1.1.15", "@langchain/langgraph": "^1.0.15", "@langchain/openai": "^1.2.1", "@nestjs/bullmq": "^10.1.0", @@ -29,9 +29,10 @@ "bullmq": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "deepagents": "^1.5.0", "ioredis": "^5.3.2", "knex": "^3.1.0", - "langchain": "^1.2.7", + "langchain": "^1.2.10", "mysql2": "^3.15.3", "objection": "^3.1.5", "openai": "^6.15.0", @@ -228,6 +229,26 @@ "tslib": "^2.1.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -689,6 +710,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1689,10 +1719,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/anthropic": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz", + "integrity": "sha512-VXq5fsEJ4FB5XGrnoG+bfm0I7OlmYLI4jZ6cX9RasyqhGo9wcDyKw1+uEQ1H7Og7jWrTa1bfXCun76wttewJnw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "1.1.15" + } + }, "node_modules/@langchain/core": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz", - "integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz", + "integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -2487,7 +2533,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2501,7 +2546,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2511,7 +2555,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4029,7 +4072,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4754,6 +4796,22 @@ "dev": true, "license": "MIT" }, + "node_modules/deepagents": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/deepagents/-/deepagents-1.5.0.tgz", + "integrity": "sha512-tjZLOISPMpqfk+k/iE1uIZavXW9j4NrhopUmH5ARqzmk95EEtGDyN++tgnY+tdVOOZTjE2LHjOVV7or58dtx8A==", + "license": "MIT", + "dependencies": { + "@langchain/anthropic": "^1.3.7", + "@langchain/core": "^1.1.12", + "@langchain/langgraph": "^1.0.14", + "fast-glob": "^3.3.3", + "langchain": "^1.2.7", + "micromatch": "^4.0.8", + "yaml": "^2.8.2", + "zod": "^4.3.5" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5514,7 +5572,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -5740,7 +5797,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6145,7 +6201,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6594,7 +6649,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6623,7 +6677,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6658,7 +6711,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7609,6 +7661,19 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -7811,9 +7876,9 @@ } }, "node_modules/langchain": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz", - "integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz", + "integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==", "license": "MIT", "dependencies": { "@langchain/langgraph": "^1.0.0", @@ -7826,7 +7891,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "1.1.12" + "@langchain/core": "1.1.15" } }, "node_modules/langchain/node_modules/uuid": { @@ -8201,7 +8266,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8211,7 +8275,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8225,7 +8288,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9388,7 +9450,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -9727,7 +9788,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -10657,7 +10717,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -10709,6 +10768,12 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -11455,6 +11520,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -11508,9 +11588,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/backend/package.json b/backend/package.json index 83e842a..1a28f97 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "dependencies": { "@casl/ability": "^6.7.5", "@fastify/websocket": "^10.0.1", - "@langchain/core": "^1.1.12", + "@langchain/core": "^1.1.15", "@langchain/langgraph": "^1.0.15", "@langchain/openai": "^1.2.1", "@nestjs/bullmq": "^10.1.0", @@ -46,9 +46,10 @@ "bullmq": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "deepagents": "^1.5.0", "ioredis": "^5.3.2", "knex": "^3.1.0", - "langchain": "^1.2.7", + "langchain": "^1.2.10", "mysql2": "^3.15.3", "objection": "^3.1.5", "openai": "^6.15.0", diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index 80cbfb0..d7c0624 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -1,15 +1,26 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { JsonOutputParser } from '@langchain/core/output_parsers'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; import { ChatOpenAI } from '@langchain/openai'; import { Annotation, END, START, StateGraph } from '@langchain/langgraph'; +import { createDeepAgent } from 'deepagents'; import { ObjectService } from '../object/object.service'; import { PageLayoutService } from '../page-layout/page-layout.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { getCentralPrisma } from '../prisma/central-prisma.service'; import { OpenAIConfig } from '../voice/interfaces/integration-config.interface'; -import { AiAssistantReply, AiAssistantState } from './ai-assistant.types'; +import { + AiAssistantReply, + AiAssistantState, + EntityInfo, + EntityFieldInfo, + EntityRelationship, + SystemEntities, + RecordCreationPlan, + PlannedRecord, +} from './ai-assistant.types'; import { MeilisearchService } from '../search/meilisearch.service'; +import { randomUUID } from 'crypto'; type AiSearchFilter = { field: string; @@ -44,6 +55,13 @@ export class AiAssistantService { { fields: Record; updatedAt: number } >(); private readonly conversationTtlMs = 30 * 60 * 1000; // 30 minutes + + // Entity discovery cache per tenant (refreshes every 5 minutes) + private readonly entityCache = new Map(); + private readonly entityCacheTtlMs = 5 * 60 * 1000; // 5 minutes + + // Plan cache per conversation + private readonly planCache = new Map(); constructor( private readonly objectService: ObjectService, @@ -52,6 +70,266 @@ export class AiAssistantService { private readonly meilisearchService: MeilisearchService, ) {} + // ============================================ + // Entity Discovery Methods + // ============================================ + + /** + * Discovers all available entities in the system for a tenant. + * Results are cached for performance. + */ + async discoverEntities(tenantId: string): Promise { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + + // Check cache first + const cached = this.entityCache.get(resolvedTenantId); + if (cached && Date.now() - cached.loadedAt < this.entityCacheTtlMs) { + console.log('=== Using cached entity discovery ==='); + return cached; + } + + console.log('=== Discovering system entities ==='); + + const objectDefinitions = await this.objectService.getObjectDefinitions(resolvedTenantId); + const entities: EntityInfo[] = []; + const entityByApiName: Record = {}; // Use plain object instead of Map + + for (const objDef of objectDefinitions) { + try { + // Get full object definition with fields + const fullDef = await this.objectService.getObjectDefinition(resolvedTenantId, objDef.apiName); + + const fields: EntityFieldInfo[] = (fullDef.fields || []).map((f: any) => ({ + apiName: f.apiName, + label: f.label || f.apiName, + type: f.type, + isRequired: f.isRequired || false, + isSystem: this.isSystemField(f.apiName), + referenceObject: f.referenceObject || undefined, + description: f.description, + })); + + const relationships: EntityRelationship[] = fields + .filter(f => f.referenceObject && !f.isSystem) + .map(f => ({ + fieldApiName: f.apiName, + fieldLabel: f.label, + targetEntity: f.referenceObject!, + relationshipType: f.type === 'LOOKUP' ? 'lookup' as const : 'master-detail' as const, + })); + + const requiredFields = fields + .filter(f => f.isRequired && !f.isSystem) + .map(f => f.apiName); + + const entityInfo: EntityInfo = { + apiName: fullDef.apiName, + label: fullDef.label || fullDef.apiName, + pluralLabel: fullDef.pluralLabel, + description: fullDef.description, + fields, + requiredFields, + relationships, + }; + + entities.push(entityInfo); + entityByApiName[fullDef.apiName.toLowerCase()] = entityInfo; + // Also map by label for easier lookup + entityByApiName[(fullDef.label || fullDef.apiName).toLowerCase()] = entityInfo; + } catch (error) { + this.logger.warn(`Failed to load entity ${objDef.apiName}: ${error.message}`); + } + } + + const systemEntities: SystemEntities = { + entities, + entityByApiName, + loadedAt: Date.now(), + }; + + this.entityCache.set(resolvedTenantId, systemEntities); + console.log(`Discovered ${entities.length} entities`); + + return systemEntities; + } + + /** + * Finds an entity by name (apiName or label, case-insensitive) + */ + findEntityByName(systemEntities: SystemEntities, name: string): EntityInfo | undefined { + if (!systemEntities?.entityByApiName) { + console.warn('findEntityByName: systemEntities or entityByApiName is undefined'); + return undefined; + } + const result = systemEntities.entityByApiName[name.toLowerCase()]; + if (!result) { + console.warn(`findEntityByName: Entity "${name}" not found. Available: ${Object.keys(systemEntities.entityByApiName).join(', ')}`); + } + return result; + } + + /** + * Generates a summary of available entities for the AI prompt + */ + generateEntitySummaryForPrompt(systemEntities: SystemEntities): string { + const lines: string[] = ['Available Entities in the System:']; + + for (const entity of systemEntities.entities) { + const requiredStr = entity.requiredFields.length > 0 + ? `Required fields: ${entity.requiredFields.join(', ')}` + : 'No required fields'; + + const relStr = entity.relationships.length > 0 + ? `Relationships: ${entity.relationships.map(r => `${r.fieldLabel} → ${r.targetEntity}`).join(', ')}` + : ''; + + lines.push(`- ${entity.label} (${entity.apiName}): ${requiredStr}${relStr ? '. ' + relStr : ''}`); + } + + return lines.join('\n'); + } + + // ============================================ + // Planning Methods + // ============================================ + + /** + * Creates a new record creation plan + */ + createPlan(): RecordCreationPlan { + return { + id: randomUUID(), + records: [], + executionOrder: [], + status: 'building', + createdRecords: [], + errors: [], + }; + } + + /** + * Adds a record to the plan + */ + addRecordToPlan( + plan: RecordCreationPlan, + entityInfo: EntityInfo, + fields: Record, + dependsOn: string[] = [], + ): PlannedRecord { + const tempId = `temp_${entityInfo.apiName.toLowerCase()}_${plan.records.length + 1}`; + + // Determine which required fields are missing + const missingRequiredFields = entityInfo.requiredFields.filter( + fieldApiName => !fields[fieldApiName] && fields[fieldApiName] !== 0 && fields[fieldApiName] !== false + ); + + const plannedRecord: PlannedRecord = { + id: tempId, + entityApiName: entityInfo.apiName, + entityLabel: entityInfo.label, + fields, + missingRequiredFields, + dependsOn, + status: missingRequiredFields.length === 0 ? 'ready' : 'pending', + }; + + plan.records.push(plannedRecord); + return plannedRecord; + } + + /** + * Updates a planned record's fields + */ + updatePlannedRecordFields( + plan: RecordCreationPlan, + recordId: string, + newFields: Record, + systemEntities: SystemEntities, + ): PlannedRecord | undefined { + const record = plan.records.find(r => r.id === recordId); + if (!record) return undefined; + + const entityInfo = this.findEntityByName(systemEntities, record.entityApiName); + if (!entityInfo) return undefined; + + record.fields = { ...record.fields, ...newFields }; + + // Recalculate missing fields + record.missingRequiredFields = entityInfo.requiredFields.filter( + fieldApiName => !record.fields[fieldApiName] && record.fields[fieldApiName] !== 0 && record.fields[fieldApiName] !== false + ); + + record.status = record.missingRequiredFields.length === 0 ? 'ready' : 'pending'; + + return record; + } + + /** + * Calculates the execution order based on dependencies + */ + calculateExecutionOrder(plan: RecordCreationPlan): string[] { + const order: string[] = []; + const processed = new Set(); + + const process = (recordId: string) => { + if (processed.has(recordId)) return; + + const record = plan.records.find(r => r.id === recordId); + if (!record) return; + + // Process dependencies first + for (const depId of record.dependsOn) { + process(depId); + } + + processed.add(recordId); + order.push(recordId); + }; + + for (const record of plan.records) { + process(record.id); + } + + plan.executionOrder = order; + return order; + } + + /** + * Checks if the plan is complete (all records have required data) + */ + isPlanComplete(plan: RecordCreationPlan): boolean { + return plan.records.every(r => r.status === 'ready' || r.status === 'created'); + } + + /** + * Gets all missing fields across the plan + */ + getAllMissingFields(plan: RecordCreationPlan): Array<{ record: PlannedRecord; missingFields: string[] }> { + return plan.records + .filter(r => r.missingRequiredFields.length > 0) + .map(r => ({ record: r, missingFields: r.missingRequiredFields })); + } + + /** + * Generates a human-readable summary of missing fields + */ + generateMissingFieldsSummary(plan: RecordCreationPlan, systemEntities: SystemEntities): string { + const missing = this.getAllMissingFields(plan); + if (missing.length === 0) return ''; + + const parts: string[] = []; + for (const { record, missingFields } of missing) { + const entityInfo = this.findEntityByName(systemEntities, record.entityApiName); + const fieldLabels = missingFields.map(apiName => { + const field = entityInfo?.fields.find(f => f.apiName === apiName); + return field?.label || apiName; + }); + parts.push(`${record.entityLabel}: ${fieldLabels.join(', ')}`); + } + + return `I need more information to complete the plan:\n${parts.join('\n')}`; + } + async handleChat( tenantId: string, userId: string, @@ -68,32 +346,281 @@ export class AiAssistantService { const prior = this.conversationState.get(conversationKey); const trimmedHistory = Array.isArray(history) ? history.slice(-6) : []; - const initialState: AiAssistantState = { - message: this.combineHistory(trimmedHistory, message), - history: trimmedHistory, - context: context || {}, - extractedFields: prior?.fields, - }; + + // Use Deep Agent as the main coordinator + const result = await this.runDeepAgent(tenantId, userId, message, history, context, prior); - const finalState = await this.runAssistantGraph(tenantId, userId, initialState); - - if (finalState.record) { + // Update conversation state based on result + if (result.record) { this.conversationState.delete(conversationKey); - } else if (finalState.extractedFields && Object.keys(finalState.extractedFields).length > 0) { + } else if ('extractedFields' in result && result.extractedFields && Object.keys(result.extractedFields).length > 0) { this.conversationState.set(conversationKey, { - fields: finalState.extractedFields, + fields: result.extractedFields, updatedAt: Date.now(), }); } return { - reply: finalState.reply || 'How can I help?', - action: finalState.action, - missingFields: finalState.missingFields, - record: finalState.record, + reply: result.reply || 'How can I help?', + action: result.action, + missingFields: result.missingFields, + record: result.record, }; } + private async runDeepAgent( + tenantId: string, + userId: string, + message: string, + history: AiAssistantState['history'], + context: AiAssistantState['context'], + prior?: { fields: Record; updatedAt: number }, + ): Promise }> { + const openAiConfig = await this.getOpenAiConfig(tenantId); + if (!openAiConfig) { + this.logger.warn('No OpenAI config found; using fallback graph execution.'); + // Fallback to direct graph execution if no OpenAI config + const initialState: AiAssistantState = { + message: this.combineHistory(history, message), + history: history, + context: context || {}, + extractedFields: prior?.fields, + }; + const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); + const result = await graph.invoke(initialState); + return { + reply: result.reply || 'How can I help?', + action: result.action, + missingFields: result.missingFields, + record: result.record, + }; + } + + // Discover available entities for dynamic prompt generation + const systemEntities = await this.discoverEntities(tenantId); + + // Build the compiled subagent + const compiledSubagent = this.buildResolveOrCreateRecordGraph(tenantId, userId); + + // Create Deep Agent with the subagent + const mainModel = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.3, + }); + + // Build dynamic system prompt based on discovered entities + const systemPrompt = this.buildDeepAgentSystemPrompt(systemEntities, context); + + const agent = createDeepAgent({ + model: mainModel, + systemPrompt, + tools: [], + subagents: [ + { + name: 'record-planner', + description: [ + 'USE THIS FOR ALL RECORD OPERATIONS. This is the ONLY way to create, find, or modify CRM records.', + '', + 'Pass the user\'s request directly. The subagent handles:', + '- Finding existing records (prevents duplicates)', + '- Creating new records with all required fields', + '- Managing relationships between records', + '- Transaction handling (prevents orphaned records)', + '', + 'Example: User says "Create contact John under Acme account"', + 'Just pass: "Create contact John under Acme account"', + 'The subagent will create both records with proper linking.', + ].join('\n'), + runnable: compiledSubagent, + }, + ], + }); + + // Convert history to messages format + const messages: BaseMessage[] = []; + if (history && history.length > 0) { + for (const entry of history) { + if (entry.role === 'user') { + messages.push(new HumanMessage(entry.text)); + } else if (entry.role === 'assistant') { + messages.push(new AIMessage(entry.text)); + } + } + } + messages.push(new HumanMessage(message)); + + // Include context information in the first message if available + let contextInfo = ''; + if (context?.objectApiName) { + contextInfo += `\n[System Context: User is working with ${context.objectApiName} object`; + if (context.recordId) { + contextInfo += `, record ID: ${context.recordId}`; + } + contextInfo += ']'; + } + if (prior?.fields && Object.keys(prior.fields).length > 0) { + contextInfo += `\n[Previously collected field values: ${JSON.stringify(prior.fields)}]`; + } + + if (contextInfo && messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + if (lastMessage instanceof HumanMessage) { + messages[messages.length - 1] = new HumanMessage( + lastMessage.content + contextInfo, + ); + } + } + + try { + console.log('=== DEEP AGENT: Starting invocation ==='); + console.log('Messages:', messages.map(m => ({ role: m._getType(), content: m.content }))); + + const result = await agent.invoke({ messages }); + + console.log('=== DEEP AGENT: Result received ==='); + console.log('Result messages count:', result.messages.length); + + // Look for subagent results in the messages + let subagentResult: any = null; + for (let i = result.messages.length - 1; i >= 0; i--) { + const msg = result.messages[i]; + console.log(`Message ${i}:`, { + type: msg._getType(), + content: typeof msg.content === 'string' ? msg.content.substring(0, 200) : msg.content, + additional_kwargs: msg.additional_kwargs, + }); + + // Check if this message has subagent output data + if (msg.additional_kwargs?.action || msg.additional_kwargs?.record) { + subagentResult = msg.additional_kwargs; + console.log('Found subagent result in message additional_kwargs:', subagentResult); + break; + } + } + + const lastMsg = result.messages[result.messages.length - 1]; + const replyText = typeof lastMsg.content === 'string' + ? lastMsg.content + : 'How can I help?'; + + console.log('Final reply text:', replyText); + + // If we found subagent results, use them; otherwise use defaults + if (subagentResult) { + console.log('=== DEEP AGENT: Using subagent result ==='); + + // If a record was found/created, log it prominently + if (subagentResult.record) { + const wasFound = subagentResult.foundExisting || subagentResult.record.wasFound; + console.log(`!!! Record ${wasFound ? 'FOUND' : 'CREATED'}: ID = ${subagentResult.record.id}, Name = ${subagentResult.record.name || 'N/A'}`); + } + + return { + reply: replyText, + action: subagentResult.action || 'clarify', + missingFields: subagentResult.missingFields || [], + record: subagentResult.record, + extractedFields: subagentResult.extractedFields, + }; + } + + console.log('=== DEEP AGENT: No subagent result found, using defaults ==='); + console.log('This usually means the Deep Agent did not invoke the subagent.'); + console.log('Falling back to direct graph invocation...'); + + // Fallback: invoke the graph directly since Deep Agent didn't use the subagent + const initialState: AiAssistantState = { + message: this.combineHistory(history, message), + history: history, + context: context || {}, + extractedFields: prior?.fields, + }; + const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); + const graphResult = await graph.invoke(initialState); + + console.log('=== DIRECT GRAPH: Result ===', { + action: graphResult.action, + hasRecord: !!graphResult.record, + reply: graphResult.reply?.substring(0, 100), + }); + + return { + reply: graphResult.reply || replyText, + action: graphResult.action || 'clarify', + missingFields: graphResult.missingFields || [], + record: graphResult.record, + extractedFields: graphResult.extractedFields, + }; + } catch (error) { + this.logger.error(`Deep Agent execution failed: ${error.message}`, error.stack); + // Fallback to direct graph execution + const initialState: AiAssistantState = { + message: this.combineHistory(history, message), + history: history, + context: context || {}, + extractedFields: prior?.fields, + }; + const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); + const result = await graph.invoke(initialState); + return { + reply: result.reply || 'How can I help?', + action: result.action, + missingFields: result.missingFields, + record: result.record, + }; + } + } + + private buildDeepAgentSystemPrompt( + systemEntities: SystemEntities, + context?: AiAssistantState['context'], + ): string { + const contextInfo = context?.objectApiName + ? ` The user is currently working with the ${context.objectApiName} object.` + : ''; + + // Generate dynamic entity information + const entitySummary = this.generateEntitySummaryForPrompt(systemEntities); + + // Find entities with relationships for examples + const entitiesWithRelationships = systemEntities.entities.filter(e => e.relationships.length > 0); + const relationshipExamples = entitiesWithRelationships.slice(0, 3).map(e => { + const rel = e.relationships[0]; + return ` - ${e.label} has a ${rel.fieldLabel} field that references ${rel.targetEntity}`; + }).join('\n'); + + return [ + 'You are an AI assistant helping users interact with a CRM system.', + '', + '*** CRITICAL: YOU MUST ALWAYS USE THE record-planner SUBAGENT ***', + 'You CANNOT create, find, or modify records yourself.', + 'For ANY request involving records, you MUST invoke the record-planner subagent.', + 'Do NOT respond to record-related requests without using the subagent first.', + '', + '=== AVAILABLE ENTITIES ===', + entitySummary, + '', + '=== HOW TO USE THE SUBAGENT ===', + 'Simply pass the user\'s request directly to the record-planner subagent.', + 'The subagent will:', + '1. Analyze what records need to be created', + '2. Check if any already exist (no duplicates)', + '3. Verify all required data is present', + '4. Create records in a transaction (no orphans)', + '', + '=== ENTITY RELATIONSHIPS ===', + relationshipExamples || ' (No relationships defined)', + '', + '=== RULES ===', + '- INVOKE the subagent for ANY record operation', + '- If subagent needs more data, ask the user', + '- Report success only when subagent confirms', + '', + contextInfo, + ].join('\n'); + } + async searchRecords( tenantId: string, userId: string, @@ -196,54 +723,970 @@ export class AiAssistantService { }; } - private async runAssistantGraph( + // ============================================ + // Planning-Based LangGraph Workflow + // ============================================ + + /** + * Builds the planning-based record creation graph. + * + * Flow: + * 1. transformInput - Convert Deep Agent messages to state + * 2. discoverEntities - Load all available entities in the system + * 3. analyzeIntent - Use AI to determine what entities need to be created + * 4. buildPlan - Create a plan of records to be created with dependencies + * 5. verifyPlan - Check if all required data is present + * 6. (if incomplete) -> requestMissingData -> END + * 7. (if complete) -> executePlan -> verifyExecution -> END + */ + private buildResolveOrCreateRecordGraph( tenantId: string, userId: string, - state: AiAssistantState, - ): Promise { - const AssistantState = Annotation.Root({ + ) { + // Extended state for planning-based workflow + const PlanningState = Annotation.Root({ + // Input fields message: Annotation(), + messages: Annotation(), history: Annotation(), context: Annotation(), + + // Entity discovery + systemEntities: Annotation(), + + // Intent analysis results + analyzedRecords: Annotation(), + + // Planning + plan: Annotation(), + + // Legacy compatibility objectDefinition: Annotation(), pageLayout: Annotation(), extractedFields: Annotation>(), requiredFields: Annotation(), missingFields: Annotation(), + + // Output action: Annotation(), record: Annotation(), + records: Annotation(), reply: Annotation(), }); - const workflow = new StateGraph(AssistantState) - .addNode('loadContext', async (current: AiAssistantState) => { - return this.loadContext(tenantId, current); - }) - .addNode('extractFields', async (current: AiAssistantState) => { - return this.extractFields(tenantId, current); - }) - .addNode('decideNext', async (current: AiAssistantState) => { - return this.decideNextStep(current); - }) - .addNode('createRecord', async (current: AiAssistantState) => { - return this.createRecord(tenantId, userId, current); - }) - .addNode('respondMissing', async (current: AiAssistantState) => { - return this.respondWithMissingFields(current); - }) - .addEdge(START, 'loadContext') - .addEdge('loadContext', 'extractFields') - .addEdge('extractFields', 'decideNext') - .addConditionalEdges('decideNext', (current: AiAssistantState) => { - return current.action === 'create_record' ? 'createRecord' : 'respondMissing'; - }) - .addEdge('createRecord', END) - .addEdge('respondMissing', END); + // Node 1: Transform Deep Agent messages into state + const transformInput = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Transform Input ==='); + + if (state.messages && Array.isArray(state.messages)) { + const lastMessage = state.messages[state.messages.length - 1]; + const messageText = typeof lastMessage.content === 'string' + ? lastMessage.content + : ''; + + console.log('Extracted message:', messageText); + + // Clean annotations from message + const cleanMessage = messageText + .replace(/\[System Context:[^\]]+\]/g, '') + .replace(/\[Previously collected field values:[^\]]+\]/g, '') + .replace(/\[Available Entities:[^\]]+\]/g, '') + .replace(/\[Current Plan:[^\]]+\]/g, '') + .trim(); + + // Extract any context hints + const contextMatch = messageText.match(/\[System Context: User is working with (\w+) object(?:, record ID: ([^\]]+))?\]/); + let extractedContext: AiAssistantState['context'] = {}; + + if (contextMatch) { + extractedContext.objectApiName = contextMatch[1]; + if (contextMatch[2]) { + extractedContext.recordId = contextMatch[2]; + } + } + + // Extract any existing plan from the conversation + const planMatch = messageText.match(/\[Current Plan: (.*?)\]/); + let existingPlan: RecordCreationPlan | undefined; + if (planMatch) { + try { + existingPlan = JSON.parse(planMatch[1]); + } catch (e) { + console.warn('Failed to parse existing plan from message'); + } + } + + return { + message: cleanMessage, + messages: state.messages, + history: [], + context: extractedContext, + plan: existingPlan, + }; + } + + return state; + }; - const graph = workflow.compile(); - return graph.invoke(state); + // Node 2: Discover available entities + const discoverEntitiesNode = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Discover Entities ==='); + + const systemEntities = await this.discoverEntities(tenantId); + console.log(`Discovered ${systemEntities.entities.length} entities`); + + return { + ...state, + systemEntities, + }; + }; + + // Node 3: Analyze user intent and determine what to create + const analyzeIntent = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Analyze Intent ==='); + + const { message, systemEntities } = state; + + // First, check if this is a search/find operation + const lowerMessage = message.toLowerCase(); + const isFindOperation = lowerMessage.includes('find') || + lowerMessage.includes('search') || + lowerMessage.includes('look for') || + lowerMessage.includes('get'); + + if (isFindOperation) { + // Handle as search operation, not creation + return this.handleSearchOperation(tenantId, userId, state); + } + + // Use AI to analyze what needs to be created + const openAiConfig = await this.getOpenAiConfig(tenantId); + if (!openAiConfig) { + // Fallback to heuristic analysis + return this.analyzeIntentWithHeuristics(state); + } + + return this.analyzeIntentWithAI(openAiConfig, state); + }; + + // Node 4: Build or update the creation plan + const buildPlanNode = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Build Plan ==='); + console.log('analyzedRecords:', state.analyzedRecords); + console.log('systemEntities available:', !!state.systemEntities); + console.log('systemEntities.entityByApiName keys:', state.systemEntities?.entityByApiName ? Object.keys(state.systemEntities.entityByApiName) : 'N/A'); + + const { systemEntities, plan, analyzedRecords } = state; + + if (!systemEntities || !systemEntities.entityByApiName) { + console.error('systemEntities not available in buildPlanNode!'); + return { + ...state, + action: 'clarify', + reply: 'System error: Entity definitions not loaded. Please try again.', + }; + } + + // If we already have a plan, update it; otherwise create new + let currentPlan = plan || this.createPlan(); + + // Track mapping from original index to actual plan record ID + // This is needed because existing records get different IDs than temp IDs + const indexToRecordId = new Map(); + // Also track name to record mapping for resolving lookup fields + const nameToExistingRecord = new Map(); + + if (analyzedRecords && Array.isArray(analyzedRecords)) { + console.log(`Processing ${analyzedRecords.length} analyzed records...`); + + for (let idx = 0; idx < analyzedRecords.length; idx++) { + const analyzed = analyzedRecords[idx]; + console.log(`Looking up entity: "${analyzed.entityName}"`); + const entityInfo = this.findEntityByName(systemEntities, analyzed.entityName); + if (!entityInfo) { + console.warn(`Entity ${analyzed.entityName} not found in system - skipping`); + continue; + } + + console.log(`Found entity: ${entityInfo.apiName} (${entityInfo.label})`); + + // Check if this record already exists in the plan + // For ContactDetail, match by value; for others, match by name + let existingInPlan: PlannedRecord | undefined; + if (entityInfo.apiName === 'ContactDetail') { + existingInPlan = currentPlan.records.find( + r => r.entityApiName === 'ContactDetail' && + r.fields.value === analyzed.fields?.value + ); + } else { + existingInPlan = currentPlan.records.find( + r => r.entityApiName === entityInfo.apiName && + r.fields.name === analyzed.fields?.name + ); + } + + if (existingInPlan) { + console.log(`Record already in plan, updating fields`); + // Update existing planned record + this.updatePlannedRecordFields(currentPlan, existingInPlan.id, analyzed.fields || {}, systemEntities); + } else { + // Check if record already exists in database + let existingRecord: any = null; + + // For ContactDetail, search by value instead of name + if (entityInfo.apiName === 'ContactDetail') { + const searchValue = analyzed.fields?.value; + if (searchValue) { + existingRecord = await this.searchForExistingContactDetail( + tenantId, + userId, + searchValue, + analyzed.fields?.relatedObjectId + ); + } + } else { + // Standard search by name for other entities + const searchName = analyzed.fields?.name || analyzed.fields?.firstName; + existingRecord = searchName ? await this.searchForExistingRecord( + tenantId, + userId, + entityInfo.apiName, + searchName + ) : null; + } + + if (existingRecord) { + console.log(`Found existing ${entityInfo.apiName} in database: ${existingRecord.id}`); + // Record exists, add to plan as already created + const recordPlanId = `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`; + const plannedRecord: PlannedRecord = { + id: recordPlanId, + entityApiName: entityInfo.apiName, + entityLabel: entityInfo.label, + fields: existingRecord, // Use full existing record for accurate display + resolvedFields: existingRecord, // Also set resolvedFields + missingRequiredFields: [], + dependsOn: [], + status: 'created', + createdRecordId: existingRecord.id, + wasExisting: true, // Mark as pre-existing + }; + currentPlan.records.push(plannedRecord); + + // Track the mapping from original index to actual plan record ID + indexToRecordId.set(idx, recordPlanId); + + // Track by name for lookup field resolution + const recordName = existingRecord.name || existingRecord.firstName || existingRecord.value; + if (recordName) { + nameToExistingRecord.set(recordName.toLowerCase(), { + id: existingRecord.id, + recordId: recordPlanId, + entityType: entityInfo.apiName, + }); + } + + console.log(`Mapped index ${idx} to existing record ${recordPlanId}, name="${recordName}"`); + } else { + console.log(`Adding new record to plan: ${entityInfo.apiName} with fields:`, analyzed.fields); + + // Resolve dependsOn references - convert original indices to actual plan record IDs + let resolvedDependsOn = analyzed.dependsOn || []; + if (resolvedDependsOn.length > 0) { + resolvedDependsOn = resolvedDependsOn.map((dep: string) => { + // Check if this is a temp_xxx reference that maps to an existing record + const tempMatch = dep.match(/temp_(\w+)_(\d+)/); + if (tempMatch) { + const depIndex = parseInt(tempMatch[2], 10) - 1; // temp IDs are 1-based + const actualId = indexToRecordId.get(depIndex); + if (actualId) { + console.log(`Resolved dependency ${dep} to existing record ${actualId}`); + return actualId; + } + } + return dep; + }); + } + + // Also populate lookup fields with parent names if dependencies exist + const fieldsWithLookups = { ...analyzed.fields }; + for (const dep of resolvedDependsOn) { + // Find the parent record in the plan + const parentRecord = currentPlan.records.find(r => r.id === dep); + if (parentRecord && parentRecord.wasExisting) { + // Find the lookup field that should reference this entity + const lookupRel = entityInfo.relationships.find( + rel => rel.targetEntity.toLowerCase() === parentRecord.entityApiName.toLowerCase() + ); + if (lookupRel && !fieldsWithLookups[lookupRel.fieldApiName]) { + // Set the lookup field to the parent's name so it can be resolved + const parentName = parentRecord.fields.name || parentRecord.fields.firstName; + if (parentName) { + fieldsWithLookups[lookupRel.fieldApiName] = parentName; + console.log(`Set lookup field ${lookupRel.fieldApiName} = "${parentName}" for relationship to ${parentRecord.entityApiName}`); + } + } + } + } + + // Add new record to plan + const newRecord = this.addRecordToPlan( + currentPlan, + entityInfo, + fieldsWithLookups, + resolvedDependsOn + ); + + // Track the mapping + indexToRecordId.set(idx, newRecord.id); + console.log(`Mapped index ${idx} to new record ${newRecord.id}`); + } + } + } + } + + // Calculate execution order + this.calculateExecutionOrder(currentPlan); + + // Determine plan status + if (this.isPlanComplete(currentPlan)) { + currentPlan.status = 'ready'; + } else { + currentPlan.status = 'incomplete'; + } + + console.log('Plan status:', currentPlan.status); + console.log('Plan records:', currentPlan.records.map(r => ({ + id: r.id, + entity: r.entityApiName, + status: r.status, + missing: r.missingRequiredFields, + }))); + + return { + ...state, + plan: currentPlan, + }; + }; + + // Node 5: Verify plan completeness + const verifyPlan = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Verify Plan ==='); + + const { plan, systemEntities } = state; + + if (!plan || plan.records.length === 0) { + return { + ...state, + action: 'clarify', + reply: 'I\'m not sure what you\'d like to create. Could you please be more specific?', + }; + } + + if (plan.status === 'ready') { + console.log('Plan is complete and ready for execution'); + return { + ...state, + action: 'plan_complete', + }; + } + + // Plan is incomplete, need more data + const missingFieldsSummary = this.generateMissingFieldsSummary(plan, systemEntities); + console.log('Plan incomplete:', missingFieldsSummary); + + return { + ...state, + action: 'plan_pending', + reply: missingFieldsSummary, + missingFields: this.getAllMissingFields(plan).flatMap(m => m.missingFields), + }; + }; + + // Node 6: Execute the plan (with transaction) + const executePlan = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Execute Plan ==='); + + const { plan, systemEntities } = state; + + if (!plan || plan.status !== 'ready') { + return { + ...state, + action: 'clarify', + reply: 'The plan is not ready for execution.', + }; + } + + plan.status = 'executing'; + + // Get tenant database connection for transaction + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Map of temp IDs to real IDs for dependency resolution + const idMapping = new Map(); + // Map of record names to their created IDs and entity types + const nameToRecord = new Map(); + + // Populate with already created records + for (const record of plan.records) { + if (record.status === 'created' && record.createdRecordId) { + idMapping.set(record.id, record.createdRecordId); + const recordName = record.fields.name || record.fields.firstName; + if (recordName) { + nameToRecord.set(recordName.toLowerCase(), { + id: record.createdRecordId, + entityType: record.entityApiName, + }); + } + } + } + + try { + // Execute in transaction + await knex.transaction(async (trx) => { + for (const tempId of plan.executionOrder) { + const plannedRecord = plan.records.find(r => r.id === tempId); + if (!plannedRecord || plannedRecord.status === 'created') continue; + + console.log(`Creating record: ${plannedRecord.entityLabel} (${plannedRecord.id})`); + console.log(`Original fields:`, plannedRecord.fields); + + // Resolve any dependency references in fields + const resolvedFields = { ...plannedRecord.fields }; + + // Get entity info for this record type + const entityInfo = this.findEntityByName(systemEntities, plannedRecord.entityApiName); + + // Resolve dependencies by temp ID + for (const depId of plannedRecord.dependsOn) { + const realId = idMapping.get(depId); + if (realId) { + const depRecord = plan.records.find(r => r.id === depId); + if (depRecord && entityInfo) { + // Find the lookup field that references this entity + const lookupField = entityInfo.relationships.find( + r => r.targetEntity.toLowerCase() === depRecord.entityApiName.toLowerCase() + ); + if (lookupField) { + resolvedFields[lookupField.fieldApiName] = realId; + console.log(`Resolved ${lookupField.fieldApiName} = ${realId} (from dependency ${depId})`); + } + } + } + } + + // Handle polymorphic fields (relatedObjectId/relatedObjectType for ContactDetail) + if (plannedRecord.entityApiName.toLowerCase() === 'contactdetail') { + // Check if relatedObjectId is a name rather than UUID + const relatedValue = resolvedFields.relatedObjectId; + if (relatedValue && typeof relatedValue === 'string' && !this.isUuid(relatedValue)) { + // Try to find the record by name in our created records + const foundRecord = nameToRecord.get(relatedValue.toLowerCase()); + if (foundRecord) { + resolvedFields.relatedObjectId = foundRecord.id; + resolvedFields.relatedObjectType = foundRecord.entityType; + console.log(`Resolved polymorphic: relatedObjectId=${foundRecord.id}, relatedObjectType=${foundRecord.entityType}`); + } else { + // Try to search in database + for (const targetType of ['Contact', 'Account']) { + const existingRecord = await this.searchForExistingRecord( + tenantId, userId, targetType, relatedValue + ); + if (existingRecord) { + resolvedFields.relatedObjectId = existingRecord.id; + resolvedFields.relatedObjectType = targetType; + console.log(`Found existing record for polymorphic: ${targetType} ${existingRecord.id}`); + break; + } + } + } + } + + // Ensure relatedObjectType is set if we have relatedObjectId + if (resolvedFields.relatedObjectId && !resolvedFields.relatedObjectType) { + // Default to Contact if not specified + resolvedFields.relatedObjectType = 'Contact'; + console.log(`Defaulting relatedObjectType to Contact`); + } + } + + // Resolve any remaining lookup fields that have names instead of IDs + if (entityInfo) { + for (const rel of entityInfo.relationships) { + const fieldValue = resolvedFields[rel.fieldApiName]; + if (fieldValue && typeof fieldValue === 'string' && !this.isUuid(fieldValue)) { + // This is a name, try to resolve it + const foundInPlan = nameToRecord.get(fieldValue.toLowerCase()); + if (foundInPlan && foundInPlan.entityType.toLowerCase() === rel.targetEntity.toLowerCase()) { + resolvedFields[rel.fieldApiName] = foundInPlan.id; + console.log(`Resolved lookup ${rel.fieldApiName} from name "${fieldValue}" to ID ${foundInPlan.id}`); + } else { + // Search in database + const existingRecord = await this.searchForExistingRecord( + tenantId, userId, rel.targetEntity, fieldValue + ); + if (existingRecord) { + resolvedFields[rel.fieldApiName] = existingRecord.id; + console.log(`Resolved lookup ${rel.fieldApiName} from database: ${existingRecord.id}`); + } + } + } + } + } + + console.log(`Resolved fields:`, resolvedFields); + + // Create the record + const createdRecord = await this.objectService.createRecord( + tenantId, + plannedRecord.entityApiName, + resolvedFields, + userId, + ); + + if (createdRecord?.id) { + plannedRecord.status = 'created'; + plannedRecord.createdRecordId = createdRecord.id; + // Store the resolved fields for accurate success message + plannedRecord.resolvedFields = resolvedFields; + idMapping.set(plannedRecord.id, createdRecord.id); + + // Add to nameToRecord map for future lookups + const recordName = resolvedFields.name || + (resolvedFields.firstName ? `${resolvedFields.firstName} ${resolvedFields.lastName || ''}`.trim() : '') || + resolvedFields.value || ''; + if (recordName) { + nameToRecord.set(recordName.toLowerCase(), { + id: createdRecord.id, + entityType: plannedRecord.entityApiName, + }); + } + + plan.createdRecords.push({ ...createdRecord, _displayName: recordName }); + console.log(`Created: ${plannedRecord.entityLabel} ID=${createdRecord.id}, name="${recordName}"`); + } else { + throw new Error(`Failed to create ${plannedRecord.entityLabel}`); + } + } + }); + + plan.status = 'completed'; + + // Generate success message with meaningful names + const newlyCreated = plan.records.filter(r => r.status === 'created' && !r.wasExisting); + const existingUsed = plan.records.filter(r => r.wasExisting); + + const getRecordDisplayName = (r: any) => { + // Use resolvedFields if available (for newly created), otherwise original fields + const fields = r.resolvedFields || r.fields; + return fields.name || + (fields.firstName ? `${fields.firstName} ${fields.lastName || ''}`.trim() : '') || + fields.value || + 'record'; + }; + + const createdSummary = newlyCreated + .map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`) + .join(', '); + + const existingSummary = existingUsed.length > 0 + ? ` (using existing: ${existingUsed.map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`).join(', ')})` + : ''; + + // Build appropriate reply based on what was created vs found + let replyMessage: string; + if (newlyCreated.length > 0 && existingUsed.length > 0) { + replyMessage = `Successfully created: ${createdSummary}${existingSummary}`; + } else if (newlyCreated.length > 0) { + replyMessage = `Successfully created: ${createdSummary}`; + } else if (existingUsed.length > 0) { + replyMessage = `Found existing records: ${existingUsed.map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`).join(', ')}. No new records needed.`; + } else { + replyMessage = 'No records were created.'; + } + + return { + ...state, + plan, + action: 'create_record', + records: plan.createdRecords, + record: plan.createdRecords[plan.createdRecords.length - 1], // Last created for compatibility + reply: replyMessage, + }; + + } catch (error) { + console.error('Plan execution failed:', error); + plan.status = 'failed'; + plan.errors.push(error.message); + + return { + ...state, + plan, + action: 'clarify', + reply: `Failed to create records: ${error.message}. The transaction was rolled back.`, + }; + } + }; + + // Node 7: Format output for Deep Agent + const formatOutput = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Format Output ==='); + + const outputMessage = new AIMessage({ + content: state.reply || 'Completed.', + additional_kwargs: { + action: state.action, + record: state.record, + records: state.records, + plan: state.plan, + missingFields: state.missingFields, + foundExisting: state.record?.wasFound || false, + }, + }); + + return { + ...state, + messages: [...(state.messages || []), outputMessage], + }; + }; + + // Build the workflow + const workflow = new StateGraph(PlanningState) + .addNode('transformInput', transformInput) + .addNode('discoverEntities', discoverEntitiesNode) + .addNode('analyzeIntent', analyzeIntent) + .addNode('buildPlan', buildPlanNode) + .addNode('verifyPlan', verifyPlan) + .addNode('executePlan', executePlan) + .addNode('formatOutput', formatOutput) + .addEdge(START, 'transformInput') + .addEdge('transformInput', 'discoverEntities') + .addEdge('discoverEntities', 'analyzeIntent') + .addConditionalEdges('analyzeIntent', (current: any) => { + // If it's a search result, go directly to format output + if (current.record || current.action === 'clarify') { + return 'formatOutput'; + } + return 'buildPlan'; + }) + .addEdge('buildPlan', 'verifyPlan') + .addConditionalEdges('verifyPlan', (current: any) => { + // If plan is complete, execute it; otherwise format the missing fields response + if (current.action === 'plan_complete') { + return 'executePlan'; + } + return 'formatOutput'; + }) + .addEdge('executePlan', 'formatOutput') + .addEdge('formatOutput', END); + + return workflow.compile(); } + // ============================================ + // Intent Analysis Helpers + // ============================================ + + private async handleSearchOperation( + tenantId: string, + userId: string, + state: any, + ): Promise { + const { message, systemEntities } = state; + + // Try to extract entity type and search term + const lowerMessage = message.toLowerCase(); + let entityName: string | undefined; + let searchTerm: string | undefined; + + // Pattern: "find/search/get [entity] [name]" + for (const entity of systemEntities.entities) { + const label = entity.label.toLowerCase(); + const apiName = entity.apiName.toLowerCase(); + + if (lowerMessage.includes(label) || lowerMessage.includes(apiName)) { + entityName = entity.apiName; + // Extract the search term after the entity name + const regex = new RegExp(`(?:find|search|get|look for)\\s+(?:${label}|${apiName})\\s+(.+)`, 'i'); + const match = message.match(regex); + if (match) { + searchTerm = match[1].trim(); + } + break; + } + } + + if (!entityName) { + return { + ...state, + action: 'clarify', + reply: 'Which type of record would you like to find?', + }; + } + + if (!searchTerm) { + return { + ...state, + action: 'clarify', + reply: `What ${entityName} are you looking for?`, + }; + } + + // Search for the record + const record = await this.searchForExistingRecord(tenantId, userId, entityName, searchTerm); + + if (record) { + return { + ...state, + action: 'create_record', // Using create_record for compatibility + record: { ...record, wasFound: true }, + reply: `Found ${entityName}: "${record.name || record.id}" (ID: ${record.id})`, + }; + } + + return { + ...state, + action: 'clarify', + reply: `No ${entityName} found matching "${searchTerm}". Would you like to create one?`, + }; + } + + private async analyzeIntentWithAI( + openAiConfig: any, + state: any, + ): Promise { + const { message, systemEntities } = state; + + const model = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.2, + }); + + const entitySummary = this.generateEntitySummaryForPrompt(systemEntities); + + const parser = new JsonOutputParser(); + + try { + const response = await model.invoke([ + new SystemMessage( + `You analyze user requests to determine what CRM records need to be created.\n\n` + + `${entitySummary}\n\n` + + `Return JSON with:\n` + + `- "records": array of records to create, each with:\n` + + ` - "entityName": the entity type (use apiName from the list)\n` + + ` - "fields": object with field values mentioned by user\n` + + ` - "dependsOn": array of indices of records this depends on (for relationships)\n\n` + + `Rules:\n` + + `- Only use entities from the list above\n` + + `- For "create X under/for Y", X depends on Y\n` + + `- Parent records (like Account) should come before children (like Contact)\n` + + `- Extract any field values mentioned in the request\n` + + `- For the "name" field, use the name mentioned by the user\n` + + `Example: "Create contact John under Acme account"\n` + + `Response: {"records":[{"entityName":"Account","fields":{"name":"Acme"},"dependsOn":[]},{"entityName":"Contact","fields":{"name":"John"},"dependsOn":[0]}]}` + ), + new HumanMessage(message), + ]); + + const content = typeof response.content === 'string' ? response.content : '{}'; + const parsed = await parser.parse(content); + + // Transform the AI response to our format + const analyzedRecords = (parsed.records || []).map((r: any, idx: number) => ({ + entityName: r.entityName, + fields: r.fields || {}, + dependsOn: (r.dependsOn || []).map((depIdx: number) => + `temp_${parsed.records[depIdx]?.entityName?.toLowerCase()}_${depIdx + 1}` + ), + })); + + console.log('AI analyzed records:', analyzedRecords); + + return { + ...state, + analyzedRecords, + }; + } catch (error) { + console.error('AI intent analysis failed:', error); + return this.analyzeIntentWithHeuristics(state); + } + } + + private analyzeIntentWithHeuristics(state: any): any { + const { message, systemEntities } = state; + const lowerMessage = message.toLowerCase(); + const analyzedRecords: any[] = []; + + // Pattern: "create X under/for Y account" + const underAccountMatch = message.match(/create\s+(\w+(?:\s+\w+)?)\s+(?:under|for)\s+(\w+(?:\s+\w+)?)\s+account/i); + if (underAccountMatch) { + const childName = underAccountMatch[1].trim(); + const accountName = underAccountMatch[2].trim(); + + // Add parent account first + analyzedRecords.push({ + entityName: 'Account', + fields: { name: accountName }, + dependsOn: [], + }); + + // Add child - try to determine type + let childEntity = 'Contact'; // Default + if (lowerMessage.includes('phone') || lowerMessage.includes('email')) { + childEntity = 'ContactDetail'; + } + + analyzedRecords.push({ + entityName: childEntity, + fields: { name: childName }, + dependsOn: ['temp_account_1'], + }); + } else { + // Simple pattern: "create/add [entity] [name]" + for (const entity of systemEntities.entities) { + const label = entity.label.toLowerCase(); + const regex = new RegExp(`(?:create|add)\\s+(?:a\\s+)?${label}\\s+(.+?)(?:\\s+with|$)`, 'i'); + const match = message.match(regex); + + if (match) { + analyzedRecords.push({ + entityName: entity.apiName, + fields: { name: match[1].trim() }, + dependsOn: [], + }); + break; + } + } + } + + console.log('Heuristic analyzed records:', analyzedRecords); + + return { + ...state, + analyzedRecords, + }; + } + + private async searchForExistingRecord( + tenantId: string, + userId: string, + entityApiName: string, + searchName: string, + ): Promise { + if (!searchName) return null; + + try { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + + // Try Meilisearch first + if (this.meilisearchService.isEnabled()) { + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + entityApiName, + searchName, + 'name', + ); + + if (meiliMatch?.id) { + console.log(`Found existing ${entityApiName} via Meilisearch: ${meiliMatch.id}`); + // Return the full hit data, not just { id, hit } + return meiliMatch.hit || meiliMatch; + } + } + + // Fallback to database + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + const tableName = this.toTableName(entityApiName); + + const record = await knex(tableName) + .whereRaw('LOWER(name) = ?', [searchName.toLowerCase()]) + .first(); + + if (record?.id) { + console.log(`Found existing ${entityApiName} via database: ${record.id}`); + return record; + } + + return null; + } catch (error) { + console.error(`Error searching for ${entityApiName}:`, error.message); + return null; + } + } + + /** + * Search for existing ContactDetail by value and optionally by relatedObjectId + * ContactDetail records are identified by their value (phone number, email, etc.) + * and optionally their parent record (Contact or Account) + */ + private async searchForExistingContactDetail( + tenantId: string, + userId: string, + value: string, + relatedObjectId?: string, + ): Promise { + if (!value) return null; + + try { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + + // Try Meilisearch first - search by value field + if (this.meilisearchService.isEnabled()) { + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + 'ContactDetail', + value, + 'value', + ); + + if (meiliMatch?.id) { + // Access the full record data from hit + const hitData = meiliMatch.hit || meiliMatch; + // If we have a relatedObjectId, verify it matches (or skip if not resolved yet) + if (!relatedObjectId || this.isUuid(relatedObjectId)) { + if (!relatedObjectId || hitData.relatedObjectId === relatedObjectId) { + console.log(`Found existing ContactDetail via Meilisearch: ${meiliMatch.id}`); + return hitData; + } + } else { + // relatedObjectId is a name, not UUID - just match by value for now + console.log(`Found existing ContactDetail via Meilisearch (value match): ${meiliMatch.id}`); + return hitData; + } + } + } + + // Fallback to database + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + const tableName = this.toTableName('ContactDetail'); + + let query = knex(tableName).whereRaw('LOWER(value) = ?', [value.toLowerCase()]); + + // If we have a UUID relatedObjectId, include it in the search + if (relatedObjectId && this.isUuid(relatedObjectId)) { + query = query.where('relatedObjectId', relatedObjectId); + } + + const record = await query.first(); + + if (record?.id) { + console.log(`Found existing ContactDetail via database: ${record.id} (value="${value}")`); + return record; + } + + return null; + } catch (error) { + console.error(`Error searching for ContactDetail:`, error.message); + return null; + } + } + + // ============================================ + // Legacy Methods (kept for compatibility) + // ============================================ + private async loadContext( tenantId: string, state: AiAssistantState, @@ -284,6 +1727,97 @@ export class AiAssistantService { }; } + private async searchExistingRecord( + tenantId: string, + userId: string, + state: AiAssistantState, + ): Promise { + if (!state.objectDefinition || !state.message) { + return state; + } + + // Check if this is a find/search operation or if we should check for existing + const lowerMessage = state.message.toLowerCase(); + const isFindOperation = lowerMessage.includes('find') || lowerMessage.includes('search') || lowerMessage.includes('look for'); + const isCreateOperation = lowerMessage.includes('create') || lowerMessage.includes('add'); + + // Extract the name to search for + let searchName: string | null = null; + + // Pattern: "Find Account X" + const findMatch = state.message.match(/(?:find|search|look for)\s+(?:\w+\s+)?(.+?)(?:\(|$)/i); + if (findMatch) { + searchName = findMatch[1].trim(); + } else if (isCreateOperation) { + // Pattern: "Create Account X" - check if X already exists + const createMatch = state.message.match(/(?:create|add)\s+(?:\w+\s+)?(.+?)(?:\s+(?:under|for|with)|\(|$)/i); + if (createMatch) { + searchName = createMatch[1].trim(); + } + } + + if (!searchName) { + console.log('No search name extracted, skipping search'); + return state; + } + + console.log(`Searching for existing ${state.objectDefinition.apiName}: "${searchName}"`); + + try { + // Use Meilisearch if available + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + + if (this.meilisearchService.isEnabled()) { + const displayField = this.getDisplayFieldForObject(state.objectDefinition); + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + state.objectDefinition.apiName, + searchName, + displayField, + ); + + if (meiliMatch?.id) { + console.log('Found existing record via Meilisearch:', meiliMatch.id); + return { + ...state, + record: { ...meiliMatch, wasFound: true }, + action: 'create_record', + reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${meiliMatch.id}).`, + }; + } + } + + // Fallback to database search + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + const tableName = this.toTableName( + state.objectDefinition.apiName, + state.objectDefinition.label, + state.objectDefinition.pluralLabel, + ); + const displayField = this.getDisplayFieldForObject(state.objectDefinition); + + const record = await knex(tableName) + .whereRaw('LOWER(??) = ?', [displayField, searchName.toLowerCase()]) + .first(); + + if (record?.id) { + console.log('Found existing record via database:', record.id); + return { + ...state, + record: { ...record, wasFound: true }, + action: 'create_record', + reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${record.id}).`, + }; + } + + console.log('No existing record found, will proceed to create'); + return state; + } catch (error) { + console.error('Error searching for existing record:', error.message); + return state; + } + } + private async extractFields( tenantId: string, state: AiAssistantState, @@ -451,6 +1985,8 @@ export class AiAssistantService { userId, ); + console.log('record',record); + const nameValue = enrichedState.extractedFields.name || record?.name || record?.id; const label = enrichedState.objectDefinition.label || enrichedState.objectDefinition.apiName; @@ -619,6 +2155,26 @@ export class AiAssistantService { const phoneField = fieldDefinitions.find((field) => field.apiName.toLowerCase().includes('phone') || field.label.toLowerCase().includes('phone'), ); + + // Check for Account lookup field (for Contacts) + const accountField = fieldDefinitions.find((field) => + field.apiName === 'accountId' || field.apiName.toLowerCase().includes('account'), + ); + + // Pattern: "Create X under/for Y account" - extract name and account reference + const underAccountMatch = message.match(/create\s+([^\s]+(?:\s+[^\s]+)?)\s+(?:under|for)\s+(.+?)\s+account/i); + if (underAccountMatch && nameField) { + const recordName = underAccountMatch[1].trim(); + const accountName = underAccountMatch[2].trim(); + + extracted[nameField.apiName] = recordName; + + if (accountField) { + // Store the account name for lookup + extracted[accountField.apiName] = accountName; + console.log('Extracted hierarchical pattern:', { name: recordName, account: accountName }); + } + } // Generic pattern matching for any field: "label: value" or "set label to value" for (const field of fieldDefinitions) { @@ -628,7 +2184,7 @@ export class AiAssistantService { } } - if (nameField) { + if (nameField && !extracted[nameField.apiName]) { const nameMatch = message.match(/add\s+([^\n]+?)(?:\s+with\s+|$)/i); if (nameMatch?.[1]) { extracted[nameField.apiName] = nameMatch[1].trim(); @@ -646,6 +2202,8 @@ export class AiAssistantService { extracted[nameField.apiName] = message.replace(/^add\s+/i, '').trim(); } + console.log('Heuristic extraction result:', extracted); + return extracted; } diff --git a/backend/src/ai-assistant/ai-assistant.types.ts b/backend/src/ai-assistant/ai-assistant.types.ts index cdb1b3e..4923c24 100644 --- a/backend/src/ai-assistant/ai-assistant.types.ts +++ b/backend/src/ai-assistant/ai-assistant.types.ts @@ -12,15 +12,94 @@ export interface AiChatContext { export interface AiAssistantReply { reply: string; - action?: 'create_record' | 'collect_fields' | 'clarify'; + action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending'; missingFields?: string[]; record?: any; + records?: any[]; // Multiple records when plan execution completes + plan?: RecordCreationPlan; } +// ============================================ +// Entity Discovery Types +// ============================================ + +export interface EntityFieldInfo { + apiName: string; + label: string; + type: string; + isRequired: boolean; + isSystem: boolean; + referenceObject?: string; // For LOOKUP fields, the target entity + description?: string; +} + +export interface EntityRelationship { + fieldApiName: string; + fieldLabel: string; + targetEntity: string; + relationshipType: 'lookup' | 'master-detail' | 'polymorphic'; +} + +export interface EntityInfo { + apiName: string; + label: string; + pluralLabel?: string; + description?: string; + fields: EntityFieldInfo[]; + requiredFields: string[]; // Field apiNames that are required + relationships: EntityRelationship[]; +} + +export interface SystemEntities { + entities: EntityInfo[]; + entityByApiName: Record; // Changed from Map for state serialization + loadedAt: number; +} + +// ============================================ +// Planning Types +// ============================================ + +export interface PlannedRecord { + id: string; // Temporary ID for planning (e.g., "temp_account_1") + entityApiName: string; + entityLabel: string; + fields: Record; + resolvedFields?: Record; // Fields after dependency resolution + missingRequiredFields: string[]; + dependsOn: string[]; // IDs of other planned records this depends on + status: 'pending' | 'ready' | 'created' | 'failed'; + createdRecordId?: string; // Actual ID after creation + wasExisting?: boolean; // True if record already existed in database + error?: string; +} + +export interface RecordCreationPlan { + id: string; + records: PlannedRecord[]; + executionOrder: string[]; // Ordered list of planned record IDs + status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed'; + createdRecords: any[]; + errors: string[]; +} + +// ============================================ +// State Types +// ============================================ + export interface AiAssistantState { message: string; + messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent history?: AiChatMessage[]; context: AiChatContext; + + // Entity discovery + systemEntities?: SystemEntities; + + // Planning + plan?: RecordCreationPlan; + + // Legacy fields (kept for compatibility during transition) objectDefinition?: any; pageLayout?: any; extractedFields?: Record; diff --git a/backend/src/object/field-mapper.service.ts b/backend/src/object/field-mapper.service.ts index 345c00d..5c55c0d 100644 --- a/backend/src/object/field-mapper.service.ts +++ b/backend/src/object/field-mapper.service.ts @@ -79,6 +79,10 @@ export class FieldMapperService { const frontendType = this.mapFieldType(field.type); const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup'); + // Hide 'id' field from list view by default + const isIdField = field.apiName === 'id'; + const defaultShowOnList = isIdField ? false : true; + return { id: field.id, apiName: field.apiName, @@ -95,7 +99,7 @@ export class FieldMapperService { isReadOnly: field.isSystem || uiMetadata.isReadOnly || false, // View visibility - showOnList: uiMetadata.showOnList !== false, + showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList, showOnDetail: uiMetadata.showOnDetail !== false, showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem, sortable: uiMetadata.sortable !== false, @@ -141,6 +145,7 @@ export class FieldMapperService { 'boolean': 'boolean', 'date': 'date', 'datetime': 'datetime', + 'date_time': 'datetime', 'time': 'time', 'email': 'email', 'url': 'url', diff --git a/backend/src/page-layout/dto/page-layout.dto.ts b/backend/src/page-layout/dto/page-layout.dto.ts index 17a8b5d..1df55b7 100644 --- a/backend/src/page-layout/dto/page-layout.dto.ts +++ b/backend/src/page-layout/dto/page-layout.dto.ts @@ -1,4 +1,6 @@ -import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator'; +import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator'; + +export type PageLayoutType = 'detail' | 'list'; export class CreatePageLayoutDto { @IsString() @@ -7,18 +9,25 @@ export class CreatePageLayoutDto { @IsUUID() objectId: string; + @IsIn(['detail', 'list']) + @IsOptional() + layoutType?: PageLayoutType = 'detail'; + @IsBoolean() @IsOptional() isDefault?: boolean; @IsObject() layoutConfig: { + // For detail layouts: grid-based field positions fields: Array<{ fieldId: string; - x: number; - y: number; - w: number; - h: number; + x?: number; + y?: number; + w?: number; + h?: number; + // For list layouts: field order (optional, defaults to array index) + order?: number; }>; relatedLists?: string[]; }; @@ -42,10 +51,11 @@ export class UpdatePageLayoutDto { layoutConfig?: { fields: Array<{ fieldId: string; - x: number; - y: number; - w: number; - h: number; + x?: number; + y?: number; + w?: number; + h?: number; + order?: number; }>; relatedLists?: string[]; }; diff --git a/backend/src/page-layout/page-layout.controller.ts b/backend/src/page-layout/page-layout.controller.ts index a7cbcae..cde4647 100644 --- a/backend/src/page-layout/page-layout.controller.ts +++ b/backend/src/page-layout/page-layout.controller.ts @@ -10,7 +10,7 @@ import { Query, } from '@nestjs/common'; import { PageLayoutService } from './page-layout.service'; -import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto'; +import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { TenantId } from '../tenant/tenant.decorator'; @@ -25,13 +25,21 @@ export class PageLayoutController { } @Get() - findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) { - return this.pageLayoutService.findAll(tenantId, objectId); + findAll( + @TenantId() tenantId: string, + @Query('objectId') objectId?: string, + @Query('layoutType') layoutType?: PageLayoutType, + ) { + return this.pageLayoutService.findAll(tenantId, objectId, layoutType); } @Get('default/:objectId') - findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) { - return this.pageLayoutService.findDefaultByObject(tenantId, objectId); + findDefaultByObject( + @TenantId() tenantId: string, + @Param('objectId') objectId: string, + @Query('layoutType') layoutType?: PageLayoutType, + ) { + return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail'); } @Get(':id') diff --git a/backend/src/page-layout/page-layout.service.ts b/backend/src/page-layout/page-layout.service.ts index 3594602..6b3c8e2 100644 --- a/backend/src/page-layout/page-layout.service.ts +++ b/backend/src/page-layout/page-layout.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; -import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto'; +import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto'; @Injectable() export class PageLayoutService { @@ -8,17 +8,19 @@ export class PageLayoutService { async create(tenantId: string, createDto: CreatePageLayoutDto) { const knex = await this.tenantDbService.getTenantKnex(tenantId); + const layoutType = createDto.layoutType || 'detail'; - // If this layout is set as default, unset other defaults for the same object + // If this layout is set as default, unset other defaults for the same object and layout type if (createDto.isDefault) { await knex('page_layouts') - .where({ object_id: createDto.objectId }) + .where({ object_id: createDto.objectId, layout_type: layoutType }) .update({ is_default: false }); } const [id] = await knex('page_layouts').insert({ name: createDto.name, object_id: createDto.objectId, + layout_type: layoutType, is_default: createDto.isDefault || false, layout_config: JSON.stringify(createDto.layoutConfig), description: createDto.description || null, @@ -29,7 +31,7 @@ export class PageLayoutService { return result; } - async findAll(tenantId: string, objectId?: string) { + async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) { const knex = await this.tenantDbService.getTenantKnex(tenantId); let query = knex('page_layouts'); @@ -38,6 +40,10 @@ export class PageLayoutService { query = query.where({ object_id: objectId }); } + if (layoutType) { + query = query.where({ layout_type: layoutType }); + } + const layouts = await query.orderByRaw('is_default DESC, name ASC'); return layouts; } @@ -54,11 +60,11 @@ export class PageLayoutService { return layout; } - async findDefaultByObject(tenantId: string, objectId: string) { + async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') { const knex = await this.tenantDbService.getTenantKnex(tenantId); const layout = await knex('page_layouts') - .where({ object_id: objectId, is_default: true }) + .where({ object_id: objectId, is_default: true, layout_type: layoutType }) .first(); return layout || null; @@ -68,13 +74,12 @@ export class PageLayoutService { const knex = await this.tenantDbService.getTenantKnex(tenantId); // Check if layout exists - await this.findOne(tenantId, id); + const layout = await this.findOne(tenantId, id); - // If setting as default, unset other defaults for the same object + // If setting as default, unset other defaults for the same object and layout type if (updateDto.isDefault) { - const layout = await this.findOne(tenantId, id); await knex('page_layouts') - .where({ object_id: layout.object_id }) + .where({ object_id: layout.object_id, layout_type: layout.layout_type }) .whereNot({ id }) .update({ is_default: false }); } diff --git a/frontend/components/ListViewLayoutEditor.vue b/frontend/components/ListViewLayoutEditor.vue new file mode 100644 index 0000000..2c24bc1 --- /dev/null +++ b/frontend/components/ListViewLayoutEditor.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/frontend/components/fields/FieldRenderer.vue b/frontend/components/fields/FieldRenderer.vue index 15eace0..6574b23 100644 --- a/frontend/components/fields/FieldRenderer.vue +++ b/frontend/components/fields/FieldRenderer.vue @@ -85,9 +85,31 @@ const formatValue = (val: any): string => { case FieldType.BELONGS_TO: return relationshipDisplayValue.value case FieldType.DATE: - return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString() + try { + const date = val instanceof Date ? val : new Date(val) + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + } catch { + return String(val) + } case FieldType.DATETIME: - return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString() + try { + const date = val instanceof Date ? val : new Date(val) + return date.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + } catch { + return String(val) + } case FieldType.BOOLEAN: return val ? 'Yes' : 'No' case FieldType.CURRENCY: diff --git a/frontend/composables/useFieldViews.ts b/frontend/composables/useFieldViews.ts index 22aae95..eceae00 100644 --- a/frontend/composables/useFieldViews.ts +++ b/frontend/composables/useFieldViews.ts @@ -20,6 +20,9 @@ export const useFields = () => { // Hide system fields and auto-generated fields on edit const shouldHideOnEdit = isSystemField || isAutoGeneratedField + // Hide 'id' field from list view by default (check both apiName and id field) + const shouldHideOnList = fieldDef.apiName === 'id' || fieldDef.label === 'Id' || fieldDef.label === 'ID' + return { id: fieldDef.id, apiName: fieldDef.apiName, @@ -37,7 +40,7 @@ export const useFields = () => { validationRules: fieldDef.validationRules || [], // View options - only hide system and auto-generated fields by default - showOnList: fieldDef.showOnList ?? true, + showOnList: fieldDef.showOnList ?? !shouldHideOnList, showOnDetail: fieldDef.showOnDetail ?? true, showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit, sortable: fieldDef.sortable ?? true, @@ -67,12 +70,36 @@ export const useFields = () => { /** * Build a ListView configuration from object definition + * @param objectDef - The object definition containing fields + * @param customConfig - Optional custom configuration + * @param listLayoutConfig - Optional list view layout configuration from page_layouts */ const buildListViewConfig = ( objectDef: any, - customConfig?: Partial + customConfig?: Partial, + listLayoutConfig?: { fields: Array<{ fieldId: string; order?: number }> } | null ): ListViewConfig => { - const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || [] + let fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || [] + + // If a list layout is provided, filter and order fields according to it + if (listLayoutConfig && listLayoutConfig.fields && listLayoutConfig.fields.length > 0) { + // Sort layout fields by order + const sortedLayoutFields = [...listLayoutConfig.fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + + // Map layout fields to actual field configs, preserving order + const orderedFields: FieldConfig[] = [] + for (const layoutField of sortedLayoutFields) { + const fieldConfig = fields.find((f: FieldConfig) => f.id === layoutField.fieldId) + if (fieldConfig) { + orderedFields.push(fieldConfig) + } + } + + // Use ordered fields if we found any, otherwise fall back to all fields + if (orderedFields.length > 0) { + fields = orderedFields + } + } return { objectApiName: objectDef.apiName, diff --git a/frontend/composables/usePageLayouts.ts b/frontend/composables/usePageLayouts.ts index 114595f..5dc9dcd 100644 --- a/frontend/composables/usePageLayouts.ts +++ b/frontend/composables/usePageLayouts.ts @@ -1,11 +1,13 @@ -import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest } from '~/types/page-layout' +import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest, PageLayoutType } from '~/types/page-layout' export const usePageLayouts = () => { const { api } = useApi() - const getPageLayouts = async (objectId?: string) => { + const getPageLayouts = async (objectId?: string, layoutType?: PageLayoutType) => { try { - const params = objectId ? { objectId } : {} + const params: Record = {} + if (objectId) params.objectId = objectId + if (layoutType) params.layoutType = layoutType const response = await api.get('/page-layouts', { params }) return response } catch (error) { @@ -24,9 +26,11 @@ export const usePageLayouts = () => { } } - const getDefaultPageLayout = async (objectId: string) => { + const getDefaultPageLayout = async (objectId: string, layoutType: PageLayoutType = 'detail') => { try { - const response = await api.get(`/page-layouts/default/${objectId}`) + const response = await api.get(`/page-layouts/default/${objectId}`, { + params: { layoutType } + }) return response } catch (error) { console.error('Error fetching default page layout:', error) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58246e1..acaa9b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1035,7 +1035,7 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -1054,12 +1054,85 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1122,6 +1195,73 @@ } } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/@internationalized/date": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", @@ -3701,7 +3841,7 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4004,6 +4144,14 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "devOptional": true, + "license": "ISC", + "peer": true + }, "node_modules/@unhead/vue": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz", @@ -4822,7 +4970,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4837,6 +4985,24 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/alien-signals": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", @@ -4964,6 +5130,14 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.0", + "peer": true + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -5596,6 +5770,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -6410,6 +6595,14 @@ "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -6600,6 +6793,20 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7027,6 +7234,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", @@ -7586,7 +7851,7 @@ "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -7632,7 +7897,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -7641,11 +7906,112 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", @@ -7663,7 +8029,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -7676,7 +8042,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -7689,7 +8055,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -7705,7 +8071,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -7823,6 +8189,22 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/fast-npm-meta": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/fast-npm-meta/-/fast-npm-meta-0.4.7.tgz", @@ -7858,6 +8240,20 @@ } } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -7876,6 +8272,48 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "devOptional": true, + "license": "ISC", + "peer": true + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8220,7 +8658,7 @@ "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "type-fest": "^0.20.2" @@ -8236,7 +8674,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -8304,7 +8742,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/gridstack": { @@ -8610,6 +9048,35 @@ "integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==", "license": "MIT" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/impound": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/impound/-/impound-1.0.0.tgz", @@ -8629,6 +9096,17 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -9343,6 +9821,20 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9355,6 +9847,14 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -9362,6 +9862,22 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9399,6 +9915,17 @@ "node": ">= 0.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9650,6 +10177,21 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9732,6 +10274,23 @@ "pathe": "^2.0.3" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -9756,6 +10315,14 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -10150,7 +10717,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/negotiator": { @@ -10835,6 +11402,25 @@ "node": ">=8" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -10955,6 +11541,40 @@ "oxc-parser": ">=0.72.0" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -10977,6 +11597,20 @@ "integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==", "license": "MIT" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -11037,7 +11671,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11836,6 +12470,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -11911,6 +12556,17 @@ "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -12504,6 +13160,73 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -13715,6 +14438,20 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -14188,6 +14925,14 @@ "b4a": "^1.6.4" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -14336,6 +15081,20 @@ "node": ">=0.6.x" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", @@ -14473,7 +15232,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14545,7 +15304,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -14990,6 +15749,17 @@ "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "devOptional": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15577,6 +16347,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -15829,6 +16610,20 @@ "node": ">= 4.0.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/youch": { "version": "4.1.0-beta.13", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.13.tgz", diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index d5f1894..0dc4cad 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useApi } from '@/composables/useApi' import { useFields, useViewState } from '@/composables/useFieldViews' +import { usePageLayouts } from '@/composables/usePageLayouts' import ListView from '@/components/views/ListView.vue' import DetailView from '@/components/views/DetailViewEnhanced.vue' import EditView from '@/components/views/EditViewEnhanced.vue' @@ -19,6 +20,7 @@ const route = useRoute() const router = useRouter() const { api } = useApi() const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields() +const { getDefaultPageLayout } = usePageLayouts() // Use breadcrumbs composable const { setBreadcrumbs } = useBreadcrumbs() @@ -40,6 +42,7 @@ const view = computed(() => { // State const objectDefinition = ref(null) +const listViewLayout = ref(null) const loading = ref(true) const error = ref(null) @@ -134,11 +137,13 @@ watch([objectDefinition, currentRecord, recordId], () => { // View configs const listConfig = computed(() => { if (!objectDefinition.value) return null + // Pass the list view layout config to buildListViewConfig if available + const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig return buildListViewConfig(objectDefinition.value, { searchable: true, exportable: true, filterable: true, - }) + }, layoutConfig) }) const detailConfig = computed(() => { @@ -172,6 +177,16 @@ const fetchObjectDefinition = async () => { error.value = null const response = await api.get(`/setup/objects/${objectApiName.value}`) objectDefinition.value = response + + // Fetch the default list view layout for this object + if (response?.id) { + try { + listViewLayout.value = await getDefaultPageLayout(response.id, 'list') + } catch (e) { + // No list view layout configured, will use default behavior + listViewLayout.value = null + } + } } catch (e: any) { error.value = e.message || 'Failed to load object definition' console.error('Error fetching object definition:', e) diff --git a/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue index f568910..b673010 100644 --- a/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useApi } from '@/composables/useApi' import { useFields, useViewState } from '@/composables/useFieldViews' +import { usePageLayouts } from '@/composables/usePageLayouts' import ListView from '@/components/views/ListView.vue' import DetailView from '@/components/views/DetailView.vue' import EditView from '@/components/views/EditView.vue' @@ -11,6 +12,7 @@ const route = useRoute() const router = useRouter() const { api } = useApi() const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields() +const { getDefaultPageLayout } = usePageLayouts() // Get object API name from route const objectApiName = computed(() => route.params.objectName as string) @@ -25,6 +27,7 @@ const view = computed(() => { // State const objectDefinition = ref(null) +const listViewLayout = ref(null) const loading = ref(true) const error = ref(null) @@ -66,11 +69,13 @@ onBeforeUnmount(() => { // View configs const listConfig = computed(() => { if (!objectDefinition.value) return null + // Pass the list view layout config to buildListViewConfig if available + const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig return buildListViewConfig(objectDefinition.value, { searchable: true, exportable: true, filterable: true, - }) + }, layoutConfig) }) const detailConfig = computed(() => { @@ -93,6 +98,16 @@ const fetchObjectDefinition = async () => { error.value = null const response = await api.get(`/setup/objects/${objectApiName.value}`) objectDefinition.value = response + + // Fetch the default list view layout for this object + if (response?.id) { + try { + listViewLayout.value = await getDefaultPageLayout(response.id, 'list') + } catch (e) { + // No list view layout configured, will use default behavior + listViewLayout.value = null + } + } } catch (e: any) { error.value = e.message || 'Failed to load object definition' console.error('Error fetching object definition:', e) diff --git a/frontend/pages/setup/objects/[apiName].vue b/frontend/pages/setup/objects/[apiName].vue index 48229e6..a9507c5 100644 --- a/frontend/pages/setup/objects/[apiName].vue +++ b/frontend/pages/setup/objects/[apiName].vue @@ -16,10 +16,11 @@
- + Fields Access Page Layouts + List View Layouts @@ -148,7 +149,7 @@
Default @@ -185,6 +186,84 @@ />
+ + + +
+
+

List View Layouts

+ +
+ +

+ Configure which fields appear in list views and their order. +

+ +
+ Loading list layouts... +
+ +
+ No list view layouts yet. Create one to customize your list views. +
+ +
+
+
+
+

{{ layout.name }}

+

+ {{ layout.description }} +

+

+ {{ getListLayoutFieldCount(layout) }} fields configured +

+
+
+ + Default + + +
+
+
+
+
+ + +
+
+ +
+ + +
+
@@ -299,6 +378,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import PageLayoutEditor from '@/components/PageLayoutEditor.vue' +import ListViewLayoutEditor from '@/components/ListViewLayoutEditor.vue' import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue' import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue' import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue' @@ -315,11 +395,16 @@ const loading = ref(true) const error = ref(null) const activeTab = ref('fields') -// Page layouts state +// Page layouts state (detail/edit layouts) const layouts = ref([]) const loadingLayouts = ref(false) const selectedLayout = ref(null) +// List view layouts state +const listLayouts = ref([]) +const loadingListLayouts = ref(false) +const selectedListLayout = ref(null) + // Field management state const showFieldDialog = ref(false) const fieldDialogMode = ref<'create' | 'edit'>('create') @@ -420,7 +505,8 @@ const fetchLayouts = async () => { try { loadingLayouts.value = true - layouts.value = await getPageLayouts(object.value.id) + // Fetch only detail layouts (default type) + layouts.value = await getPageLayouts(object.value.id, 'detail') } catch (e: any) { console.error('Error fetching layouts:', e) toast.error('Failed to load page layouts') @@ -429,6 +515,20 @@ const fetchLayouts = async () => { } } +const fetchListLayouts = async () => { + if (!object.value) return + + try { + loadingListLayouts.value = true + listLayouts.value = await getPageLayouts(object.value.id, 'list') + } catch (e: any) { + console.error('Error fetching list layouts:', e) + toast.error('Failed to load list view layouts') + } finally { + loadingListLayouts.value = false + } +} + const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => { fieldDialogMode.value = mode fieldDialogError.value = null @@ -684,6 +784,7 @@ const handleCreateLayout = async () => { const newLayout = await createPageLayout({ name, objectId: object.value.id, + layoutType: 'detail', isDefault: layouts.value.length === 0, layoutConfig: { fields: [], relatedLists: [] }, }) @@ -736,6 +837,73 @@ const handleDeleteLayout = async (layoutId: string) => { } } +// List View Layout methods +const handleCreateListLayout = async () => { + const name = prompt('Enter a name for the new list view layout:') + if (!name) return + + try { + const newLayout = await createPageLayout({ + name, + objectId: object.value.id, + layoutType: 'list', + isDefault: listLayouts.value.length === 0, + layoutConfig: { fields: [] }, + }) + + listLayouts.value.push(newLayout) + selectedListLayout.value = newLayout + toast.success('List view layout created successfully') + } catch (e: any) { + console.error('Error creating list layout:', e) + toast.error('Failed to create list view layout') + } +} + +const handleSelectListLayout = (layout: PageLayout) => { + selectedListLayout.value = layout +} + +const handleSaveListLayout = async (layoutConfig: { fields: FieldLayoutItem[] }) => { + if (!selectedListLayout.value) return + + try { + const updated = await updatePageLayout(selectedListLayout.value.id, { + layoutConfig, + }) + + // Update the layout in the list + const index = listLayouts.value.findIndex(l => l.id === selectedListLayout.value!.id) + if (index !== -1) { + listLayouts.value[index] = updated + } + + selectedListLayout.value = updated + toast.success('List view layout saved successfully') + } catch (e: any) { + console.error('Error saving list layout:', e) + toast.error('Failed to save list view layout') + } +} + +const handleDeleteListLayout = async (layoutId: string) => { + if (!confirm('Are you sure you want to delete this list view layout?')) return + + try { + await deletePageLayout(layoutId) + listLayouts.value = listLayouts.value.filter(l => l.id !== layoutId) + toast.success('List view layout deleted successfully') + } catch (e: any) { + console.error('Error deleting list layout:', e) + toast.error('Failed to delete list view layout') + } +} + +const getListLayoutFieldCount = (layout: PageLayout): number => { + const config = layout.layoutConfig || layout.layout_config + return config?.fields?.length || 0 +} + const handleAccessUpdate = (orgWideDefault: string) => { if (object.value) { object.value.orgWideDefault = orgWideDefault @@ -747,6 +915,9 @@ watch(activeTab, (newTab) => { if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) { fetchLayouts() } + if (newTab === 'listLayouts' && listLayouts.value.length === 0 && !loadingListLayouts.value) { + fetchListLayouts() + } }) onMounted(async () => { @@ -755,5 +926,8 @@ onMounted(async () => { if (activeTab.value === 'layouts') { await fetchLayouts() } + if (activeTab.value === 'listLayouts') { + await fetchListLayouts() + } }) diff --git a/frontend/types/page-layout.ts b/frontend/types/page-layout.ts index 0ed4aab..4d86fb3 100644 --- a/frontend/types/page-layout.ts +++ b/frontend/types/page-layout.ts @@ -1,11 +1,15 @@ export interface FieldLayoutItem { fieldId: string; - x: number; - y: number; - w: number; - h: number; + x?: number; + y?: number; + w?: number; + h?: number; + // For list layouts: field order (optional) + order?: number; } +export type PageLayoutType = 'detail' | 'list'; + export interface PageLayoutConfig { fields: FieldLayoutItem[]; relatedLists?: string[]; @@ -15,16 +19,23 @@ export interface PageLayout { id: string; name: string; objectId: string; + layoutType: PageLayoutType; isDefault: boolean; layoutConfig: PageLayoutConfig; description?: string; createdAt?: string; updatedAt?: string; + // Database column names (snake_case) - used when data comes directly from DB + layout_type?: PageLayoutType; + layout_config?: PageLayoutConfig; + object_id?: string; + is_default?: boolean; } export interface CreatePageLayoutRequest { name: string; objectId: string; + layoutType?: PageLayoutType; isDefault?: boolean; layoutConfig: PageLayoutConfig; description?: string;