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/src/ai-assistant/ai-assistant.module.ts b/backend/src/ai-assistant/ai-assistant.module.ts index f80af6d..33473db 100644 --- a/backend/src/ai-assistant/ai-assistant.module.ts +++ b/backend/src/ai-assistant/ai-assistant.module.ts @@ -4,9 +4,10 @@ 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], + imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule], controllers: [AiAssistantController], providers: [AiAssistantService], }) diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index bb4015c..2ae296b 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -9,6 +9,7 @@ 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'; @Injectable() export class AiAssistantService { @@ -24,6 +25,7 @@ export class AiAssistantService { private readonly objectService: ObjectService, private readonly pageLayoutService: PageLayoutService, private readonly tenantDbService: TenantDatabaseService, + private readonly meilisearchService: MeilisearchService, ) {} async handleChat( @@ -174,12 +176,21 @@ export class AiAssistantService { } const newExtraction = openAiConfig - ? await this.extractWithOpenAI(openAiConfig, state.message, state.objectDefinition.label, fieldDefinitions) + ? await this.extractWithOpenAI( + openAiConfig, + state.message, + state.objectDefinition.label, + fieldDefinitions, + ) : this.extractWithHeuristics(state.message, fieldDefinitions); - const mergedExtraction = { - ...(state.extractedFields || {}), - ...(newExtraction || {}), - }; + const mergedExtraction = this.enrichPolymorphicLookupFromMessage( + state.message, + state.objectDefinition, + { + ...(state.extractedFields || {}), + ...(newExtraction || {}), + }, + ); return { ...state, @@ -220,6 +231,37 @@ export class AiAssistantService { }; } + 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, @@ -610,6 +652,23 @@ export class AiAssistantService { 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(); @@ -839,6 +898,19 @@ export class AiAssistantService { : this.toTableName(targetApiName); const providedValue = typeof value === 'string' ? value.trim() : value; + if (providedValue && typeof providedValue === 'string') { + const meiliMatch = await this.meilisearchService.searchRecord( + resolvedTenantId, + targetDefinition?.apiName || targetApiName, + providedValue, + displayField, + ); + if (meiliMatch?.id) { + resolvedFields[field.apiName] = meiliMatch.id; + continue; + } + } + const record = providedValue && typeof providedValue === 'string' ? await knex(tableName) 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 d15cb21..ab896e9 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -1,4 +1,4 @@ -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'; @@ -8,6 +8,7 @@ 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'; @Injectable() export class ObjectService { @@ -18,6 +19,7 @@ export class ObjectService { private customMigrationService: CustomMigrationService, private modelService: ModelService, private authService: AuthorizationService, + private meilisearchService: MeilisearchService, ) {} // Setup endpoints - Object metadata management @@ -820,7 +822,13 @@ export class ObjectService { ...editableData, ...(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; } @@ -881,8 +889,15 @@ export class ObjectService { // 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, + ); + await boundModel.query().where({ id: recordId }).update(normalizedEditableData); + const record = await boundModel.query().where({ id: recordId }).first(); + await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); + return record; } async deleteRecord( @@ -932,10 +947,163 @@ export class ObjectService { // 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 }; } + 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)); + + await this.meilisearchService.upsertRecord( + tenantId, + objectApiName, + record, + fieldsToIndex, + ); + } + + 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 [ + 'id', + 'ownerId', + 'created_at', + 'updated_at', + 'createdAt', + 'updatedAt', + 'tenantId', + ].includes(apiName); + } + + 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 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..c40d6b8 --- /dev/null +++ b/backend/src/search/meilisearch.service.ts @@ -0,0 +1,200 @@ +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`; + + 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 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/infra/docker-compose.yml b/infra/docker-compose.yml index d227c89..d742eb5 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -17,6 +17,7 @@ services: depends_on: - db - redis + - meilisearch networks: - platform-network @@ -66,9 +67,24 @@ services: networks: - platform-network + meilisearch: + image: getmeili/meilisearch:v1.7 + container_name: platform-meilisearch + restart: unless-stopped + environment: + MEILI_ENV: development + MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-master-key} + ports: + - "7700:7700" + volumes: + - meili-data:/meili_data + networks: + - platform-network + volumes: percona-data: redis-data: + meili-data: networks: platform-network: