project init
This commit is contained in:
156
apps/api/src/search/meilisearch.service.ts
Normal file
156
apps/api/src/search/meilisearch.service.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MeiliSearch, Index } from 'meilisearch';
|
||||
|
||||
export interface CourseDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
status: string;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
tags: string[];
|
||||
difficulty: string | null;
|
||||
price: number | null;
|
||||
isPublished: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MeilisearchService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MeilisearchService.name);
|
||||
private client: MeiliSearch;
|
||||
private coursesIndex: Index<CourseDocument> | null = null;
|
||||
private isAvailable = false;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.client = new MeiliSearch({
|
||||
host: this.configService.get<string>('MEILISEARCH_HOST') || 'http://localhost:7700',
|
||||
apiKey: this.configService.get<string>('MEILISEARCH_API_KEY'),
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.setupIndexes();
|
||||
this.isAvailable = true;
|
||||
this.logger.log('Meilisearch connected successfully');
|
||||
} catch (error) {
|
||||
this.logger.warn('Meilisearch is not available. Search functionality will be disabled.');
|
||||
this.isAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupIndexes() {
|
||||
// Create courses index
|
||||
try {
|
||||
await this.client.createIndex('courses', { primaryKey: 'id' });
|
||||
} catch {
|
||||
// Index might already exist
|
||||
}
|
||||
|
||||
this.coursesIndex = this.client.index<CourseDocument>('courses');
|
||||
|
||||
// Configure searchable attributes
|
||||
await this.coursesIndex.updateSearchableAttributes([
|
||||
'title',
|
||||
'description',
|
||||
'tags',
|
||||
'authorName',
|
||||
'categoryName',
|
||||
]);
|
||||
|
||||
// Configure filterable attributes
|
||||
await this.coursesIndex.updateFilterableAttributes([
|
||||
'authorId',
|
||||
'status',
|
||||
'categoryId',
|
||||
'tags',
|
||||
'difficulty',
|
||||
'isPublished',
|
||||
'price',
|
||||
]);
|
||||
|
||||
// Configure sortable attributes
|
||||
await this.coursesIndex.updateSortableAttributes([
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'price',
|
||||
'title',
|
||||
]);
|
||||
|
||||
// Configure ranking rules
|
||||
await this.coursesIndex.updateRankingRules([
|
||||
'words',
|
||||
'typo',
|
||||
'proximity',
|
||||
'attribute',
|
||||
'sort',
|
||||
'exactness',
|
||||
]);
|
||||
}
|
||||
|
||||
async indexCourse(course: CourseDocument): Promise<void> {
|
||||
if (!this.isAvailable || !this.coursesIndex) return;
|
||||
await this.coursesIndex.addDocuments([course]);
|
||||
}
|
||||
|
||||
async updateCourse(course: CourseDocument): Promise<void> {
|
||||
if (!this.isAvailable || !this.coursesIndex) return;
|
||||
await this.coursesIndex.updateDocuments([course]);
|
||||
}
|
||||
|
||||
async deleteCourse(courseId: string): Promise<void> {
|
||||
if (!this.isAvailable || !this.coursesIndex) return;
|
||||
await this.coursesIndex.deleteDocument(courseId);
|
||||
}
|
||||
|
||||
async searchCourses(
|
||||
query: string,
|
||||
options?: {
|
||||
filter?: string;
|
||||
sort?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
) {
|
||||
if (!this.isAvailable || !this.coursesIndex) {
|
||||
return {
|
||||
hits: [],
|
||||
query,
|
||||
processingTimeMs: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const results = await this.coursesIndex.search(query, {
|
||||
filter: options?.filter,
|
||||
sort: options?.sort,
|
||||
limit: options?.limit || 20,
|
||||
offset: options?.offset || 0,
|
||||
attributesToHighlight: ['title', 'description'],
|
||||
});
|
||||
|
||||
return {
|
||||
hits: results.hits,
|
||||
query: results.query,
|
||||
processingTimeMs: results.processingTimeMs,
|
||||
total: results.estimatedTotalHits,
|
||||
};
|
||||
}
|
||||
|
||||
async indexAllCourses(courses: CourseDocument[]): Promise<void> {
|
||||
if (!this.isAvailable || !this.coursesIndex || courses.length === 0) return;
|
||||
|
||||
// Batch index in chunks of 1000
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < courses.length; i += chunkSize) {
|
||||
const chunk = courses.slice(i, i + chunkSize);
|
||||
await this.coursesIndex.addDocuments(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/api/src/search/search.controller.ts
Normal file
58
apps/api/src/search/search.controller.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { SearchService } from './search.service';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@ApiTags('search')
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private searchService: SearchService) {}
|
||||
|
||||
@Get('courses')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Search courses' })
|
||||
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
|
||||
@ApiQuery({ name: 'categoryId', required: false })
|
||||
@ApiQuery({ name: 'difficulty', required: false, enum: ['beginner', 'intermediate', 'advanced'] })
|
||||
@ApiQuery({ name: 'tags', required: false, type: [String] })
|
||||
@ApiQuery({ name: 'priceMin', required: false, type: Number })
|
||||
@ApiQuery({ name: 'priceMax', required: false, type: Number })
|
||||
@ApiQuery({ name: 'sort', required: false, enum: ['newest', 'oldest', 'price_asc', 'price_desc', 'title_asc', 'title_desc'] })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async searchCourses(
|
||||
@Query('q') query: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
@Query('difficulty') difficulty?: string,
|
||||
@Query('tags') tags?: string[],
|
||||
@Query('priceMin') priceMin?: number,
|
||||
@Query('priceMax') priceMax?: number,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number
|
||||
) {
|
||||
return this.searchService.searchCourses(query, {
|
||||
categoryId,
|
||||
difficulty,
|
||||
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
|
||||
priceMin,
|
||||
priceMax,
|
||||
sort,
|
||||
page,
|
||||
limit,
|
||||
publishedOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('reindex')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Reindex all courses (admin only)' })
|
||||
async reindexAll() {
|
||||
return this.searchService.reindexAllCourses();
|
||||
}
|
||||
}
|
||||
11
apps/api/src/search/search.module.ts
Normal file
11
apps/api/src/search/search.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import { MeilisearchService } from './meilisearch.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService, MeilisearchService],
|
||||
exports: [SearchService, MeilisearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
181
apps/api/src/search/search.service.ts
Normal file
181
apps/api/src/search/search.service.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { MeilisearchService, CourseDocument } from './meilisearch.service';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private meilisearch: MeilisearchService
|
||||
) {}
|
||||
|
||||
async searchCourses(
|
||||
query: string,
|
||||
options?: {
|
||||
categoryId?: string;
|
||||
difficulty?: string;
|
||||
tags?: string[];
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
authorId?: string;
|
||||
publishedOnly?: boolean;
|
||||
sort?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
) {
|
||||
const filters: string[] = [];
|
||||
|
||||
if (options?.publishedOnly !== false) {
|
||||
filters.push('isPublished = true');
|
||||
}
|
||||
|
||||
if (options?.categoryId) {
|
||||
filters.push(`categoryId = "${options.categoryId}"`);
|
||||
}
|
||||
|
||||
if (options?.difficulty) {
|
||||
filters.push(`difficulty = "${options.difficulty}"`);
|
||||
}
|
||||
|
||||
if (options?.tags && options.tags.length > 0) {
|
||||
const tagFilters = options.tags.map((tag) => `tags = "${tag}"`).join(' OR ');
|
||||
filters.push(`(${tagFilters})`);
|
||||
}
|
||||
|
||||
if (options?.priceMin !== undefined) {
|
||||
filters.push(`price >= ${options.priceMin}`);
|
||||
}
|
||||
|
||||
if (options?.priceMax !== undefined) {
|
||||
filters.push(`price <= ${options.priceMax}`);
|
||||
}
|
||||
|
||||
if (options?.authorId) {
|
||||
filters.push(`authorId = "${options.authorId}"`);
|
||||
}
|
||||
|
||||
// Parse sort option
|
||||
let sort: string[] | undefined;
|
||||
if (options?.sort) {
|
||||
switch (options.sort) {
|
||||
case 'newest':
|
||||
sort = ['createdAt:desc'];
|
||||
break;
|
||||
case 'oldest':
|
||||
sort = ['createdAt:asc'];
|
||||
break;
|
||||
case 'price_asc':
|
||||
sort = ['price:asc'];
|
||||
break;
|
||||
case 'price_desc':
|
||||
sort = ['price:desc'];
|
||||
break;
|
||||
case 'title_asc':
|
||||
sort = ['title:asc'];
|
||||
break;
|
||||
case 'title_desc':
|
||||
sort = ['title:desc'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const results = await this.meilisearch.searchCourses(query, {
|
||||
filter: filters.length > 0 ? filters.join(' AND ') : undefined,
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
data: results.hits,
|
||||
meta: {
|
||||
query: results.query,
|
||||
processingTimeMs: results.processingTimeMs,
|
||||
total: results.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil((results.total || 0) / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async indexCourse(courseId: string) {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
category: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) return;
|
||||
|
||||
const document: CourseDocument = {
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
slug: course.slug,
|
||||
authorId: course.authorId,
|
||||
authorName: course.author.name,
|
||||
status: course.status,
|
||||
categoryId: course.categoryId,
|
||||
categoryName: course.category?.name || null,
|
||||
tags: course.tags,
|
||||
difficulty: course.difficulty,
|
||||
price: course.price ? Number(course.price) : null,
|
||||
isPublished: course.isPublished,
|
||||
createdAt: course.createdAt.getTime(),
|
||||
updatedAt: course.updatedAt.getTime(),
|
||||
};
|
||||
|
||||
await this.meilisearch.indexCourse(document);
|
||||
}
|
||||
|
||||
async deleteCourseFromIndex(courseId: string) {
|
||||
await this.meilisearch.deleteCourse(courseId);
|
||||
}
|
||||
|
||||
async reindexAllCourses() {
|
||||
const courses = await this.prisma.course.findMany({
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
category: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const documents: CourseDocument[] = courses.map((course) => ({
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
slug: course.slug,
|
||||
authorId: course.authorId,
|
||||
authorName: course.author.name,
|
||||
status: course.status,
|
||||
categoryId: course.categoryId,
|
||||
categoryName: course.category?.name || null,
|
||||
tags: course.tags,
|
||||
difficulty: course.difficulty,
|
||||
price: course.price ? Number(course.price) : null,
|
||||
isPublished: course.isPublished,
|
||||
createdAt: course.createdAt.getTime(),
|
||||
updatedAt: course.updatedAt.getTime(),
|
||||
}));
|
||||
|
||||
await this.meilisearch.indexAllCourses(documents);
|
||||
|
||||
return { indexed: documents.length };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user