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