project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View 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);
}
}
}

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

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

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