WIP - add meilisearch for easier record find for AI assistant
This commit is contained in:
5
.env.api
5
.env.api
@@ -5,6 +5,11 @@ DATABASE_URL="mysql://platform:platform@db:3306/platform"
|
|||||||
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
|
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
|
||||||
REDIS_URL="redis://redis:6379"
|
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, multi-tenant hints, etc.
|
||||||
JWT_SECRET="devsecret"
|
JWT_SECRET="devsecret"
|
||||||
TENANCY_STRATEGY="single-db"
|
TENANCY_STRATEGY="single-db"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { AiAssistantService } from './ai-assistant.service';
|
|||||||
import { ObjectModule } from '../object/object.module';
|
import { ObjectModule } from '../object/object.module';
|
||||||
import { PageLayoutModule } from '../page-layout/page-layout.module';
|
import { PageLayoutModule } from '../page-layout/page-layout.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { MeilisearchModule } from '../search/meilisearch.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ObjectModule, PageLayoutModule, TenantModule],
|
imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule],
|
||||||
controllers: [AiAssistantController],
|
controllers: [AiAssistantController],
|
||||||
providers: [AiAssistantService],
|
providers: [AiAssistantService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|||||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||||
import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
||||||
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
||||||
|
import { MeilisearchService } from '../search/meilisearch.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiAssistantService {
|
export class AiAssistantService {
|
||||||
@@ -24,6 +25,7 @@ export class AiAssistantService {
|
|||||||
private readonly objectService: ObjectService,
|
private readonly objectService: ObjectService,
|
||||||
private readonly pageLayoutService: PageLayoutService,
|
private readonly pageLayoutService: PageLayoutService,
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
private readonly tenantDbService: TenantDatabaseService,
|
||||||
|
private readonly meilisearchService: MeilisearchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleChat(
|
async handleChat(
|
||||||
@@ -174,12 +176,21 @@ export class AiAssistantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newExtraction = openAiConfig
|
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);
|
: this.extractWithHeuristics(state.message, fieldDefinitions);
|
||||||
const mergedExtraction = {
|
const mergedExtraction = this.enrichPolymorphicLookupFromMessage(
|
||||||
...(state.extractedFields || {}),
|
state.message,
|
||||||
...(newExtraction || {}),
|
state.objectDefinition,
|
||||||
};
|
{
|
||||||
|
...(state.extractedFields || {}),
|
||||||
|
...(newExtraction || {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -220,6 +231,37 @@ export class AiAssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enrichPolymorphicLookupFromMessage(
|
||||||
|
message: string,
|
||||||
|
objectDefinition: any,
|
||||||
|
extracted: Record<string, any>,
|
||||||
|
): Record<string, any> {
|
||||||
|
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(
|
private async createRecord(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -610,6 +652,23 @@ export class AiAssistantService {
|
|||||||
targetDefinition.pluralLabel,
|
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)
|
const record = await knex(tableName)
|
||||||
.whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()])
|
.whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()])
|
||||||
.first();
|
.first();
|
||||||
@@ -839,6 +898,19 @@ export class AiAssistantService {
|
|||||||
: this.toTableName(targetApiName);
|
: this.toTableName(targetApiName);
|
||||||
|
|
||||||
const providedValue = typeof value === 'string' ? value.trim() : value;
|
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 =
|
const record =
|
||||||
providedValue && typeof providedValue === 'string'
|
providedValue && typeof providedValue === 'string'
|
||||||
? await knex(tableName)
|
? await knex(tableName)
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { MigrationModule } from '../migration/migration.module';
|
|||||||
import { RbacModule } from '../rbac/rbac.module';
|
import { RbacModule } from '../rbac/rbac.module';
|
||||||
import { ModelRegistry } from './models/model.registry';
|
import { ModelRegistry } from './models/model.registry';
|
||||||
import { ModelService } from './models/model.service';
|
import { ModelService } from './models/model.service';
|
||||||
|
import { MeilisearchModule } from '../search/meilisearch.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule, MigrationModule, RbacModule],
|
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
|
||||||
providers: [
|
providers: [
|
||||||
ObjectService,
|
ObjectService,
|
||||||
SchemaManagementService,
|
SchemaManagementService,
|
||||||
|
|||||||
@@ -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 { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||||
import { ModelService } from './models/model.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 { FieldDefinition } from '../models/field-definition.model';
|
||||||
import { User } from '../models/user.model';
|
import { User } from '../models/user.model';
|
||||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||||
|
import { MeilisearchService } from '../search/meilisearch.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ObjectService {
|
export class ObjectService {
|
||||||
@@ -18,6 +19,7 @@ export class ObjectService {
|
|||||||
private customMigrationService: CustomMigrationService,
|
private customMigrationService: CustomMigrationService,
|
||||||
private modelService: ModelService,
|
private modelService: ModelService,
|
||||||
private authService: AuthorizationService,
|
private authService: AuthorizationService,
|
||||||
|
private meilisearchService: MeilisearchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Setup endpoints - Object metadata management
|
// Setup endpoints - Object metadata management
|
||||||
@@ -820,7 +822,13 @@ export class ObjectService {
|
|||||||
...editableData,
|
...editableData,
|
||||||
...(hasOwnerField ? { ownerId: userId } : {}),
|
...(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;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,8 +889,15 @@ export class ObjectService {
|
|||||||
|
|
||||||
// Use Objection model
|
// Use Objection model
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
await boundModel.query().where({ id: recordId }).update(editableData);
|
const normalizedEditableData = await this.normalizePolymorphicRelatedObject(
|
||||||
return boundModel.query().where({ id: recordId }).first();
|
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(
|
async deleteRecord(
|
||||||
@@ -932,10 +947,163 @@ export class ObjectService {
|
|||||||
// Use Objection model
|
// Use Objection model
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
await boundModel.query().where({ id: recordId }).delete();
|
await boundModel.query().where({ id: recordId }).delete();
|
||||||
|
await this.removeIndexedRecord(resolvedTenantId, objectApiName, recordId);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async indexRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
fields: FieldDefinition[],
|
||||||
|
record: Record<string, any>,
|
||||||
|
) {
|
||||||
|
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<any> {
|
||||||
|
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
|
* Update a field definition
|
||||||
* Can update metadata (label, description, placeholder, helpText, etc.) safely
|
* Can update metadata (label, description, placeholder, helpText, etc.) safely
|
||||||
|
|||||||
8
backend/src/search/meilisearch.module.ts
Normal file
8
backend/src/search/meilisearch.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MeilisearchService } from './meilisearch.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MeilisearchService],
|
||||||
|
exports: [MeilisearchService],
|
||||||
|
})
|
||||||
|
export class MeilisearchModule {}
|
||||||
200
backend/src/search/meilisearch.service.ts
Normal file
200
backend/src/search/meilisearch.service.ts
Normal file
@@ -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<string, any>,
|
||||||
|
fieldsToIndex: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'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<string, any>, fields: string[]): Record<string, any> {
|
||||||
|
const document: Record<string, any> = { 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<string, string>,
|
||||||
|
): 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
- meilisearch
|
||||||
networks:
|
networks:
|
||||||
- platform-network
|
- platform-network
|
||||||
|
|
||||||
@@ -66,9 +67,24 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- platform-network
|
- 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:
|
volumes:
|
||||||
percona-data:
|
percona-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
meili-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
platform-network:
|
platform-network:
|
||||||
|
|||||||
Reference in New Issue
Block a user