Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant

This commit is contained in:
Francisco Gaona
2026-01-16 18:01:26 +01:00
parent 51c82d3d95
commit 20fc90a3fb
62 changed files with 6613 additions and 278 deletions

View File

@@ -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,
);
}
}

View File

@@ -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 {}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, any>;
requiredFields?: string[];
missingFields?: string[];
action?: AiAssistantReply['action'];
record?: any;
reply?: string;
}

View File

@@ -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[];
}

View File

@@ -0,0 +1,10 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class AiChatMessageDto {
@IsIn(['user', 'assistant'])
role: 'user' | 'assistant';
@IsString()
@IsNotEmpty()
text: string;
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<string, any>) {
const mapped: Record<string, any> = {};
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<string, any>) {
const mapped: Record<string, any> = {};
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;

View File

@@ -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',
},
},
};
}

View File

@@ -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',
},
},
};
}

View File

@@ -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.

View File

@@ -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,
})),
};
}

View File

@@ -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<string, any> {
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`;
}
}

View File

@@ -16,13 +16,17 @@ export class ModelRegistry {
*/
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): 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<BaseModel> {
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;

View File

@@ -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}`);

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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,
);
}
}

View File

@@ -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;
}
/**

View File

@@ -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,

View File

@@ -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()

View File

@@ -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;
}
/**

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MeilisearchService } from './meilisearch.service';
@Module({
providers: [MeilisearchService],
exports: [MeilisearchService],
})
export class MeilisearchModule {}

View File

@@ -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<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();
});
}
}