From 20fc90a3fbe84da1cd5a92e96ecd7386105a1d6a Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Fri, 16 Jan 2026 18:01:26 +0100 Subject: [PATCH] Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant --- .env.api | 5 + ...001_create_contacts_and_contact_details.js | 207 +++ ...pdate_contact_detail_polymorphic_fields.js | 101 ++ ...003_make_contact_detail_fields_editable.js | 45 + .../20250311000002_add_owner_to_contacts.js | 62 + backend/package-lock.json | 431 +++++- backend/package.json | 4 + backend/prisma/schema.prisma | 32 +- .../ai-assistant/ai-assistant.controller.ts | 41 + .../src/ai-assistant/ai-assistant.module.ts | 14 + .../src/ai-assistant/ai-assistant.service.ts | 1236 +++++++++++++++++ .../src/ai-assistant/ai-assistant.types.ts | 32 + backend/src/ai-assistant/dto/ai-chat.dto.ts | 36 + .../ai-assistant/dto/ai-chat.message.dto.ts | 10 + backend/src/ai-assistant/dto/ai-search.dto.ts | 22 + backend/src/app.module.ts | 2 + backend/src/models/base.model.ts | 35 +- backend/src/models/contact-detail.model.ts | 33 + backend/src/models/contact.model.ts | 30 + backend/src/models/field-definition.model.ts | 2 + backend/src/object/field-mapper.service.ts | 23 + .../object/models/dynamic-model.factory.ts | 95 +- backend/src/object/models/model.registry.ts | 11 +- backend/src/object/models/model.service.ts | 19 + backend/src/object/object.module.ts | 3 +- backend/src/object/object.service.ts | 1177 ++++++++++++++-- .../src/object/runtime-object.controller.ts | 16 + .../src/object/schema-management.service.ts | 124 +- backend/src/object/setup-object.controller.ts | 30 + .../src/page-layout/dto/page-layout.dto.ts | 2 + backend/src/rbac/ability.factory.ts | 5 +- backend/src/rbac/record-sharing.controller.ts | 60 +- backend/src/search/meilisearch.module.ts | 8 + backend/src/search/meilisearch.service.ts | 244 ++++ frontend/components/AIChatBar.vue | 91 +- frontend/components/AppSidebar.vue | 14 +- frontend/components/BottomDrawer.vue | 454 ++++++ frontend/components/PageLayoutEditor.vue | 79 +- frontend/components/PageLayoutRenderer.vue | 13 + frontend/components/RelatedList.vue | 80 +- .../fields/FieldAttributesCommon.vue | 195 +++ .../components/fields/FieldAttributesType.vue | 296 ++++ frontend/components/fields/FieldRenderer.vue | 17 +- .../components/fields/FieldTypeSelector.vue | 140 ++ frontend/components/fields/LookupField.vue | 75 +- frontend/components/ui/checkbox/Checkbox.vue | 2 +- .../components/views/DetailViewEnhanced.vue | 33 +- frontend/components/views/EditView.vue | 11 + .../components/views/EditViewEnhanced.vue | 11 + frontend/components/views/ListView.vue | 172 ++- frontend/composables/useFieldViews.ts | 71 +- frontend/layouts/default.vue | 52 +- .../[objectName]/[[recordId]]/[[view]].vue | 227 ++- frontend/pages/app/[appSlug]/[pageSlug].vue | 7 +- .../[objectName]/[[recordId]]/[[view]].vue | 77 +- .../central/domains/[[recordId]]/[[view]].vue | 1 + .../central/tenants/[[recordId]]/[[view]].vue | 1 + .../central/users/[[recordId]]/[[view]].vue | 1 + frontend/pages/setup/objects/[apiName].vue | 552 +++++++- frontend/types/field-types.ts | 5 + frontend/types/page-layout.ts | 1 + infra/docker-compose.yml | 16 + 62 files changed, 6613 insertions(+), 278 deletions(-) create mode 100644 backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js create mode 100644 backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js create mode 100644 backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js create mode 100644 backend/migrations/tenant/20250311000002_add_owner_to_contacts.js create mode 100644 backend/src/ai-assistant/ai-assistant.controller.ts create mode 100644 backend/src/ai-assistant/ai-assistant.module.ts create mode 100644 backend/src/ai-assistant/ai-assistant.service.ts create mode 100644 backend/src/ai-assistant/ai-assistant.types.ts create mode 100644 backend/src/ai-assistant/dto/ai-chat.dto.ts create mode 100644 backend/src/ai-assistant/dto/ai-chat.message.dto.ts create mode 100644 backend/src/ai-assistant/dto/ai-search.dto.ts create mode 100644 backend/src/models/contact-detail.model.ts create mode 100644 backend/src/models/contact.model.ts create mode 100644 backend/src/search/meilisearch.module.ts create mode 100644 backend/src/search/meilisearch.service.ts create mode 100644 frontend/components/BottomDrawer.vue create mode 100644 frontend/components/fields/FieldAttributesCommon.vue create mode 100644 frontend/components/fields/FieldAttributesType.vue create mode 100644 frontend/components/fields/FieldTypeSelector.vue diff --git a/.env.api b/.env.api index 4e9c444..83d8de4 100644 --- a/.env.api +++ b/.env.api @@ -5,6 +5,11 @@ DATABASE_URL="mysql://platform:platform@db:3306/platform" CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform" REDIS_URL="redis://redis:6379" +# Meilisearch (optional) +MEILI_HOST="http://meilisearch:7700" +MEILI_API_KEY="dev-meili-master-key" +MEILI_INDEX_PREFIX="tenant_" + # JWT, multi-tenant hints, etc. JWT_SECRET="devsecret" TENANCY_STRATEGY="single-db" diff --git a/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js b/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js new file mode 100644 index 0000000..1b4176d --- /dev/null +++ b/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js @@ -0,0 +1,207 @@ +exports.up = async function (knex) { + await knex.schema.createTable('contacts', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); + table.string('firstName', 100).notNullable(); + table.string('lastName', 100).notNullable(); + table.uuid('accountId').notNullable(); + table.timestamps(true, true); + + table + .foreign('accountId') + .references('id') + .inTable('accounts') + .onDelete('CASCADE'); + table.index(['accountId']); + table.index(['lastName', 'firstName']); + }); + + await knex.schema.createTable('contact_details', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); + table.string('relatedObjectType', 100).notNullable(); + table.uuid('relatedObjectId').notNullable(); + table.string('detailType', 50).notNullable(); + table.string('label', 100); + table.text('value').notNullable(); + table.boolean('isPrimary').defaultTo(false); + table.timestamps(true, true); + + table.index(['relatedObjectType', 'relatedObjectId']); + table.index(['detailType']); + }); + + const [contactObjectId] = await knex('object_definitions').insert({ + id: knex.raw('(UUID())'), + apiName: 'Contact', + label: 'Contact', + pluralLabel: 'Contacts', + description: 'Standard Contact object', + isSystem: true, + isCustom: false, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + + const contactObjectDefId = + contactObjectId || + (await knex('object_definitions').where('apiName', 'Contact').first()).id; + + await knex('field_definitions').insert([ + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactObjectDefId, + apiName: 'firstName', + label: 'First Name', + type: 'String', + length: 100, + isRequired: true, + isSystem: true, + isCustom: false, + displayOrder: 1, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactObjectDefId, + apiName: 'lastName', + label: 'Last Name', + type: 'String', + length: 100, + isRequired: true, + isSystem: true, + isCustom: false, + displayOrder: 2, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactObjectDefId, + apiName: 'accountId', + label: 'Account', + type: 'Reference', + referenceObject: 'Account', + isRequired: true, + isSystem: true, + isCustom: false, + displayOrder: 3, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + ]); + + const [contactDetailObjectId] = await knex('object_definitions').insert({ + id: knex.raw('(UUID())'), + apiName: 'ContactDetail', + label: 'Contact Detail', + pluralLabel: 'Contact Details', + description: 'Polymorphic contact detail object', + isSystem: true, + isCustom: false, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + + const contactDetailObjectDefId = + contactDetailObjectId || + (await knex('object_definitions').where('apiName', 'ContactDetail').first()) + .id; + + const contactDetailRelationObjects = ['Account', 'Contact'] + + await knex('field_definitions').insert([ + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'relatedObjectType', + label: 'Related Object Type', + type: 'PICKLIST', + length: 100, + isRequired: true, + isSystem: false, + isCustom: false, + displayOrder: 1, + ui_metadata: JSON.stringify({ + options: contactDetailRelationObjects.map((value) => ({ label: value, value })), + }), + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'relatedObjectId', + label: 'Related Object ID', + type: 'LOOKUP', + length: 36, + isRequired: true, + isSystem: false, + isCustom: false, + displayOrder: 2, + ui_metadata: JSON.stringify({ + relationObjects: contactDetailRelationObjects, + relationTypeField: 'relatedObjectType', + relationDisplayField: 'name', + }), + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'detailType', + label: 'Detail Type', + type: 'String', + length: 50, + isRequired: true, + isSystem: false, + isCustom: false, + displayOrder: 3, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'label', + label: 'Label', + type: 'String', + length: 100, + isSystem: false, + isCustom: false, + displayOrder: 4, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'value', + label: 'Value', + type: 'Text', + isRequired: true, + isSystem: false, + isCustom: false, + displayOrder: 5, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + { + id: knex.raw('(UUID())'), + objectDefinitionId: contactDetailObjectDefId, + apiName: 'isPrimary', + label: 'Primary', + type: 'Boolean', + isSystem: false, + isCustom: false, + displayOrder: 6, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }, + ]); +}; + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('contact_details'); + await knex.schema.dropTableIfExists('contacts'); +}; diff --git a/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js b/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js new file mode 100644 index 0000000..bea8f3c --- /dev/null +++ b/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js @@ -0,0 +1,101 @@ +exports.up = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + const relationObjects = ['Account', 'Contact']; + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectType', + }) + .update({ + type: 'PICKLIST', + length: 100, + isSystem: false, + ui_metadata: JSON.stringify({ + options: relationObjects.map((value) => ({ label: value, value })), + }), + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectId', + }) + .update({ + type: 'LOOKUP', + length: 36, + isSystem: false, + ui_metadata: JSON.stringify({ + relationObjects, + relationTypeField: 'relatedObjectType', + relationDisplayField: 'name', + }), + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .whereIn('apiName', [ + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .andWhere({ objectDefinitionId: contactDetailObject.id }) + .update({ + isSystem: false, + updated_at: knex.fn.now(), + }); +}; + +exports.down = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectType', + }) + .update({ + type: 'String', + length: 100, + isSystem: true, + ui_metadata: null, + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectId', + }) + .update({ + type: 'String', + length: 36, + isSystem: true, + ui_metadata: null, + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .whereIn('apiName', [ + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .andWhere({ objectDefinitionId: contactDetailObject.id }) + .update({ + isSystem: true, + updated_at: knex.fn.now(), + }); +}; diff --git a/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js b/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js new file mode 100644 index 0000000..f9e4d5d --- /dev/null +++ b/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js @@ -0,0 +1,45 @@ +exports.up = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ objectDefinitionId: contactDetailObject.id }) + .whereIn('apiName', [ + 'relatedObjectType', + 'relatedObjectId', + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .update({ + isSystem: false, + updated_at: knex.fn.now(), + }); +}; + +exports.down = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ objectDefinitionId: contactDetailObject.id }) + .whereIn('apiName', [ + 'relatedObjectType', + 'relatedObjectId', + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .update({ + isSystem: true, + updated_at: knex.fn.now(), + }); +}; diff --git a/backend/migrations/tenant/20250311000002_add_owner_to_contacts.js b/backend/migrations/tenant/20250311000002_add_owner_to_contacts.js new file mode 100644 index 0000000..cf45d12 --- /dev/null +++ b/backend/migrations/tenant/20250311000002_add_owner_to_contacts.js @@ -0,0 +1,62 @@ +exports.up = async function (knex) { + // Add ownerId column to contacts + await knex.schema.alterTable('contacts', (table) => { + table.uuid('ownerId'); + table + .foreign('ownerId') + .references('id') + .inTable('users') + .onDelete('SET NULL'); + table.index(['ownerId']); + }); + + // Add ownerId field definition metadata for Contact object + const contactObject = await knex('object_definitions') + .where('apiName', 'Contact') + .first(); + + if (contactObject) { + const existingField = await knex('field_definitions') + .where({ + objectDefinitionId: contactObject.id, + apiName: 'ownerId', + }) + .first(); + + if (!existingField) { + await knex('field_definitions').insert({ + id: knex.raw('(UUID())'), + objectDefinitionId: contactObject.id, + apiName: 'ownerId', + label: 'Owner', + type: 'Reference', + referenceObject: 'User', + isSystem: true, + isCustom: false, + displayOrder: 4, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } +}; + +exports.down = async function (knex) { + const contactObject = await knex('object_definitions') + .where('apiName', 'Contact') + .first(); + + if (contactObject) { + await knex('field_definitions') + .where({ + objectDefinitionId: contactObject.id, + apiName: 'ownerId', + }) + .delete(); + } + + await knex.schema.alterTable('contacts', (table) => { + table.dropForeign(['ownerId']); + table.dropColumn('ownerId'); + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index e5d76be..a3299e1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,9 @@ "dependencies": { "@casl/ability": "^6.7.5", "@fastify/websocket": "^10.0.1", + "@langchain/core": "^1.1.12", + "@langchain/langgraph": "^1.0.15", + "@langchain/openai": "^1.2.1", "@nestjs/bullmq": "^10.1.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", @@ -28,6 +31,7 @@ "class-validator": "^0.14.1", "ioredis": "^5.3.2", "knex": "^3.1.0", + "langchain": "^1.2.7", "mysql2": "^3.15.3", "objection": "^3.1.5", "openai": "^6.15.0", @@ -762,6 +766,12 @@ "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1679,6 +1689,220 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/core": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz", + "integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.4.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@langchain/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/core/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/langgraph": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.0.15.tgz", + "integrity": "sha512-l7/f255sPilanhyY+lbX+VDXQSnytFwJ4FVoEl4OBpjDoCHuDyHUL5yrb568apBSHgQA7aKsYac0mBEqIR5Bjg==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.0.0", + "@langchain/langgraph-sdk": "~1.5.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.0.1", + "zod": "^3.25.32 || ^4.1.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", + "integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==", + "license": "MIT", + "dependencies": { + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.0.1" + } + }, + "node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.2.tgz", + "integrity": "sha512-ArRnYIqJEUKnS+HFZoTtsIy2Uxy158l5ZTPWNhJkws6FuDEA3q/h6bhvHpZIf5z0JseDHCCoIbx6yOc2RpMpgg==", + "license": "MIT", + "dependencies": { + "p-queue": "^9.0.1", + "p-retry": "^7.1.1", + "uuid": "^13.0.0" + }, + "peerDependencies": { + "@langchain/core": "^1.0.1", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/@langchain/langgraph/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/openai": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.1.tgz", + "integrity": "sha512-eZYPhvXIwz0/8iCjj2LWqeaznQ7DZ6tBdvF+Ebv4sQW2UqJWZqRC8QIdKZgTbs8ffMWPHkSSOidYqu4XfWCNYg==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^6.10.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -2818,6 +3042,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -3711,7 +3941,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4337,6 +4566,15 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/console-table-printer": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.1.2" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4485,6 +4723,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -5150,6 +5397,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6389,6 +6642,18 @@ "node": ">=8" } }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7279,6 +7544,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7536,6 +7810,85 @@ "node": ">=8" } }, + "node_modules/langchain": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz", + "integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph": "^1.0.0", + "@langchain/langgraph-checkpoint": "^1.0.0", + "langsmith": ">=0.4.0 <1.0.0", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "1.1.12" + } + }, + "node_modules/langchain/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/langsmith": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz", + "integrity": "sha512-9N4JSQLz6fWiZwVXaiy0erlvNHlC68EtGJZG2OX+1y9mqj7KvKSL+xJnbCFc+ky3JN8s1d6sCfyyDdi4uDdLnQ==", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, + "node_modules/langsmith/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8037,6 +8390,15 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -8438,6 +8800,15 @@ "node": ">=0.10.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8470,6 +8841,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -9617,6 +10031,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11086,6 +11506,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 6f756c1..83e842a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,9 @@ "dependencies": { "@casl/ability": "^6.7.5", "@fastify/websocket": "^10.0.1", + "@langchain/core": "^1.1.12", + "@langchain/langgraph": "^1.0.15", + "@langchain/openai": "^1.2.1", "@nestjs/bullmq": "^10.1.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", @@ -45,6 +48,7 @@ "class-validator": "^0.14.1", "ioredis": "^5.3.2", "knex": "^3.1.0", + "langchain": "^1.2.7", "mysql2": "^3.15.3", "objection": "^3.1.5", "openai": "^6.15.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cecfd56..4e53e58 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -145,12 +145,42 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id]) + owner User @relation(fields: [ownerId], references: [id]) + contacts Contact[] @@index([ownerId]) @@map("accounts") } +model Contact { + id String @id @default(uuid()) + firstName String + lastName String + accountId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@map("contacts") +} + +model ContactDetail { + id String @id @default(uuid()) + relatedObjectType String + relatedObjectId String + detailType String + label String? + value String + isPrimary Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([relatedObjectType, relatedObjectId]) + @@map("contact_details") +} + // Application Builder model App { id String @id @default(uuid()) diff --git a/backend/src/ai-assistant/ai-assistant.controller.ts b/backend/src/ai-assistant/ai-assistant.controller.ts new file mode 100644 index 0000000..8fdb6ef --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantId } from '../tenant/tenant.decorator'; +import { AiAssistantService } from './ai-assistant.service'; +import { AiChatRequestDto } from './dto/ai-chat.dto'; +import { AiSearchRequestDto } from './dto/ai-search.dto'; + +@Controller('ai') +@UseGuards(JwtAuthGuard) +export class AiAssistantController { + constructor(private readonly aiAssistantService: AiAssistantService) {} + + @Post('chat') + async chat( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() payload: AiChatRequestDto, + ) { + return this.aiAssistantService.handleChat( + tenantId, + user.userId, + payload.message, + payload.history, + payload.context, + ); + } + + @Post('search') + async search( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() payload: AiSearchRequestDto, + ) { + return this.aiAssistantService.searchRecords( + tenantId, + user.userId, + payload, + ); + } +} diff --git a/backend/src/ai-assistant/ai-assistant.module.ts b/backend/src/ai-assistant/ai-assistant.module.ts new file mode 100644 index 0000000..33473db --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AiAssistantController } from './ai-assistant.controller'; +import { AiAssistantService } from './ai-assistant.service'; +import { ObjectModule } from '../object/object.module'; +import { PageLayoutModule } from '../page-layout/page-layout.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { MeilisearchModule } from '../search/meilisearch.module'; + +@Module({ + imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule], + controllers: [AiAssistantController], + providers: [AiAssistantService], +}) +export class AiAssistantModule {} diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts new file mode 100644 index 0000000..80cbfb0 --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -0,0 +1,1236 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { JsonOutputParser } from '@langchain/core/output_parsers'; +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { ChatOpenAI } from '@langchain/openai'; +import { Annotation, END, START, StateGraph } from '@langchain/langgraph'; +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 { MeilisearchService } from '../search/meilisearch.service'; + +type AiSearchFilter = { + field: string; + operator: string; + value?: any; + values?: any[]; + from?: string; + to?: string; +}; + +type AiSearchPlan = { + strategy: 'keyword' | 'query'; + explanation: string; + keyword?: string | null; + filters?: AiSearchFilter[]; + sort?: { field: string; direction: 'asc' | 'desc' } | null; +}; + +type AiSearchPayload = { + objectApiName: string; + query: string; + page?: number; + pageSize?: number; +}; + +@Injectable() +export class AiAssistantService { + private readonly logger = new Logger(AiAssistantService.name); + private readonly defaultModel = process.env.OPENAI_MODEL || 'gpt-4o'; + private readonly conversationState = new Map< + string, + { fields: Record; updatedAt: number } + >(); + private readonly conversationTtlMs = 30 * 60 * 1000; // 30 minutes + + constructor( + private readonly objectService: ObjectService, + private readonly pageLayoutService: PageLayoutService, + private readonly tenantDbService: TenantDatabaseService, + private readonly meilisearchService: MeilisearchService, + ) {} + + async handleChat( + tenantId: string, + userId: string, + message: string, + history: AiAssistantState['history'], + context: AiAssistantState['context'], + ): Promise { + this.pruneConversations(); + const conversationKey = this.getConversationKey( + tenantId, + userId, + context?.objectApiName, + ); + 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, + }; + + const finalState = await this.runAssistantGraph(tenantId, userId, initialState); + + if (finalState.record) { + this.conversationState.delete(conversationKey); + } else if (finalState.extractedFields && Object.keys(finalState.extractedFields).length > 0) { + this.conversationState.set(conversationKey, { + fields: finalState.extractedFields, + updatedAt: Date.now(), + }); + } + + return { + reply: finalState.reply || 'How can I help?', + action: finalState.action, + missingFields: finalState.missingFields, + record: finalState.record, + }; + } + + async searchRecords( + tenantId: string, + userId: string, + payload: AiSearchPayload, + ) { + const queryText = payload?.query?.trim(); + if (!payload?.objectApiName || !queryText) { + throw new BadRequestException('objectApiName and query are required'); + } + + // Normalize tenant ID so Meilisearch index names align with indexed records + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + + const objectDefinition = await this.objectService.getObjectDefinition( + resolvedTenantId, + payload.objectApiName, + ); + if (!objectDefinition) { + throw new BadRequestException(`Object ${payload.objectApiName} not found`); + } + + const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1; + const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20; + + const plan = await this.buildSearchPlan( + resolvedTenantId, + queryText, + objectDefinition, + ); + + console.log('AI search plan:', plan); + + if (plan.strategy === 'keyword') { + + console.log('AI search plan (keyword):', plan); + + const keyword = plan.keyword?.trim() || queryText; + if (this.meilisearchService.isEnabled()) { + const offset = (page - 1) * pageSize; + const meiliResults = await this.meilisearchService.searchRecords( + resolvedTenantId, + payload.objectApiName, + keyword, + { limit: pageSize, offset }, + ); + + console.log('Meilisearch results:', meiliResults); + + const ids = meiliResults.hits + .map((hit: any) => hit?.id) + .filter(Boolean); + + const records = ids.length + ? await this.objectService.searchRecordsByIds( + resolvedTenantId, + payload.objectApiName, + userId, + ids, + { page, pageSize }, + ) + : { data: [], totalCount: 0, page, pageSize }; + + return { + ...records, + totalCount: meiliResults.total ?? records.totalCount ?? 0, + strategy: plan.strategy, + explanation: plan.explanation, + }; + } + + const fallback = await this.objectService.searchRecordsByKeyword( + resolvedTenantId, + payload.objectApiName, + userId, + keyword, + { page, pageSize }, + ); + return { + ...fallback, + strategy: plan.strategy, + explanation: plan.explanation, + }; + } + + console.log('AI search plan (query):', plan); + + const filtered = await this.objectService.searchRecordsWithFilters( + resolvedTenantId, + payload.objectApiName, + userId, + plan.filters || [], + { page, pageSize }, + plan.sort || undefined, + ); + + return { + ...filtered, + strategy: plan.strategy, + explanation: plan.explanation, + }; + } + + private async runAssistantGraph( + tenantId: string, + userId: string, + state: AiAssistantState, + ): Promise { + const AssistantState = Annotation.Root({ + message: Annotation(), + history: Annotation(), + context: Annotation(), + objectDefinition: Annotation(), + pageLayout: Annotation(), + extractedFields: Annotation>(), + requiredFields: Annotation(), + missingFields: Annotation(), + action: Annotation(), + record: 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); + + const graph = workflow.compile(); + return graph.invoke(state); + } + + private async loadContext( + tenantId: string, + state: AiAssistantState, + ): Promise { + const objectApiName = state.context?.objectApiName; + console.log('Here:'); + console.log(objectApiName); + if (!objectApiName) { + return { + ...state, + action: 'clarify', + reply: 'Tell me which object you want to work with, for example: "Add an account named Cloudflare."', + }; + } + + const objectDefinition = await this.objectService.getObjectDefinition( + tenantId, + objectApiName, + ); + if (!objectDefinition) { + return { + ...state, + action: 'clarify', + reply: `I could not find an object named "${objectApiName}". Which object should I use?`, + }; + } + + const pageLayout = await this.pageLayoutService.findDefaultByObject( + tenantId, + objectDefinition.id, + ); + + return { + ...state, + objectDefinition, + pageLayout, + history: state.history, + }; + } + + private async extractFields( + tenantId: string, + state: AiAssistantState, + ): Promise { + if (!state.objectDefinition) { + return state; + } + + const openAiConfig = await this.getOpenAiConfig(tenantId); + const fieldDefinitions = (state.objectDefinition.fields || []).filter( + (field: any) => !this.isSystemField(field.apiName), + ); + + if (!openAiConfig) { + this.logger.warn('No OpenAI config found; using heuristic extraction.'); + } + + const newExtraction = openAiConfig + ? await this.extractWithOpenAI( + openAiConfig, + state.message, + state.objectDefinition.label, + fieldDefinitions, + ) + : this.extractWithHeuristics(state.message, fieldDefinitions); + const mergedExtraction = this.enrichPolymorphicLookupFromMessage( + state.message, + state.objectDefinition, + { + ...(state.extractedFields || {}), + ...(newExtraction || {}), + }, + ); + + return { + ...state, + extractedFields: mergedExtraction, + history: state.history, + }; + } + + private decideNextStep(state: AiAssistantState): AiAssistantState { + if (!state.objectDefinition) { + return state; + } + + console.log('extracated:',state.extractedFields); + + const fieldDefinitions = (state.objectDefinition.fields || []).filter( + (field: any) => !this.isSystemField(field.apiName), + ); + const requiredFields = this.getRequiredFields(fieldDefinitions); + const missingFields = requiredFields.filter( + (fieldApiName) => !state.extractedFields?.[fieldApiName], + ); + + if (missingFields.length > 0) { + return { + ...state, + requiredFields, + missingFields, + action: 'collect_fields', + }; + } + + return { + ...state, + requiredFields, + missingFields: [], + action: 'create_record', + }; + } + + private enrichPolymorphicLookupFromMessage( + message: string, + objectDefinition: any, + extracted: Record, + ): Record { + if (!objectDefinition || !this.isContactDetail(objectDefinition.apiName)) { + return extracted; + } + + if (extracted.relatedObjectId) return extracted; + + const match = message.match(/(?:to|for)\s+([^.,;]+)$/i); + if (!match?.[1]) return extracted; + + const candidateName = match[1].trim(); + if (!candidateName) return extracted; + + const lowerMessage = message.toLowerCase(); + const preferredType = lowerMessage.includes('account') + ? 'Account' + : lowerMessage.includes('contact') + ? 'Contact' + : undefined; + + return { + ...extracted, + relatedObjectId: candidateName, + ...(preferredType ? { relatedObjectType: preferredType } : {}), + }; + } + + private async createRecord( + tenantId: string, + userId: string, + state: AiAssistantState, + ): Promise { + if (!state.objectDefinition || !state.extractedFields) { + return { + ...state, + action: 'clarify', + reply: 'I could not infer the record details. Can you provide the fields you want to set?' + }; + } + + const enrichedState = await this.resolvePolymorphicRelatedObject( + tenantId, + this.applyPolymorphicDefaults(state), + ); + + const { + resolvedFields, + unresolvedLookups, + } = await this.resolveLookupFields( + tenantId, + enrichedState.objectDefinition, + enrichedState.extractedFields, + ); + + if (unresolvedLookups.length > 0) { + const missingText = unresolvedLookups + .map( + (lookup) => + `${lookup.fieldLabel || lookup.fieldApiName} (value "${lookup.providedValue}") for ${lookup.targetLabel || 'the related record'}`, + ) + .join('; '); + + return { + ...state, + action: 'collect_fields', + reply: `I couldn't find these related records: ${missingText}. Please provide an existing record name or ID for each.`, + }; + } + + if (this.isContactDetail(enrichedState.objectDefinition.apiName)) { + const hasId = !!resolvedFields.relatedObjectId; + const hasType = !!resolvedFields.relatedObjectType; + if (!hasId || !hasType) { + return { + ...enrichedState, + action: 'collect_fields', + reply: + 'I need which record this contact detail belongs to. Please provide the related Contact or Account name/ID.', + history: state.history, + }; + } + } + + const record = await this.objectService.createRecord( + tenantId, + enrichedState.objectDefinition.apiName, + resolvedFields, + userId, + ); + + const nameValue = enrichedState.extractedFields.name || record?.name || record?.id; + const label = enrichedState.objectDefinition.label || enrichedState.objectDefinition.apiName; + + return { + ...enrichedState, + record, + action: 'create_record', + reply: `Created ${label} ${nameValue ? `"${nameValue}"` : 'record'} successfully.`, + history: state.history, + }; + } + + private respondWithMissingFields(state: AiAssistantState): AiAssistantState { + if (!state.objectDefinition) { + return state; + } + + const label = state.objectDefinition.label || state.objectDefinition.apiName; + const orderedMissing = this.orderMissingFields(state); + const missingLabels = orderedMissing.map( + (apiName) => this.getFieldLabel(state.objectDefinition.fields || [], apiName), + ); + + return { + ...state, + action: 'collect_fields', + reply: `To create a ${label}, I still need: ${missingLabels.join(', ')}.`, + history: state.history, + }; + } + + private orderMissingFields(state: AiAssistantState): string[] { + if (!state.pageLayout || !state.missingFields) { + return state.missingFields || []; + } + + const layoutConfig = this.parseLayoutConfig(state.pageLayout.layout_config); + const layoutFieldIds: string[] = layoutConfig?.fields?.map((field: any) => field.fieldId) || []; + const fieldIdToApiName = new Map( + (state.objectDefinition.fields || []).map((field: any) => [field.id, field.apiName]), + ); + + const ordered = layoutFieldIds + .map((fieldId) => fieldIdToApiName.get(fieldId)) + .filter((apiName): apiName is string => Boolean(apiName)) + .filter((apiName) => state.missingFields?.includes(apiName)); + + console.log('ordered:',ordered); + + const remaining = (state.missingFields || []).filter( + (apiName) => !ordered.includes(apiName), + ); + + console.log('remaining:',remaining); + + return [...ordered, ...remaining]; + } + + private getRequiredFields(fieldDefinitions: any[]): string[] { + const required = fieldDefinitions + .filter((field) => field.isRequired) + .map((field) => field.apiName); + + const hasNameField = fieldDefinitions.some((field) => field.apiName === 'name'); + if (hasNameField && !required.includes('name')) { + required.unshift('name'); + } + + return Array.from(new Set(required)); + } + + private async extractWithOpenAI( + openAiConfig: OpenAIConfig, + message: string, + objectLabel: string, + fieldDefinitions: any[], + didRetry = false, + ): Promise> { + + console.log('Using OpenAI extraction for message:', message); + + try { + const model = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.2, + }); + const fieldDescriptions = fieldDefinitions.map((field) => { + return `${field.label} (${field.apiName}, type: ${field.type})`; + }); + + console.log('fieldDescriptions:',fieldDescriptions); + + const parser = new JsonOutputParser>(); + const response = await model.invoke([ + new SystemMessage( + `You extract field values to create a ${objectLabel} record.` + + '\n- Return JSON only with keys: action, fields.' + + '\n- Use action "create_record" when the user wants to add or create.' + + '\n- Use ONLY apiName keys exactly as provided (case-sensitive). NEVER use labels or other keys.' + + '\n- Prefer values from the latest user turn, but keep earlier user-provided values in the same conversation for missing fields.' + + '\n- If a field value is provided, include it even if it looks custom; do not drop custom fields.' + + '\n- Avoid guessing fields that were not mentioned.' + + '\n- Example: {"action":"create_record","fields":{"apiName1":"value"}}', + ), + new HumanMessage( + `Fields: ${fieldDescriptions.join('; ')}.\nUser message: ${message}`, + ), + ]); + + console.log('respomse:', response); + + const content = typeof response.content === 'string' ? response.content : '{}'; + const parsed = await parser.parse(content); + const rawFields = parsed.fields || {}; + const normalizedFields = this.normalizeExtractedFieldKeys(rawFields, fieldDefinitions); + const sanitizedFields = this.sanitizeUserOwnerFields( + normalizedFields, + fieldDefinitions, + message, + ); + + return Object.fromEntries( + Object.entries(sanitizedFields).filter(([apiName]) => + fieldDefinitions.some((field) => field.apiName === apiName), + ), + ); + } catch (error) { + const messageText = error?.message || ''; + const shouldRetryWithDefault = + !didRetry && + (messageText.includes('not a chat model') || + messageText.includes('MODEL_NOT_FOUND') || + messageText.includes('404')); + + if (shouldRetryWithDefault) { + this.logger.warn( + `OpenAI extraction failed with model "${openAiConfig.model}". Retrying with gpt-4o-mini. Error: ${messageText}`, + ); + return this.extractWithOpenAI( + { ...openAiConfig, model: 'gpt-4o-mini' }, + message, + objectLabel, + fieldDefinitions, + true, + ); + } + + this.logger.warn(`OpenAI extraction failed: ${messageText}`); + return this.extractWithHeuristics(message, fieldDefinitions); + } + } + + private extractWithHeuristics( + message: string, + fieldDefinitions: any[], + ): Record { + const extracted: Record = {}; + const lowerMessage = message.toLowerCase(); + + console.log('Heuristic extraction for message:', message); + + const nameField = fieldDefinitions.find( + (field) => field.apiName === 'name' || field.label.toLowerCase() === 'name', + ); + const phoneField = fieldDefinitions.find((field) => + field.apiName.toLowerCase().includes('phone') || field.label.toLowerCase().includes('phone'), + ); + + // Generic pattern matching for any field: "label: value" or "set label to value" + for (const field of fieldDefinitions) { + const value = this.extractValueForField(message, field); + if (value) { + extracted[field.apiName] = value; + } + } + + if (nameField) { + const nameMatch = message.match(/add\s+([^\n]+?)(?:\s+with\s+|$)/i); + if (nameMatch?.[1]) { + extracted[nameField.apiName] = nameMatch[1].trim(); + } + } + + if (phoneField) { + const phoneMatch = message.match(/phone\s+([\d+().\s-]+)/i); + if (phoneMatch?.[1]) { + extracted[phoneField.apiName] = phoneMatch[1].trim(); + } + } + + if (Object.keys(extracted).length === 0 && lowerMessage.startsWith('add ') && nameField) { + extracted[nameField.apiName] = message.replace(/^add\s+/i, '').trim(); + } + + return extracted; + } + + private async buildSearchPlan( + tenantId: string, + message: string, + objectDefinition: any, + ): Promise { + const openAiConfig = await this.getOpenAiConfig(tenantId); + if (!openAiConfig) { + return this.buildSearchPlanFallback(message); + } + + try { + return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition); + } catch (error) { + this.logger.warn(`AI search planning failed: ${error.message}`); + return this.buildSearchPlanFallback(message); + } + } + + private buildSearchPlanFallback(message: string): AiSearchPlan { + const trimmed = message.trim(); + return { + strategy: 'keyword', + keyword: trimmed, + explanation: `Searched records that matches the word: "${trimmed}"`, + }; + } + + private async buildSearchPlanWithAi( + openAiConfig: OpenAIConfig, + message: string, + objectDefinition: any, + ): Promise { + const model = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.2, + }); + + const parser = new JsonOutputParser(); + const fields = (objectDefinition.fields || []).map((field: any) => ({ + apiName: field.apiName, + label: field.label, + type: field.type, + })); + + const formatInstructions = parser.getFormatInstructions(); + const today = new Date().toISOString(); + + const response = await model.invoke([ + new SystemMessage( + `You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` + + `\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` + + `\n- strategy must be "keyword" or "query".` + + `\n- explanation must be a short sentence explaining the approach.` + + `\n- keyword should be the search term when strategy is "keyword", otherwise null.` + + `\n- filters is an array of {field, operator, value, values, from, to}.` + + `\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` + + `\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` + + `\n- sort should be {field, direction} when sorting is requested.` + + `\n- Only use field apiName values exactly as provided.` + + `\n${formatInstructions}`, + ), + new HumanMessage( + `Object: ${objectDefinition.label || objectDefinition.apiName}.\n` + + `Fields: ${JSON.stringify(fields)}.\n` + + `Today is ${today}.\n` + + `User query: ${message}`, + ), + ]); + + const content = typeof response.content === 'string' ? response.content : '{}'; + const parsed = await parser.parse(content); + return this.normalizeSearchPlan(parsed, message); + } + + private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan { + if (!plan || typeof plan !== 'object') { + return this.buildSearchPlanFallback(message); + } + + const strategy = plan.strategy === 'query' ? 'query' : 'keyword'; + const explanation = plan.explanation?.trim() + ? plan.explanation.trim() + : strategy === 'keyword' + ? `Searched records that matches the word: "${message.trim()}"` + : `Applied filters based on: "${message.trim()}"`; + + if (strategy === 'keyword') { + return { + strategy, + keyword: plan.keyword?.trim() || message.trim(), + explanation, + }; + } + + return { + strategy, + explanation, + keyword: null, + filters: Array.isArray(plan.filters) ? plan.filters : [], + sort: plan.sort || null, + }; + } + + private sanitizeUserOwnerFields( + fields: Record, + fieldDefinitions: any[], + message: string, + ): Record { + const mentionsAssignment = /\b(user|owner|assign|assigned)\b/i.test(message); + const defsByApi = new Map(fieldDefinitions.map((f: any) => [f.apiName, f])); + + const result: Record = {}; + for (const [apiName, value] of Object.entries(fields || {})) { + const def = defsByApi.get(apiName); + const label = def?.label || apiName; + const isUserish = /\b(user|owner)\b/i.test(label); + + if (isUserish && !mentionsAssignment) { + // Skip auto-assigned "User"/"Owner" when the user didn't mention assignment + continue; + } + + result[apiName] = value; + } + + return result; + } + + private normalizeExtractedFieldKeys( + fields: Record, + fieldDefinitions: any[], + ): Record { + if (!fields) return {}; + const apiNames = new Map( + (fieldDefinitions || []).map((f: any) => [f.apiName.toLowerCase(), f.apiName]), + ); + + const result: Record = {}; + for (const [key, value] of Object.entries(fields)) { + const canonical = apiNames.get(key.toLowerCase()); + if (canonical) { + result[canonical] = value; + } + } + return result; + } + + private applyPolymorphicDefaults(state: AiAssistantState): AiAssistantState { + if (!state.objectDefinition || !state.extractedFields) return state; + + const apiName = String(state.objectDefinition.apiName || '').toLowerCase(); + if (!this.isContactDetail(apiName)) { + return state; + } + + const updatedFields = { ...(state.extractedFields || {}) }; + + if (!updatedFields.relatedObjectId && state.context?.recordId) { + updatedFields.relatedObjectId = state.context.recordId; + } + + if (!updatedFields.relatedObjectType && state.context?.objectApiName) { + const type = this.toPolymorphicType(state.context.objectApiName); + if (type) { + updatedFields.relatedObjectType = type; + } + } + + return { + ...state, + extractedFields: updatedFields, + }; + } + + private toPolymorphicType(objectApiName: string): string | null { + const normalized = objectApiName.toLowerCase(); + if (normalized === 'account' || normalized === 'accounts') return 'Account'; + if (normalized === 'contact' || normalized === 'contacts') return 'Contact'; + return null; + } + + private isContactDetail(objectApiName: string | undefined): boolean { + if (!objectApiName) return false; + const normalized = objectApiName.toLowerCase(); + return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes( + normalized, + ); + } + + private async resolvePolymorphicRelatedObject( + tenantId: string, + state: AiAssistantState, + ): Promise { + if (!state.objectDefinition || !state.extractedFields) return state; + + const apiName = String(state.objectDefinition.apiName || '').toLowerCase(); + if (!this.isContactDetail(apiName)) { + return state; + } + + const provided = state.extractedFields.relatedObjectId; + if (!provided || typeof provided !== 'string' || this.isUuid(provided)) { + return state; + } + + const preferredType = + state.extractedFields.relatedObjectType || + this.toPolymorphicType(state.context?.objectApiName || ''); + + const candidateTypes = preferredType + ? [preferredType, ...['Account', 'Contact'].filter((t) => t !== preferredType)] + : ['Account', 'Contact']; + + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + for (const type of candidateTypes) { + const objectApi = type.toLowerCase(); + let targetDefinition: any; + try { + targetDefinition = await this.objectService.getObjectDefinition(tenantId, objectApi); + } catch (error) { + continue; + } + + const displayField = this.getDisplayFieldForObject(targetDefinition); + const tableName = this.toTableName( + targetDefinition.apiName, + targetDefinition.label, + targetDefinition.pluralLabel, + ); + + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + targetDefinition.apiName, + provided, + displayField, + ); + if (meiliMatch?.id) { + return { + ...state, + extractedFields: { + ...state.extractedFields, + relatedObjectId: meiliMatch.id, + relatedObjectType: type, + }, + }; + } + + const record = await knex(tableName) + .whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()]) + .first(); + + if (record?.id) { + return { + ...state, + extractedFields: { + ...state.extractedFields, + relatedObjectId: record.id, + relatedObjectType: type, + }, + }; + } + } + + return state; + } + + private getDisplayFieldForObject(objectDefinition: any): string { + if (!objectDefinition?.fields) return 'name'; + const hasName = objectDefinition.fields.some( + (candidate: any) => candidate.apiName === 'name', + ); + if (hasName) return 'name'; + + const firstText = objectDefinition.fields.find((candidate: any) => + ['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()), + ); + return firstText?.apiName || 'id'; + } + + private extractValueForField(message: string, field: any): string | null { + const label = field.label || field.apiName; + const apiName = field.apiName; + const patterns = [ + new RegExp(`${this.escapeRegex(label)}\\s*:\\s*([^\\n;,]+)`, 'i'), + new RegExp(`${this.escapeRegex(apiName)}\\s*:\\s*([^\\n;,]+)`, 'i'), + new RegExp(`set\\s+${this.escapeRegex(label)}\\s+to\\s+([^\\n;,]+)`, 'i'), + new RegExp(`set\\s+${this.escapeRegex(apiName)}\\s+to\\s+([^\\n;,]+)`, 'i'), + ]; + + for (const pattern of patterns) { + const match = message.match(pattern); + if (match?.[1]) { + return match[1].trim(); + } + } + + return null; + } + + private escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private getFieldLabel(fields: any[], apiName: string): string { + const field = fields.find((candidate) => candidate.apiName === apiName); + return field?.label || apiName; + } + + private parseLayoutConfig(layoutConfig: any) { + if (!layoutConfig) return null; + if (typeof layoutConfig === 'string') { + try { + return JSON.parse(layoutConfig); + } catch (error) { + this.logger.warn(`Failed to parse layout config: ${error.message}`); + return null; + } + } + + return layoutConfig; + } + + private isSystemField(apiName: string): boolean { + return [ + 'id', + 'ownerId', + 'created_at', + 'updated_at', + 'createdAt', + 'updatedAt', + 'tenantId', + ].includes(apiName); + } + + private async getOpenAiConfig(tenantId: string): Promise { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const centralPrisma = getCentralPrisma(); + const tenant = await centralPrisma.tenant.findUnique({ + where: { id: resolvedTenantId }, + select: { integrationsConfig: true }, + }); + + let config = tenant?.integrationsConfig + ? typeof tenant.integrationsConfig === 'string' + ? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig) + : tenant.integrationsConfig + : null; + + // Fallback to environment if tenant config is missing + if (!config?.openai && process.env.OPENAI_API_KEY) { + this.logger.log('Using OPENAI_API_KEY fallback for AI assistant.'); + config = { + ...(config || {}), + openai: { + apiKey: process.env.OPENAI_API_KEY, + model: process.env.OPENAI_MODEL || this.defaultModel, + }, + }; + } + + if (config?.openai?.apiKey) { + return { + apiKey: config.openai.apiKey, + model: this.defaultModel, + }; + } + + return null; + } + + private normalizeChatModel(model?: string): string { + if (!model) return this.defaultModel; + + const lower = model.toLowerCase(); + if ( + lower.includes('instruct') || + lower.startsWith('text-') || + lower.startsWith('davinci') || + lower.startsWith('curie') || + lower.includes('realtime') + ) { + return this.defaultModel; + } + + return model; + } + + private combineHistory(history: AiAssistantState['history'], message: string): string { + if (!history || history.length === 0) return message; + + const recent = history.slice(-6); + const serialized = recent + .map((entry) => `[${entry.role}] ${entry.text}`) + .join('\n'); + return `${serialized}\n[user] ${message}`; + } + + private getConversationKey( + tenantId: string, + userId: string, + objectApiName?: string, + ): string { + return `${tenantId}:${userId}:${objectApiName || 'global'}`; + } + + private pruneConversations() { + const now = Date.now(); + for (const [key, value] of this.conversationState.entries()) { + if (now - value.updatedAt > this.conversationTtlMs) { + this.conversationState.delete(key); + } + } + } + + private async resolveLookupFields( + tenantId: string, + objectDefinition: any, + extractedFields: Record, + ): Promise<{ + resolvedFields: Record; + unresolvedLookups: Array<{ + fieldApiName: string; + fieldLabel?: string; + targetLabel?: string; + providedValue: any; + }>; + }> { + if (!extractedFields) { + return { resolvedFields: {}, unresolvedLookups: [] }; + } + + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + const resolvedFields = { ...extractedFields }; + const unresolvedLookups: Array<{ + fieldApiName: string; + fieldLabel?: string; + targetLabel?: string; + providedValue: any; + }> = []; + + const lookupFields = (objectDefinition.fields || []).filter( + (field: any) => field.type === 'LOOKUP' && field.referenceObject, + ); + + for (const field of lookupFields) { + const value = extractedFields[field.apiName]; + if (value === undefined || value === null) continue; + + // Already an ID or object with ID + if (typeof value === 'object' && value.id) { + resolvedFields[field.apiName] = value.id; + continue; + } + if (typeof value === 'string' && this.isUuid(value)) { + resolvedFields[field.apiName] = value; + continue; + } + + // Resolve by display field (e.g., name) + const targetApiName = String(field.referenceObject); + let targetDefinition: any = null; + try { + targetDefinition = await this.objectService.getObjectDefinition(tenantId, targetApiName); + } catch (error) { + this.logger.warn( + `Failed to load reference object ${targetApiName} for field ${field.apiName}: ${error.message}`, + ); + } + + const displayField = targetDefinition ? this.getLookupDisplayField(field, targetDefinition) : 'name'; + const tableName = targetDefinition + ? this.toTableName(targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel) + : this.toTableName(targetApiName); + + const providedValue = typeof value === 'string' ? value.trim() : value; + if (providedValue && typeof providedValue === 'string') { + + console.log('providedValue:', providedValue); + + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + targetDefinition?.apiName || targetApiName, + providedValue, + displayField, + ); + + console.log('MeiliSearch lookup for', meiliMatch); + + if (meiliMatch?.id) { + resolvedFields[field.apiName] = meiliMatch.id; + continue; + } + } + + const record = + providedValue && typeof providedValue === 'string' + ? await knex(tableName) + .whereRaw('LOWER(??) = ?', [displayField, providedValue.toLowerCase()]) + .first() + : null; + + if (record?.id) { + resolvedFields[field.apiName] = record.id; + } else { + unresolvedLookups.push({ + fieldApiName: field.apiName, + fieldLabel: field.label, + targetLabel: targetDefinition?.label || targetApiName, + providedValue: value, + }); + } + } + + return { resolvedFields, unresolvedLookups }; + } + + private getLookupDisplayField(field: any, targetDefinition: any): string { + const uiMetadata = this.parseUiMetadata(field.uiMetadata || field.ui_metadata); + if (uiMetadata?.relationDisplayField) { + return uiMetadata.relationDisplayField; + } + + const hasName = (targetDefinition.fields || []).some( + (candidate: any) => candidate.apiName === 'name', + ); + if (hasName) return 'name'; + + // Fallback to first string-like field + const firstTextField = (targetDefinition.fields || []).find((candidate: any) => + ['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()), + ); + return firstTextField?.apiName || 'id'; + } + + private parseUiMetadata(uiMetadata: any): any { + if (!uiMetadata) return null; + if (typeof uiMetadata === 'object') return uiMetadata; + if (typeof uiMetadata === 'string') { + try { + return JSON.parse(uiMetadata); + } catch (error) { + this.logger.warn(`Failed to parse UI metadata: ${error.message}`); + return null; + } + } + return null; + } + + private isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + } + + private toTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); + + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(objectApiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; + } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + return fromApi; + } +} diff --git a/backend/src/ai-assistant/ai-assistant.types.ts b/backend/src/ai-assistant/ai-assistant.types.ts new file mode 100644 index 0000000..cdb1b3e --- /dev/null +++ b/backend/src/ai-assistant/ai-assistant.types.ts @@ -0,0 +1,32 @@ +export interface AiChatMessage { + role: 'user' | 'assistant'; + text: string; +} + +export interface AiChatContext { + objectApiName?: string; + view?: string; + recordId?: string; + route?: string; +} + +export interface AiAssistantReply { + reply: string; + action?: 'create_record' | 'collect_fields' | 'clarify'; + missingFields?: string[]; + record?: any; +} + +export interface AiAssistantState { + message: string; + history?: AiChatMessage[]; + context: AiChatContext; + objectDefinition?: any; + pageLayout?: any; + extractedFields?: Record; + requiredFields?: string[]; + missingFields?: string[]; + action?: AiAssistantReply['action']; + record?: any; + reply?: string; +} diff --git a/backend/src/ai-assistant/dto/ai-chat.dto.ts b/backend/src/ai-assistant/dto/ai-chat.dto.ts new file mode 100644 index 0000000..6f7f005 --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-chat.dto.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { AiChatMessageDto } from './ai-chat.message.dto'; + +export class AiChatContextDto { + @IsOptional() + @IsString() + objectApiName?: string; + + @IsOptional() + @IsString() + view?: string; + + @IsOptional() + @IsString() + recordId?: string; + + @IsOptional() + @IsString() + route?: string; +} + +export class AiChatRequestDto { + @IsString() + @IsNotEmpty() + message: string; + + @IsOptional() + @IsObject() + context?: AiChatContextDto; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AiChatMessageDto) + history?: AiChatMessageDto[]; +} diff --git a/backend/src/ai-assistant/dto/ai-chat.message.dto.ts b/backend/src/ai-assistant/dto/ai-chat.message.dto.ts new file mode 100644 index 0000000..70cf0ea --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-chat.message.dto.ts @@ -0,0 +1,10 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class AiChatMessageDto { + @IsIn(['user', 'assistant']) + role: 'user' | 'assistant'; + + @IsString() + @IsNotEmpty() + text: string; +} diff --git a/backend/src/ai-assistant/dto/ai-search.dto.ts b/backend/src/ai-assistant/dto/ai-search.dto.ts new file mode 100644 index 0000000..8a9f29f --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-search.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; + +export class AiSearchRequestDto { + @IsString() + @IsNotEmpty() + objectApiName: string; + + @IsString() + @IsNotEmpty() + query: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + pageSize?: number; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7b9db0c..00dcef8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { ObjectModule } from './object/object.module'; import { AppBuilderModule } from './app-builder/app-builder.module'; import { PageLayoutModule } from './page-layout/page-layout.module'; import { VoiceModule } from './voice/voice.module'; +import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { VoiceModule } from './voice/voice.module'; AppBuilderModule, PageLayoutModule, VoiceModule, + AiAssistantModule, ], }) export class AppModule {} diff --git a/backend/src/models/base.model.ts b/backend/src/models/base.model.ts index 259e992..6584e0c 100644 --- a/backend/src/models/base.model.ts +++ b/backend/src/models/base.model.ts @@ -1,7 +1,38 @@ -import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection'; +import { Model, ModelOptions, QueryContext } from 'objection'; export class BaseModel extends Model { - static columnNameMappers = snakeCaseMappers(); + /** + * Use a minimal column mapper: keep property names as-is, but handle + * timestamp fields that are stored as created_at/updated_at in the DB. + */ + static columnNameMappers = { + parse(dbRow: Record) { + const mapped: Record = {}; + for (const [key, value] of Object.entries(dbRow || {})) { + if (key === 'created_at') { + mapped.createdAt = value; + } else if (key === 'updated_at') { + mapped.updatedAt = value; + } else { + mapped[key] = value; + } + } + return mapped; + }, + format(model: Record) { + const mapped: Record = {}; + for (const [key, value] of Object.entries(model || {})) { + if (key === 'createdAt') { + mapped.created_at = value; + } else if (key === 'updatedAt') { + mapped.updated_at = value; + } else { + mapped[key] = value; + } + } + return mapped; + }, + }; id: string; createdAt: Date; diff --git a/backend/src/models/contact-detail.model.ts b/backend/src/models/contact-detail.model.ts new file mode 100644 index 0000000..0587cf2 --- /dev/null +++ b/backend/src/models/contact-detail.model.ts @@ -0,0 +1,33 @@ +import { BaseModel } from './base.model'; + +export class ContactDetail extends BaseModel { + static tableName = 'contact_details'; + + id!: string; + relatedObjectType!: 'Account' | 'Contact'; + relatedObjectId!: string; + detailType!: string; + label?: string; + value!: string; + isPrimary!: boolean; + + // Provide optional relations for each supported parent type. + static relationMappings = { + account: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'account.model', + join: { + from: 'contact_details.relatedObjectId', + to: 'accounts.id', + }, + }, + contact: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'contact.model', + join: { + from: 'contact_details.relatedObjectId', + to: 'contacts.id', + }, + }, + }; +} diff --git a/backend/src/models/contact.model.ts b/backend/src/models/contact.model.ts new file mode 100644 index 0000000..3dfc9e4 --- /dev/null +++ b/backend/src/models/contact.model.ts @@ -0,0 +1,30 @@ +import { BaseModel } from './base.model'; + +export class Contact extends BaseModel { + static tableName = 'contacts'; + + id!: string; + firstName!: string; + lastName!: string; + accountId!: string; + ownerId?: string; + + static relationMappings = { + account: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'account.model', + join: { + from: 'contacts.accountId', + to: 'accounts.id', + }, + }, + owner: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'user.model', + join: { + from: 'contacts.ownerId', + to: 'users.id', + }, + }, + }; +} diff --git a/backend/src/models/field-definition.model.ts b/backend/src/models/field-definition.model.ts index 661e389..d63bb19 100644 --- a/backend/src/models/field-definition.model.ts +++ b/backend/src/models/field-definition.model.ts @@ -30,6 +30,8 @@ export interface UIMetadata { step?: number; // For number accept?: string; // For file/image relationDisplayField?: string; // Which field to display for relations + relationObjects?: string[]; // For polymorphic relations + relationTypeField?: string; // Field API name storing the selected relation type // Formatting format?: string; // Date format, number format, etc. diff --git a/backend/src/object/field-mapper.service.ts b/backend/src/object/field-mapper.service.ts index 5043ee8..345c00d 100644 --- a/backend/src/object/field-mapper.service.ts +++ b/backend/src/object/field-mapper.service.ts @@ -22,7 +22,9 @@ export interface FieldConfigDTO { step?: number; accept?: string; relationObject?: string; + relationObjects?: string[]; relationDisplayField?: string; + relationTypeField?: string; format?: string; prefix?: string; suffix?: string; @@ -43,6 +45,14 @@ export interface ObjectDefinitionDTO { description?: string; isSystem: boolean; fields: FieldConfigDTO[]; + relatedLists?: Array<{ + title: string; + relationName: string; + objectApiName: string; + fields: FieldConfigDTO[]; + canCreate?: boolean; + createRoute?: string; + }>; } @Injectable() @@ -98,10 +108,12 @@ export class FieldMapperService { step: uiMetadata.step, accept: uiMetadata.accept, relationObject: field.referenceObject, + relationObjects: uiMetadata.relationObjects, // For lookup fields, provide default display field if not specified relationDisplayField: isLookupField ? (uiMetadata.relationDisplayField || 'name') : uiMetadata.relationDisplayField, + relationTypeField: uiMetadata.relationTypeField, // Formatting format: uiMetadata.format, @@ -206,6 +218,17 @@ export class FieldMapperService { .filter((f: any) => f.isActive !== false) .sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0)) .map((f: any) => this.mapFieldToDTO(f)), + relatedLists: (objectDef.relatedLists || []).map((list: any) => ({ + title: list.title, + relationName: list.relationName, + objectApiName: list.objectApiName, + fields: (list.fields || []) + .filter((f: any) => f.isActive !== false) + .map((f: any) => this.mapFieldToDTO(f)) + .filter((f: any) => f.showOnList !== false), + canCreate: list.canCreate, + createRoute: list.createRoute, + })), }; } diff --git a/backend/src/object/models/dynamic-model.factory.ts b/backend/src/object/models/dynamic-model.factory.ts index 046a03b..78e4848 100644 --- a/backend/src/object/models/dynamic-model.factory.ts +++ b/backend/src/object/models/dynamic-model.factory.ts @@ -119,6 +119,47 @@ export class DynamicModelFactory { }; } + // Add additional relation mappings (e.g., hasMany) + for (const relation of relations) { + if (mappings[relation.name]) { + continue; + } + + let modelClass: any = relation.targetObjectApiName; + if (getModel) { + const resolvedModel = getModel(relation.targetObjectApiName); + if (resolvedModel) { + modelClass = resolvedModel; + } else { + continue; + } + } + + const targetTable = DynamicModelFactory.getTableName(relation.targetObjectApiName); + + if (relation.type === 'belongsTo') { + mappings[relation.name] = { + relation: Model.BelongsToOneRelation, + modelClass, + join: { + from: `${tableName}.${relation.fromColumn}`, + to: `${targetTable}.${relation.toColumn}`, + }, + }; + } + + if (relation.type === 'hasMany') { + mappings[relation.name] = { + relation: Model.HasManyRelation, + modelClass, + join: { + from: `${tableName}.${relation.fromColumn}`, + to: `${targetTable}.${relation.toColumn}`, + }, + }; + } + } + return mappings; } @@ -138,7 +179,8 @@ export class DynamicModelFactory { * Convert a field definition to JSON schema property */ private static fieldToJsonSchema(field: FieldDefinition): Record { - switch (field.type.toUpperCase()) { + const baseSchema = () => { + switch (field.type.toUpperCase()) { case 'TEXT': case 'STRING': case 'EMAIL': @@ -146,45 +188,57 @@ export class DynamicModelFactory { case 'PHONE': case 'PICKLIST': case 'MULTI_PICKLIST': - return { - type: 'string', - ...(field.isUnique && { uniqueItems: true }), - }; + return { + type: 'string', + ...(field.isUnique && { uniqueItems: true }), + }; case 'LONG_TEXT': - return { type: 'string' }; + return { type: 'string' }; case 'NUMBER': case 'DECIMAL': case 'CURRENCY': case 'PERCENT': - return { - type: 'number', - ...(field.isUnique && { uniqueItems: true }), - }; + return { + type: 'number', + ...(field.isUnique && { uniqueItems: true }), + }; case 'INTEGER': - return { - type: 'integer', - ...(field.isUnique && { uniqueItems: true }), - }; + return { + type: 'integer', + ...(field.isUnique && { uniqueItems: true }), + }; case 'BOOLEAN': - return { type: 'boolean', default: false }; + return { type: 'boolean', default: false }; case 'DATE': - return { type: 'string', format: 'date' }; + return { type: 'string', format: 'date' }; case 'DATE_TIME': - return { type: 'string', format: 'date-time' }; + return { type: 'string', format: 'date-time' }; case 'LOOKUP': case 'BELONGS_TO': - return { type: 'string' }; + return { type: 'string' }; default: - return { type: 'string' }; + return { type: 'string' }; + } + }; + + const schema = baseSchema(); + + // Allow null for non-required fields so optional strings/numbers don't fail validation + if (!field.isRequired) { + return { + anyOf: [schema, { type: 'null' }], + }; } + + return schema; } /** @@ -196,6 +250,9 @@ export class DynamicModelFactory { .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, ''); + if (snakeCase.endsWith('y')) { + return `${snakeCase.slice(0, -1)}ies`; + } return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; } } diff --git a/backend/src/object/models/model.registry.ts b/backend/src/object/models/model.registry.ts index cd728ef..8ee2d02 100644 --- a/backend/src/object/models/model.registry.ts +++ b/backend/src/object/models/model.registry.ts @@ -16,13 +16,17 @@ export class ModelRegistry { */ registerModel(apiName: string, modelClass: ModelClass): void { this.registry.set(apiName, modelClass); + const lowerKey = apiName.toLowerCase(); + if (lowerKey !== apiName && !this.registry.has(lowerKey)) { + this.registry.set(lowerKey, modelClass); + } } /** * Get a model from the registry */ getModel(apiName: string): ModelClass { - const model = this.registry.get(apiName); + const model = this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()); if (!model) { throw new Error(`Model for ${apiName} not found in registry`); } @@ -33,7 +37,7 @@ export class ModelRegistry { * Check if a model exists in the registry */ hasModel(apiName: string): boolean { - return this.registry.has(apiName); + return this.registry.has(apiName) || this.registry.has(apiName.toLowerCase()); } /** @@ -46,7 +50,8 @@ export class ModelRegistry { // Returns undefined if model not found (for models not yet registered) const model = DynamicModelFactory.createModel( metadata, - (apiName: string) => this.registry.get(apiName), + (apiName: string) => + this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()), ); this.registerModel(metadata.apiName, model); return model; diff --git a/backend/src/object/models/model.service.ts b/backend/src/object/models/model.service.ts index 6b87979..96a6100 100644 --- a/backend/src/object/models/model.service.ts +++ b/backend/src/object/models/model.service.ts @@ -171,6 +171,25 @@ export class ModelService { } } + if (objectMetadata.relations) { + for (const relation of objectMetadata.relations) { + if (relation.targetObjectApiName) { + try { + await this.ensureModelWithDependencies( + tenantId, + relation.targetObjectApiName, + fetchMetadata, + visited, + ); + } catch (error) { + this.logger.debug( + `Skipping registration of related model ${relation.targetObjectApiName}: ${error.message}` + ); + } + } + } + } + // Now create and register this model (all dependencies are ready) await this.createModelForObject(tenantId, objectMetadata); this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`); diff --git a/backend/src/object/object.module.ts b/backend/src/object/object.module.ts index 7304302..7a8e873 100644 --- a/backend/src/object/object.module.ts +++ b/backend/src/object/object.module.ts @@ -9,9 +9,10 @@ import { MigrationModule } from '../migration/migration.module'; import { RbacModule } from '../rbac/rbac.module'; import { ModelRegistry } from './models/model.registry'; import { ModelService } from './models/model.service'; +import { MeilisearchModule } from '../search/meilisearch.module'; @Module({ - imports: [TenantModule, MigrationModule, RbacModule], + imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule], providers: [ ObjectService, SchemaManagementService, diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 26fec40..dd3a1b1 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -1,12 +1,33 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; import { AuthorizationService } from '../rbac/authorization.service'; +import { SchemaManagementService } from './schema-management.service'; import { ObjectDefinition } from '../models/object-definition.model'; import { FieldDefinition } from '../models/field-definition.model'; import { User } from '../models/user.model'; import { ObjectMetadata } from './models/dynamic-model.factory'; +import { MeilisearchService } from '../search/meilisearch.service'; + +type SearchFilter = { + field: string; + operator: string; + value?: any; + values?: any[]; + from?: string; + to?: string; +}; + +type SearchSort = { + field: string; + direction: 'asc' | 'desc'; +}; + +type SearchPagination = { + page?: number; + pageSize?: number; +}; @Injectable() export class ObjectService { @@ -17,6 +38,7 @@ export class ObjectService { private customMigrationService: CustomMigrationService, private modelService: ModelService, private authService: AuthorizationService, + private meilisearchService: MeilisearchService, ) {} // Setup endpoints - Object metadata management @@ -59,8 +81,10 @@ export class ObjectService { .where({ objectDefinitionId: obj.id }) .orderBy('label', 'asc'); - // Normalize all fields to ensure system fields are properly marked - const normalizedFields = fields.map((field: any) => this.normalizeField(field)); + // Normalize all fields to ensure system fields are properly marked and add any missing system fields + const normalizedFields = this.addMissingSystemFields( + fields.map((field: any) => this.normalizeField(field)), + ); // Get app information if object belongs to an app let app = null; @@ -71,10 +95,13 @@ export class ObjectService { .first(); } + const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, apiName); + return { ...obj, fields: normalizedFields, app, + relatedLists, }; } @@ -183,7 +210,7 @@ export class ObjectService { } // Create a migration to create the table - const tableName = this.getTableName(data.apiName); + const tableName = this.getTableName(data.apiName, data.label, data.pluralLabel); const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName); try { @@ -269,7 +296,13 @@ export class ObjectService { referenceObject?: string; relationObject?: string; relationDisplayField?: string; + relationObjects?: string[]; + relationTypeField?: string; defaultValue?: string; + length?: number; + precision?: number; + scale?: number; + uiMetadata?: any; }, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); @@ -282,8 +315,11 @@ export class ObjectService { // Use relationObject if provided (alias for referenceObject) const referenceObject = data.referenceObject || data.relationObject; + // Generate UUID in Node.js instead of using MySQL UUID() function + const fieldId = require('crypto').randomUUID(); + const fieldData: any = { - id: knex.raw('(UUID())'), + id: fieldId, objectDefinitionId: obj.id, apiName: data.apiName, label: data.label, @@ -293,39 +329,78 @@ export class ObjectService { isUnique: data.isUnique ?? false, referenceObject: referenceObject, defaultValue: data.defaultValue, + length: data.length, + precision: data.precision, + scale: data.scale, created_at: knex.fn.now(), updated_at: knex.fn.now(), }; // Store relationDisplayField in UI metadata if provided - if (data.relationDisplayField) { + if (data.relationDisplayField || data.relationObjects || data.relationTypeField) { fieldData.ui_metadata = JSON.stringify({ relationDisplayField: data.relationDisplayField, + relationObjects: data.relationObjects, + relationTypeField: data.relationTypeField, }); } - const [id] = await knex('field_definitions').insert(fieldData); + await knex('field_definitions').insert(fieldData); + const createdField = await knex('field_definitions').where({ id: fieldId }).first(); + + // Add the column to the physical table + const schemaManagementService = new SchemaManagementService(); + try { + await schemaManagementService.addFieldToTable( + knex, + obj.apiName, + createdField, + obj.label, + obj.pluralLabel, + ); + this.logger.log(`Added column ${data.apiName} to table for object ${objectApiName}`); + } catch (error) { + // If column creation fails, delete the field definition to maintain consistency + this.logger.error(`Failed to add column ${data.apiName}: ${error.message}`); + await knex('field_definitions').where({ id: fieldId }).delete(); + throw new Error(`Failed to create field column: ${error.message}`); + } - return knex('field_definitions').where({ id }).first(); + return createdField; } // Helper to get table name from object definition - private getTableName(objectApiName: string): string { - // Convert CamelCase to snake_case and pluralize - // Account -> accounts, ContactPerson -> contact_persons - const snakeCase = objectApiName - .replace(/([A-Z])/g, '_$1') - .toLowerCase() - .replace(/^_/, ''); - - // Simple pluralization (can be enhanced) - if (snakeCase.endsWith('y')) { - return snakeCase.slice(0, -1) + 'ies'; - } else if (snakeCase.endsWith('s')) { - return snakeCase; - } else { - return snakeCase + 's'; + private getTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); + + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(objectApiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + // Prefer the label-derived name when it introduces clearer word boundaries + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + // Otherwise fall back to label/plural if they differ from API-derived + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } /** @@ -365,6 +440,7 @@ export class ObjectService { 'url': 'URL', 'color': 'TEXT', 'json': 'JSON', + 'lookup': 'LOOKUP', 'belongsTo': 'LOOKUP', 'hasMany': 'LOOKUP', 'manyToMany': 'LOOKUP', @@ -384,11 +460,16 @@ export class ObjectService { private async ensureModelRegistered( tenantId: string, objectApiName: string, + objectDefinition?: any, ): Promise { // Provide a metadata fetcher function that the ModelService can use const fetchMetadata = async (apiName: string): Promise => { const objectDef = await this.getObjectDefinition(tenantId, apiName); - const tableName = this.getTableName(apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); // Build relations from lookup fields, but only for models that exist const lookupFields = objectDef.fields.filter((f: any) => @@ -409,6 +490,17 @@ export class ObjectService { }); } + const relatedLists = await this.getRelatedListDefinitions(tenantId, apiName); + for (const relatedList of relatedLists) { + validRelations.push({ + name: relatedList.relationName, + type: 'hasMany' as const, + targetObjectApiName: relatedList.objectApiName, + fromColumn: 'id', + toColumn: relatedList.lookupFieldApiName, + }); + } + return { apiName, tableName, @@ -428,7 +520,7 @@ export class ObjectService { try { await this.modelService.ensureModelWithDependencies( tenantId, - objectApiName, + objectDefinition?.apiName || objectApiName, fetchMetadata, ); } catch (error) { @@ -438,44 +530,41 @@ export class ObjectService { } } - // Runtime endpoints - CRUD operations - async getRecords( + private async buildAuthorizedQuery( tenantId: string, objectApiName: string, userId: string, - filters?: any, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - - // Get user with roles and permissions + const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); - + if (!user) { throw new NotFoundException('User not found'); } - - // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); - + if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } - - const tableName = this.getTableName(objectApiName); - - // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); - // Use Objection model + // Normalize and enrich fields to include system fields for downstream permissions/search + const normalizedFields = this.addMissingSystemFields( + (objectDefModel.fields || []).map((field: any) => this.normalizeField(field)), + ); + objectDefModel.fields = normalizedFields; + + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); + const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); - - // Apply authorization scope (modifies query in place) + await this.authService.applyScopeToQuery( query, objectDefModel, @@ -483,39 +572,310 @@ export class ObjectService { 'read', knex, ); - - // Build graph expression for lookup fields - const lookupFields = objectDefModel.fields?.filter(f => - f.type === 'LOOKUP' && f.referenceObject + + const lookupFields = objectDefModel.fields?.filter((field) => + field.type === 'LOOKUP' && field.referenceObject, ) || []; - + if (lookupFields.length > 0) { - // Build relation expression - use singular lowercase for relation name const relationExpression = lookupFields - .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) + .map((field) => field.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean) .join(', '); - + if (relationExpression) { query = query.withGraphFetched(`[${relationExpression}]`); } } - - // Apply additional filters - if (filters) { - query = query.where(filters); + + return { + query, + objectDefModel, + user, + knex, + }; + } + + private applySearchFilters( + query: any, + filters: SearchFilter[], + validFields: Set, + ) { + if (!Array.isArray(filters) || filters.length === 0) { + return query; } - + + for (const filter of filters) { + const field = filter?.field; + if (!field || !validFields.has(field)) { + continue; + } + + const operator = String(filter.operator || 'eq').toLowerCase(); + const value = filter.value; + const values = Array.isArray(filter.values) ? filter.values : undefined; + + switch (operator) { + case 'eq': + query.where(field, value); + break; + case 'neq': + query.whereNot(field, value); + break; + case 'gt': + query.where(field, '>', value); + break; + case 'gte': + query.where(field, '>=', value); + break; + case 'lt': + query.where(field, '<', value); + break; + case 'lte': + query.where(field, '<=', value); + break; + case 'contains': + if (value !== undefined && value !== null) { + query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]); + } + break; + case 'startswith': + if (value !== undefined && value !== null) { + query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]); + } + break; + case 'endswith': + if (value !== undefined && value !== null) { + query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]); + } + break; + case 'in': + if (values?.length) { + query.whereIn(field, values); + } else if (Array.isArray(value)) { + query.whereIn(field, value); + } + break; + case 'notin': + if (values?.length) { + query.whereNotIn(field, values); + } else if (Array.isArray(value)) { + query.whereNotIn(field, value); + } + break; + case 'isnull': + query.whereNull(field); + break; + case 'notnull': + query.whereNotNull(field); + break; + case 'between': { + const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined); + const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined); + if (from !== undefined && to !== undefined) { + query.whereBetween(field, [from, to]); + } else if (from !== undefined) { + query.where(field, '>=', from); + } else if (to !== undefined) { + query.where(field, '<=', to); + } + break; + } + default: + break; + } + } + + return query; + } + + private applyKeywordFilter( + query: any, + keyword: string, + fields: string[], + ) { + const trimmed = keyword?.trim(); + if (!trimmed || fields.length === 0) { + return query; + } + + query.where((builder: any) => { + const lowered = trimmed.toLowerCase(); + for (const field of fields) { + builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]); + } + }); + + return query; + } + + private async finalizeRecordQuery( + query: any, + objectDefModel: any, + user: any, + pagination?: SearchPagination, + ) { + const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1; + const parsedPageSize = Number.isFinite(Number(pagination?.pageSize)) + ? Number(pagination?.pageSize) + : 0; + const safePage = parsedPage > 0 ? parsedPage : 1; + const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0; + const shouldPaginate = safePageSize > 0; + + const totalCount = await query.clone().resultSize(); + + if (shouldPaginate) { + query = query.offset((safePage - 1) * safePageSize).limit(safePageSize); + } + const records = await query.select('*'); - - // Filter fields based on field-level permissions const filteredRecords = await Promise.all( - records.map(record => - this.authService.filterReadableFields(record, objectDefModel.fields, user) - ) + records.map((record: any) => + this.authService.filterReadableFields(record, objectDefModel.fields, user), + ), + ); + + return { + data: filteredRecords, + totalCount, + page: shouldPaginate ? safePage : 1, + pageSize: shouldPaginate ? safePageSize : filteredRecords.length, + }; + } + + // Runtime endpoints - CRUD operations + async getRecords( + tenantId: string, + objectApiName: string, + userId: string, + filters?: any, + ) { + let { query, objectDefModel, user } = await this.buildAuthorizedQuery( + tenantId, + objectApiName, + userId, ); - return filteredRecords; + // Extract pagination and sorting parameters from query string + const { + page, + pageSize, + sortField, + sortDirection, + ...rawFilters + } = filters || {}; + + const reservedFilterKeys = new Set(['page', 'pageSize', 'sortField', 'sortDirection']); + const filterEntries = Object.entries(rawFilters || {}).filter( + ([key, value]) => + !reservedFilterKeys.has(key) && + value !== undefined && + value !== null && + value !== '', + ); + + if (filterEntries.length > 0) { + query = query.where(builder => { + for (const [key, value] of filterEntries) { + builder.where(key, value as any); + } + }); + } + + if (sortField) { + const direction = sortDirection === 'desc' ? 'desc' : 'asc'; + query = query.orderBy(sortField, direction); + } + + return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize }); + } + + async searchRecordsByIds( + tenantId: string, + objectApiName: string, + userId: string, + recordIds: string[], + pagination?: SearchPagination, + ) { + if (!Array.isArray(recordIds) || recordIds.length === 0) { + return { + data: [], + totalCount: 0, + page: pagination?.page ?? 1, + pageSize: pagination?.pageSize ?? 0, + }; + } + + const { query, objectDefModel, user } = await this.buildAuthorizedQuery( + tenantId, + objectApiName, + userId, + ); + + query.whereIn('id', recordIds); + const orderBindings: any[] = ['id']; + const cases = recordIds + .map((id, index) => { + orderBindings.push(id, index); + return 'when ? then ?'; + }) + .join(' '); + + if (cases) { + query.orderByRaw(`case ?? ${cases} end`, orderBindings); + } + + return this.finalizeRecordQuery(query, objectDefModel, user, pagination); + } + + async searchRecordsWithFilters( + tenantId: string, + objectApiName: string, + userId: string, + filters: SearchFilter[], + pagination?: SearchPagination, + sort?: SearchSort, + ) { + const { query, objectDefModel, user } = await this.buildAuthorizedQuery( + tenantId, + objectApiName, + userId, + ); + + const validFields = new Set([ + ...(objectDefModel.fields?.map((field: any) => field.apiName) || []), + ...this.getSystemFieldNames(), + ]); + this.applySearchFilters(query, filters, validFields); + + if (sort?.field && validFields.has(sort.field)) { + query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc'); + } + + return this.finalizeRecordQuery(query, objectDefModel, user, pagination); + } + + async searchRecordsByKeyword( + tenantId: string, + objectApiName: string, + userId: string, + keyword: string, + pagination?: SearchPagination, + ) { + const { query, objectDefModel, user } = await this.buildAuthorizedQuery( + tenantId, + objectApiName, + userId, + ); + + const keywordFields = (objectDefModel.fields || []) + .filter((field: any) => this.isKeywordField(field.type)) + .map((field: any) => field.apiName); + + this.applyKeywordFilter(query, keyword, keywordFields); + + return this.finalizeRecordQuery(query, objectDefModel, user, pagination); } async getRecord( @@ -546,7 +906,7 @@ export class ObjectService { } // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); @@ -566,16 +926,18 @@ export class ObjectService { f.type === 'LOOKUP' && f.referenceObject ) || []; - if (lookupFields.length > 0) { - // Build relation expression - use singular lowercase for relation name - const relationExpression = lookupFields + const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, objectApiName); + + const relationNames = [ + ...lookupFields .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) - .filter(Boolean) - .join(', '); - - if (relationExpression) { - query = query.withGraphFetched(`[${relationExpression}]`); - } + .filter(Boolean), + ...relatedLists.map(list => list.relationName), + ]; + + if (relationNames.length > 0) { + const relationExpression = relationNames.join(', '); + query = query.withGraphFetched(`[${relationExpression}]`); } const record = await query.first(); @@ -589,6 +951,116 @@ export class ObjectService { return filteredRecord; } + private async getRelatedListDefinitions( + tenantId: string, + objectApiName: string, + ): Promise> { + const knex = await this.tenantDbService.getTenantKnexById(tenantId); + + const relatedLookupsRaw = await knex('field_definitions as fd') + .join('object_definitions as od', 'fd.objectDefinitionId', 'od.id') + .where('fd.type', 'LOOKUP') + .select( + 'fd.apiName as fieldApiName', + 'fd.label as fieldLabel', + 'fd.objectDefinitionId as objectDefinitionId', + 'fd.referenceObject as referenceObject', + 'fd.ui_metadata as uiMetadata', + 'od.apiName as childApiName', + 'od.label as childLabel', + 'od.pluralLabel as childPluralLabel', + ); + + const relatedLookups = relatedLookupsRaw + .map((lookup: any) => { + let uiMetadata: any = {}; + if (lookup.uiMetadata) { + try { + uiMetadata = typeof lookup.uiMetadata === 'string' + ? JSON.parse(lookup.uiMetadata) + : lookup.uiMetadata; + } catch { + uiMetadata = {}; + } + } + return { ...lookup, uiMetadata }; + }) + .filter((lookup: any) => { + const target = (objectApiName || '').toLowerCase(); + const referenceMatch = + typeof lookup.referenceObject === 'string' && + lookup.referenceObject.toLowerCase() === target; + + if (referenceMatch) return true; + + const relationObjects = lookup.uiMetadata?.relationObjects; + if (!Array.isArray(relationObjects)) return false; + + return relationObjects.some( + (rel: string) => typeof rel === 'string' && rel.toLowerCase() === target, + ); + }); + + if (relatedLookups.length === 0) { + return []; + } + + const objectIds = Array.from( + new Set(relatedLookups.map((lookup: any) => lookup.objectDefinitionId)), + ); + + const relatedFields = await knex('field_definitions') + .whereIn('objectDefinitionId', objectIds) + .orderBy('label', 'asc'); + + const fieldsByObject = new Map(); + for (const field of relatedFields) { + const existing = fieldsByObject.get(field.objectDefinitionId) || []; + existing.push(this.normalizeField(field)); + fieldsByObject.set(field.objectDefinitionId, existing); + } + + const lookupCounts = relatedLookups.reduce>( + (acc, lookup: any) => { + acc[lookup.childApiName] = (acc[lookup.childApiName] || 0) + 1; + return acc; + }, + {}, + ); + + return relatedLookups.map((lookup: any) => { + const baseRelationName = this.getTableName( + lookup.childApiName, + lookup.childLabel, + lookup.childPluralLabel, + ); + const hasMultiple = lookupCounts[lookup.childApiName] > 1; + const relationName = hasMultiple + ? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}` + : baseRelationName; + const baseTitle = + lookup.childPluralLabel || + (lookup.childLabel ? `${lookup.childLabel}s` : lookup.childApiName); + const title = hasMultiple ? `${baseTitle} (${lookup.fieldLabel})` : baseTitle; + + return { + title, + relationName, + objectApiName: lookup.childApiName, + lookupFieldApiName: lookup.fieldApiName, + parentObjectApiName: objectApiName, + fields: fieldsByObject.get(lookup.objectDefinitionId) || [], + }; + }); + } + async createRecord( tenantId: string, objectApiName: string, @@ -626,15 +1098,22 @@ export class ObjectService { const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + const hasOwnerField = objectDefModel.fields?.some((f: any) => f.apiName === 'ownerId'); const recordData = { ...editableData, - ownerId: userId, // Auto-set owner + ...(hasOwnerField ? { ownerId: userId } : {}), }; - const record = await boundModel.query().insert(recordData); + const normalizedRecordData = await this.normalizePolymorphicRelatedObject( + resolvedTenantId, + objectApiName, + recordData, + ); + const record = await boundModel.query().insert(normalizedRecordData); + await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); return record; } @@ -666,7 +1145,11 @@ export class ObjectService { throw new NotFoundException(`Object ${objectApiName} not found`); } - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); @@ -687,12 +1170,20 @@ export class ObjectService { delete editableData.tenantId; // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); - await boundModel.query().where({ id: recordId }).update(editableData); - return boundModel.query().where({ id: recordId }).first(); + const normalizedEditableData = await this.normalizePolymorphicRelatedObject( + resolvedTenantId, + objectApiName, + editableData, + ); + // Use patch to avoid validating or overwriting fields that aren't present in the edit view + await boundModel.query().patch(normalizedEditableData).where({ id: recordId }); + const record = await boundModel.query().where({ id: recordId }).first(); + await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); + return record; } async deleteRecord( @@ -721,7 +1212,11 @@ export class ObjectService { throw new NotFoundException(`Object ${objectApiName} not found`); } - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); @@ -733,15 +1228,537 @@ export class ObjectService { await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); await boundModel.query().where({ id: recordId }).delete(); + await this.removeIndexedRecord(resolvedTenantId, objectApiName, recordId); return { success: true }; } + async deleteRecords( + tenantId: string, + objectApiName: string, + recordIds: string[], + userId: string, + ) { + if (!Array.isArray(recordIds) || recordIds.length === 0) { + throw new BadRequestException('No record IDs provided'); + } + + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get user with roles and permissions + const user = await User.query(knex) + .findById(userId) + .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDefModel) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); + + const records = await knex(tableName).whereIn('id', recordIds); + if (records.length === 0) { + throw new NotFoundException('No records found to delete'); + } + + const foundIds = new Set(records.map((record: any) => record.id)); + const missingIds = recordIds.filter(id => !foundIds.has(id)); + if (missingIds.length > 0) { + throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`); + } + + const deletableIds: string[] = []; + const deniedIds: string[] = []; + + for (const record of records) { + const canDelete = await this.authService.canPerformAction( + 'delete', + objectDefModel, + record, + user, + knex, + ); + if (canDelete) { + deletableIds.push(record.id); + } else { + deniedIds.push(record.id); + } + } + + // Ensure model is registered + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); + + // Use Objection model + const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + if (deletableIds.length > 0) { + await boundModel.query().whereIn('id', deletableIds).delete(); + } + + // Remove from search index + await Promise.all( + deletableIds.map((id) => + this.removeIndexedRecord(resolvedTenantId, objectApiName, id), + ), + ); + + return { + success: true, + deleted: deletableIds.length, + deletedIds: deletableIds, + deniedIds, + }; + } + + private async indexRecord( + tenantId: string, + objectApiName: string, + fields: FieldDefinition[], + record: Record, + ) { + if (!this.meilisearchService.isEnabled() || !record?.id) return; + + const fieldsToIndex = (fields || []) + .map((field: any) => field.apiName) + .filter((apiName) => apiName && !this.isSystemField(apiName)); + + console.log('Indexing record', { + tenantId, + objectApiName, + recordId: record.id, + fieldsToIndex, + }); + + await this.meilisearchService.upsertRecord( + tenantId, + objectApiName, + record, + fieldsToIndex, + ); + + console.log('Indexed record successfully'); + + + const meiliResults = await this.meilisearchService.searchRecords( + tenantId, + objectApiName, + record.name, + { limit: 10 }, + ); + + console.log('Meilisearch results:', meiliResults); + + } + + private async removeIndexedRecord( + tenantId: string, + objectApiName: string, + recordId: string, + ) { + if (!this.meilisearchService.isEnabled()) return; + await this.meilisearchService.deleteRecord(tenantId, objectApiName, recordId); + } + + private isSystemField(apiName: string): boolean { + return this.getSystemFieldNames().includes(apiName); + } + + private getSystemFieldNames(): string[] { + return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId']; + } + + private addMissingSystemFields(fields: any[]): any[] { + const existing = new Map((fields || []).map((field) => [field.apiName, field])); + const systemDefaults = [ + { apiName: 'id', label: 'ID', type: 'STRING' }, + { apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' }, + { apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' }, + { apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' }, + ]; + + const merged = [...fields]; + for (const sysField of systemDefaults) { + if (!existing.has(sysField.apiName)) { + merged.push({ + ...sysField, + isSystem: true, + isCustom: false, + isRequired: false, + }); + } + } + + return merged; + } + + private isKeywordField(type: string | undefined): boolean { + const normalized = String(type || '').toUpperCase(); + return [ + 'STRING', + 'TEXT', + 'EMAIL', + 'PHONE', + 'URL', + 'RICH_TEXT', + 'TEXTAREA', + ].includes(normalized); + } + + private async normalizePolymorphicRelatedObject( + tenantId: string, + objectApiName: string, + data: any, + ): Promise { + if (!data || !this.isContactDetailApi(objectApiName)) return data; + + const relatedObjectType = data.relatedObjectType; + const relatedObjectId = data.relatedObjectId; + if (!relatedObjectType || !relatedObjectId) return data; + + const normalizedType = this.toPolymorphicApiName(relatedObjectType); + if (!normalizedType) return data; + + if (this.isUuid(String(relatedObjectId))) { + return { + ...data, + relatedObjectType: normalizedType, + }; + } + + let targetDefinition: any; + try { + targetDefinition = await this.getObjectDefinition(tenantId, normalizedType.toLowerCase()); + } catch (error) { + this.logger.warn(`Failed to load definition for ${normalizedType}: ${error.message}`); + } + + if (!targetDefinition) { + throw new BadRequestException( + `Unable to resolve ${normalizedType} for "${relatedObjectId}". Please provide a valid record.`, + ); + } + + const displayField = this.getDisplayFieldForObjectDefinition(targetDefinition); + const tableName = this.getTableName( + targetDefinition.apiName, + targetDefinition.label, + targetDefinition.pluralLabel, + ); + + let resolvedId: string | null = null; + + if (this.meilisearchService.isEnabled()) { + const match = await this.meilisearchService.searchRecord( + tenantId, + targetDefinition.apiName, + String(relatedObjectId), + displayField, + ); + if (match?.id) { + resolvedId = match.id; + } + } + + if (!resolvedId) { + const knex = await this.tenantDbService.getTenantKnexById(tenantId); + const record = await knex(tableName) + .whereRaw('LOWER(??) = ?', [displayField, String(relatedObjectId).toLowerCase()]) + .first(); + if (record?.id) { + resolvedId = record.id; + } + } + + if (!resolvedId) { + throw new BadRequestException( + `Could not find ${normalizedType} matching "${relatedObjectId}". Please use an existing record.`, + ); + } + + return { + ...data, + relatedObjectId: resolvedId, + relatedObjectType: normalizedType, + }; + } + + private isContactDetailApi(objectApiName: string): boolean { + if (!objectApiName) return false; + const normalized = objectApiName.toLowerCase(); + return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes( + normalized, + ); + } + + private toPolymorphicApiName(raw: string): string | null { + if (!raw) return null; + const normalized = raw.toLowerCase(); + if (normalized === 'account' || normalized === 'accounts') return 'Account'; + if (normalized === 'contact' || normalized === 'contacts') return 'Contact'; + return null; + } + + private isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value || '', + ); + } + + private getDisplayFieldForObjectDefinition(objectDefinition: any): string { + if (!objectDefinition?.fields) return 'id'; + const hasName = objectDefinition.fields.some((field: any) => field.apiName === 'name'); + if (hasName) return 'name'; + + const firstText = objectDefinition.fields.find((field: any) => + ['STRING', 'TEXT', 'EMAIL'].includes(String(field.type || '').toUpperCase()), + ); + return firstText?.apiName || 'id'; + } + + /** + * Update a field definition + * Can update metadata (label, description, placeholder, helpText, etc.) safely + * Cannot update apiName or type if field has existing data (prevent data loss) + */ + async updateFieldDefinition( + tenantId: string, + objectApiName: string, + fieldApiName: string, + data: Partial<{ + label: string; + description: string; + isRequired: boolean; + isUnique: boolean; + defaultValue: string; + placeholder: string; + helpText: string; + displayOrder: number; + uiMetadata: Record; + }>, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get the object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + // Get the field definition + const field = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id, apiName: fieldApiName }) + .first(); + + if (!field) { + throw new NotFoundException(`Field ${fieldApiName} not found`); + } + + // Check if this field has data (count records) + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); + const recordCount = await knex(tableName).count('* as cnt').first(); + const hasData = recordCount && (recordCount.cnt as number) > 0; + + // Prepare update object + const updateData: any = { + updated_at: knex.fn.now(), + }; + + // Always allow these updates + if (data.label !== undefined) updateData.label = data.label; + if (data.description !== undefined) updateData.description = data.description; + if (data.displayOrder !== undefined) updateData.displayOrder = data.displayOrder; + + // Merge with existing uiMetadata + const existingMetadata = field.ui_metadata ? JSON.parse(field.ui_metadata) : {}; + const newMetadata = { ...existingMetadata }; + + if (data.placeholder !== undefined) newMetadata.placeholder = data.placeholder; + if (data.helpText !== undefined) newMetadata.helpText = data.helpText; + if (data.uiMetadata) { + Object.assign(newMetadata, data.uiMetadata); + } + + if (Object.keys(newMetadata).length > 0) { + updateData.ui_metadata = JSON.stringify(newMetadata); + } + + // Conditional updates based on data existence + if (data.isRequired !== undefined) { + if (hasData && data.isRequired && !field.isRequired) { + throw new Error('Cannot make a field required when data exists. Existing records may have null values.'); + } + updateData.isRequired = data.isRequired; + } + + if (data.isUnique !== undefined) { + if (hasData && data.isUnique && !field.isUnique) { + throw new Error('Cannot add unique constraint to field with existing data. Existing records may have duplicate values.'); + } + updateData.isUnique = data.isUnique; + } + + // Update the field definition + await knex('field_definitions') + .where({ id: field.id }) + .update(updateData); + + return knex('field_definitions').where({ id: field.id }).first(); + } + + /** + * Delete a field definition and clean up dependencies + * Removes the column from the physical table + * Removes field references from page layouts + * CASCADE deletion handles role_field_permissions + */ + async deleteFieldDefinition( + tenantId: string, + objectApiName: string, + fieldApiName: string, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get the object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + // Get the field definition + const field = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id, apiName: fieldApiName }) + .first(); + + if (!field) { + throw new NotFoundException(`Field ${fieldApiName} not found`); + } + + // Prevent deletion of system fields + const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt']; + if (systemFieldNames.includes(fieldApiName)) { + throw new Error(`Cannot delete system field: ${fieldApiName}`); + } + + // Clean up page layouts - remove field references from layoutConfig + try { + const pageLayouts = await knex('page_layouts') + .where({ object_id: objectDef.id }); + + for (const layout of pageLayouts) { + // Handle JSON column that might already be parsed + let layoutConfig; + if (layout.layout_config) { + layoutConfig = typeof layout.layout_config === 'string' + ? JSON.parse(layout.layout_config) + : layout.layout_config; + } else { + layoutConfig = { fields: [] }; + } + + // Filter out any field references for this field + if (layoutConfig.fields) { + layoutConfig.fields = layoutConfig.fields.filter( + (f: any) => f.fieldId !== field.id, + ); + } + + // Update the page layout + await knex('page_layouts') + .where({ id: layout.id }) + .update({ + layout_config: JSON.stringify(layoutConfig), + updated_at: knex.fn.now(), + }); + } + } catch (error) { + // If page layouts table doesn't exist or query fails, log but continue + this.logger.warn(`Could not update page layouts for field deletion: ${error.message}`); + } + + // Clean up dependsOn references in other fields + const otherFields = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id }) + .whereNot({ id: field.id }); + + for (const otherField of otherFields) { + // Handle JSON column that might already be parsed + let metadata; + if (otherField.ui_metadata) { + metadata = typeof otherField.ui_metadata === 'string' + ? JSON.parse(otherField.ui_metadata) + : otherField.ui_metadata; + } else { + metadata = {}; + } + + if (metadata.dependsOn && Array.isArray(metadata.dependsOn)) { + metadata.dependsOn = metadata.dependsOn.filter( + (dep: any) => dep !== field.apiName, + ); + + await knex('field_definitions') + .where({ id: otherField.id }) + .update({ + ui_metadata: JSON.stringify(metadata), + updated_at: knex.fn.now(), + }); + } + } + + // Remove the column from the physical table + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); + const schemaManagementService = new SchemaManagementService(); + + try { + await schemaManagementService.removeFieldFromTable( + knex, + objectDef.apiName, + fieldApiName, + objectDef.label, + objectDef.pluralLabel, + ); + } catch (error) { + this.logger.warn(`Failed to remove column ${fieldApiName} from table ${tableName}: ${error.message}`); + // Continue with deletion even if column removal fails - field definition must be cleaned up + } + + // Delete the field definition (CASCADE will delete role_field_permissions) + await knex('field_definitions').where({ id: field.id }).delete(); + + return { success: true }; + } + async getFieldPermissions(tenantId: string, objectId: string) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); diff --git a/backend/src/object/runtime-object.controller.ts b/backend/src/object/runtime-object.controller.ts index 6a55e05..139c759 100644 --- a/backend/src/object/runtime-object.controller.ts +++ b/backend/src/object/runtime-object.controller.ts @@ -95,4 +95,20 @@ export class RuntimeObjectController { user.userId, ); } + + @Post(':objectApiName/records/bulk-delete') + async deleteRecords( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Body() body: { recordIds?: string[]; ids?: string[] }, + @CurrentUser() user: any, + ) { + const recordIds: string[] = body?.recordIds || body?.ids || []; + return this.objectService.deleteRecords( + tenantId, + objectApiName, + recordIds, + user.userId, + ); + } } diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts index 7f932b5..39b79cc 100644 --- a/backend/src/object/schema-management.service.ts +++ b/backend/src/object/schema-management.service.ts @@ -15,7 +15,11 @@ export class SchemaManagementService { objectDefinition: ObjectDefinition, fields: FieldDefinition[], ) { - const tableName = this.getTableName(objectDefinition.apiName); + const tableName = this.getTableName( + objectDefinition.apiName, + objectDefinition.label, + objectDefinition.pluralLabel, + ); // Check if table already exists const exists = await knex.schema.hasTable(tableName); @@ -44,8 +48,10 @@ export class SchemaManagementService { knex: Knex, objectApiName: string, field: FieldDefinition, + objectLabel?: string, + pluralLabel?: string, ) { - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.alterTable(tableName, (table) => { this.addFieldColumn(table, field); @@ -61,8 +67,10 @@ export class SchemaManagementService { knex: Knex, objectApiName: string, fieldApiName: string, + objectLabel?: string, + pluralLabel?: string, ) { - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.alterTable(tableName, (table) => { table.dropColumn(fieldApiName); @@ -71,11 +79,44 @@ export class SchemaManagementService { this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`); } + /** + * Alter a field in an existing object table + * Handles safe updates like changing NOT NULL or constraints + * Warns about potentially destructive operations + */ + async alterFieldInTable( + knex: Knex, + objectApiName: string, + fieldApiName: string, + field: FieldDefinition, + objectLabel?: string, + pluralLabel?: string, + options?: { + skipTypeChange?: boolean; // Skip if type change would lose data + }, + ) { + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); + const skipTypeChange = options?.skipTypeChange ?? true; + + await knex.schema.alterTable(tableName, (table) => { + // Drop the existing column and recreate with new definition + // Note: This approach works for metadata changes, but type changes may need data migration + table.dropColumn(fieldApiName); + }); + + // Recreate the column with new definition + await knex.schema.alterTable(tableName, (table) => { + this.addFieldColumn(table, field); + }); + + this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`); + } + /** * Drop an object table */ - async dropObjectTable(knex: Knex, objectApiName: string) { - const tableName = this.getTableName(objectApiName); + async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) { + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.dropTableIfExists(tableName); @@ -94,15 +135,30 @@ export class SchemaManagementService { let column: Knex.ColumnBuilder; switch (field.type) { + // Text types case 'String': + case 'TEXT': + case 'EMAIL': + case 'PHONE': + case 'URL': column = table.string(columnName, field.length || 255); break; case 'Text': + case 'LONG_TEXT': column = table.text(columnName); break; + case 'PICKLIST': + case 'MULTI_PICKLIST': + column = table.string(columnName, 255); + break; + + // Numeric types case 'Number': + case 'NUMBER': + case 'CURRENCY': + case 'PERCENT': if (field.scale && field.scale > 0) { column = table.decimal( columnName, @@ -115,18 +171,28 @@ export class SchemaManagementService { break; case 'Boolean': + case 'BOOLEAN': column = table.boolean(columnName).defaultTo(false); break; + // Date types case 'Date': + case 'DATE': column = table.date(columnName); break; case 'DateTime': + case 'DATE_TIME': column = table.datetime(columnName); break; + case 'TIME': + column = table.time(columnName); + break; + + // Relationship types case 'Reference': + case 'LOOKUP': column = table.uuid(columnName); if (field.referenceObject) { const refTableName = this.getTableName(field.referenceObject); @@ -134,19 +200,30 @@ export class SchemaManagementService { } break; + // Email (legacy) case 'Email': column = table.string(columnName, 255); break; + // Phone (legacy) case 'Phone': column = table.string(columnName, 50); break; + // Url (legacy) case 'Url': column = table.string(columnName, 255); break; + // File types + case 'FILE': + case 'IMAGE': + column = table.text(columnName); // Store file path or URL + break; + + // JSON case 'Json': + case 'JSON': column = table.json(columnName); break; @@ -174,16 +251,35 @@ export class SchemaManagementService { /** * Convert object API name to table name (convert to snake_case, pluralize) */ - private getTableName(apiName: string): string { - // Convert PascalCase to snake_case - const snakeCase = apiName - .replace(/([A-Z])/g, '_$1') - .toLowerCase() - .replace(/^_/, ''); + private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); - // Simple pluralization (append 's' if not already plural) - // In production, use a proper pluralization library - return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(apiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; + } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } /** diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts index 426376c..5da5f93 100644 --- a/backend/src/object/setup-object.controller.ts +++ b/backend/src/object/setup-object.controller.ts @@ -4,6 +4,7 @@ import { Post, Patch, Put, + Delete, Param, Body, UseGuards, @@ -72,6 +73,35 @@ export class SetupObjectController { return this.fieldMapperService.mapFieldToDTO(field); } + @Put(':objectApiName/fields/:fieldApiName') + async updateFieldDefinition( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('fieldApiName') fieldApiName: string, + @Body() data: any, + ) { + const field = await this.objectService.updateFieldDefinition( + tenantId, + objectApiName, + fieldApiName, + data, + ); + return this.fieldMapperService.mapFieldToDTO(field); + } + + @Delete(':objectApiName/fields/:fieldApiName') + async deleteFieldDefinition( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('fieldApiName') fieldApiName: string, + ) { + return this.objectService.deleteFieldDefinition( + tenantId, + objectApiName, + fieldApiName, + ); + } + @Patch(':objectApiName') async updateObjectDefinition( @TenantId() tenantId: string, diff --git a/backend/src/page-layout/dto/page-layout.dto.ts b/backend/src/page-layout/dto/page-layout.dto.ts index e954790..17a8b5d 100644 --- a/backend/src/page-layout/dto/page-layout.dto.ts +++ b/backend/src/page-layout/dto/page-layout.dto.ts @@ -20,6 +20,7 @@ export class CreatePageLayoutDto { w: number; h: number; }>; + relatedLists?: string[]; }; @IsString() @@ -46,6 +47,7 @@ export class UpdatePageLayoutDto { w: number; h: number; }>; + relatedLists?: string[]; }; @IsString() diff --git a/backend/src/rbac/ability.factory.ts b/backend/src/rbac/ability.factory.ts index 4e1fc28..673848e 100644 --- a/backend/src/rbac/ability.factory.ts +++ b/backend/src/rbac/ability.factory.ts @@ -180,8 +180,9 @@ export class AbilityFactory { } } - // Field permissions exist but this field is not explicitly granted → deny - return false; + // No explicit rule for this field but other field permissions exist. + // Default to allow so new fields don't get silently stripped and fail validation. + return true; } /** diff --git a/backend/src/rbac/record-sharing.controller.ts b/backend/src/rbac/record-sharing.controller.ts index 5f5b7f0..0f1877b 100644 --- a/backend/src/rbac/record-sharing.controller.ts +++ b/backend/src/rbac/record-sharing.controller.ts @@ -45,7 +45,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -109,7 +113,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -207,7 +215,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -305,20 +317,34 @@ export class RecordSharingController { return false; } - private getTableName(apiName: string): string { - // Convert CamelCase to snake_case and pluralize - const snakeCase = apiName - .replace(/([A-Z])/g, '_$1') - .toLowerCase() - .replace(/^_/, ''); - - // Simple pluralization - if (snakeCase.endsWith('y')) { - return snakeCase.slice(0, -1) + 'ies'; - } else if (snakeCase.endsWith('s')) { - return snakeCase + 'es'; - } else { - return snakeCase + 's'; + private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); + + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(apiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } } diff --git a/backend/src/search/meilisearch.module.ts b/backend/src/search/meilisearch.module.ts new file mode 100644 index 0000000..11ae262 --- /dev/null +++ b/backend/src/search/meilisearch.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MeilisearchService } from './meilisearch.service'; + +@Module({ + providers: [MeilisearchService], + exports: [MeilisearchService], +}) +export class MeilisearchModule {} diff --git a/backend/src/search/meilisearch.service.ts b/backend/src/search/meilisearch.service.ts new file mode 100644 index 0000000..ecd5d2a --- /dev/null +++ b/backend/src/search/meilisearch.service.ts @@ -0,0 +1,244 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as http from 'http'; +import * as https from 'https'; + +type MeiliConfig = { + host: string; + apiKey?: string; + indexPrefix: string; +}; + +@Injectable() +export class MeilisearchService { + private readonly logger = new Logger(MeilisearchService.name); + + isEnabled(): boolean { + return Boolean(this.getConfig()); + } + + async searchRecord( + tenantId: string, + objectApiName: string, + query: string, + displayField?: string, + ): Promise<{ id: string; hit: any } | null> { + const config = this.getConfig(); + if (!config) return null; + + const indexName = this.buildIndexName(config, tenantId, objectApiName); + const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`; + + console.log('querying Meilisearch index:', { indexName, query, displayField }); + + try { + const response = await this.requestJson('POST', url, { + q: query, + limit: 5, + }, this.buildHeaders(config)); + + if (!this.isSuccessStatus(response.status)) { + this.logger.warn( + `Meilisearch query failed for index ${indexName}: ${response.status}`, + ); + return null; + } + + const hits = Array.isArray(response.body?.hits) ? response.body.hits : []; + if (hits.length === 0) return null; + + if (displayField) { + const loweredQuery = query.toLowerCase(); + const exactMatch = hits.find((hit: any) => { + const value = hit?.[displayField]; + return value && String(value).toLowerCase() === loweredQuery; + }); + if (exactMatch?.id) { + return { id: exactMatch.id, hit: exactMatch }; + } + } + + const match = hits[0]; + if (match?.id) { + return { id: match.id, hit: match }; + } + } catch (error) { + this.logger.warn(`Meilisearch lookup failed: ${error.message}`); + } + + return null; + } + + async searchRecords( + tenantId: string, + objectApiName: string, + query: string, + options?: { limit?: number; offset?: number }, + ): Promise<{ hits: any[]; total: number }> { + const config = this.getConfig(); + if (!config) return { hits: [], total: 0 }; + + const indexName = this.buildIndexName(config, tenantId, objectApiName); + const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`; + const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20; + const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0; + + try { + const response = await this.requestJson('POST', url, { + q: query, + limit, + offset, + }, this.buildHeaders(config)); + + console.log('Meilisearch response body:', response.body); + + if (!this.isSuccessStatus(response.status)) { + this.logger.warn( + `Meilisearch query failed for index ${indexName}: ${response.status}`, + ); + return { hits: [], total: 0 }; + } + + const hits = Array.isArray(response.body?.hits) ? response.body.hits : []; + const total = + response.body?.estimatedTotalHits ?? + response.body?.nbHits ?? + hits.length; + return { hits, total }; + } catch (error) { + this.logger.warn(`Meilisearch query failed: ${error.message}`); + return { hits: [], total: 0 }; + } + } + + async upsertRecord( + tenantId: string, + objectApiName: string, + record: Record, + fieldsToIndex: string[], + ): Promise { + const config = this.getConfig(); + if (!config || !record?.id) return; + + const indexName = this.buildIndexName(config, tenantId, objectApiName); + const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents?primaryKey=id`; + const document = this.pickRecordFields(record, fieldsToIndex); + + try { + const response = await this.requestJson('POST', url, [document], this.buildHeaders(config)); + if (!this.isSuccessStatus(response.status)) { + this.logger.warn( + `Meilisearch upsert failed for index ${indexName}: ${response.status}`, + ); + } + } catch (error) { + this.logger.warn(`Meilisearch upsert failed: ${error.message}`); + } + } + + async deleteRecord( + tenantId: string, + objectApiName: string, + recordId: string, + ): Promise { + const config = this.getConfig(); + if (!config || !recordId) return; + + const indexName = this.buildIndexName(config, tenantId, objectApiName); + const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents/${encodeURIComponent(recordId)}`; + + try { + const response = await this.requestJson('DELETE', url, undefined, this.buildHeaders(config)); + if (!this.isSuccessStatus(response.status)) { + this.logger.warn( + `Meilisearch delete failed for index ${indexName}: ${response.status}`, + ); + } + } catch (error) { + this.logger.warn(`Meilisearch delete failed: ${error.message}`); + } + } + + private getConfig(): MeiliConfig | null { + const host = process.env.MEILI_HOST || process.env.MEILISEARCH_HOST; + if (!host) return null; + const trimmedHost = host.replace(/\/+$/, ''); + const apiKey = process.env.MEILI_API_KEY || process.env.MEILISEARCH_API_KEY; + const indexPrefix = process.env.MEILI_INDEX_PREFIX || 'tenant_'; + return { host: trimmedHost, apiKey, indexPrefix }; + } + + private buildIndexName(config: MeiliConfig, tenantId: string, objectApiName: string): string { + return `${config.indexPrefix}${tenantId}_${objectApiName}`.toLowerCase(); + } + + private buildHeaders(config: MeiliConfig): Record { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (config.apiKey) { + headers['X-Meili-API-Key'] = config.apiKey; + headers.Authorization = `Bearer ${config.apiKey}`; + } + return headers; + } + + private pickRecordFields(record: Record, fields: string[]): Record { + const document: Record = { id: record.id }; + for (const field of fields) { + if (record[field] !== undefined) { + document[field] = record[field]; + } + } + return document; + } + + private isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; + } + + private requestJson( + method: 'POST' | 'DELETE', + url: string, + payload: any, + headers: Record, + ): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + const request = client.request( + { + method, + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: `${parsedUrl.pathname}${parsedUrl.search}`, + headers, + }, + (response) => { + let data = ''; + response.on('data', (chunk) => { + data += chunk; + }); + response.on('end', () => { + if (!data) { + resolve({ status: response.statusCode || 0, body: null }); + return; + } + try { + const body = JSON.parse(data); + resolve({ status: response.statusCode || 0, body }); + } catch (error) { + reject(error); + } + }); + }, + ); + + request.on('error', reject); + if (payload !== undefined) { + request.write(JSON.stringify(payload)); + } + request.end(); + }); + } +} diff --git a/frontend/components/AIChatBar.vue b/frontend/components/AIChatBar.vue index 90db51a..0699a76 100644 --- a/frontend/components/AIChatBar.vue +++ b/frontend/components/AIChatBar.vue @@ -8,26 +8,101 @@ import { } from '@/components/ui/input-group' import { Separator } from '@/components/ui/separator' import { ArrowUp } from 'lucide-vue-next' +import { useRoute } from 'vue-router' +import { useApi } from '@/composables/useApi' const chatInput = ref('') +const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([]) +const sending = ref(false) +const route = useRoute() +const { api } = useApi() -const handleSend = () => { +const buildContext = () => { + const recordId = route.params.recordId ? String(route.params.recordId) : undefined + const viewParam = route.params.view ? String(route.params.view) : undefined + const view = viewParam || (recordId ? (recordId === 'new' ? 'edit' : 'detail') : 'list') + const objectApiName = route.params.objectName + ? String(route.params.objectName) + : undefined + + return { + objectApiName, + view, + recordId, + route: route.fullPath, + } +} + +const handleSend = async () => { if (!chatInput.value.trim()) return - - // TODO: Implement AI chat send functionality - console.log('Sending message:', chatInput.value) + + const message = chatInput.value.trim() + messages.value.push({ role: 'user', text: message }) chatInput.value = '' + sending.value = true + + try { + const history = messages.value.slice(0, -1).slice(-6) + const response = await api.post('/ai/chat', { + message, + history, + context: buildContext(), + }) + + messages.value.push({ + role: 'assistant', + text: response.reply || 'Let me know what else you need.', + }) + + if (response.action === 'create_record') { + window.dispatchEvent( + new CustomEvent('ai-record-created', { + detail: { + objectApiName: buildContext().objectApiName, + record: response.record, + }, + }), + ) + } + } catch (error: any) { + console.error('Failed to send AI chat message:', error) + messages.value.push({ + role: 'assistant', + text: error.message || 'Sorry, I ran into an error. Please try again.', + }) + } finally { + sending.value = false + } }