Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant
This commit is contained in:
244
backend/src/search/meilisearch.service.ts
Normal file
244
backend/src/search/meilisearch.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user