251123:2300 Update T1
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { IsString, IsOptional, IsInt, IsNotEmpty } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SearchQueryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
q?: string; // คำค้นหา (Query)
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
type?: string; // กรองประเภท: 'rfa', 'correspondence', 'drawing'
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
projectId?: number;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
page: number = 1;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
limit: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchQueryDto } from './dto/search-query.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('Search')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Advanced Search across all documents' })
|
||||
@RequirePermission('search.advanced') // สิทธิ์ ID 48
|
||||
search(@Query() queryDto: SearchQueryDto) {
|
||||
return this.searchService.search(queryDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ElasticsearchModule } from '@nestjs/elasticsearch';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchController } from './search.controller';
|
||||
import { UserModule } from '../user/user.module'; // ✅ 1. Import UserModule
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
// ✅ 2. เพิ่ม UserModule เข้าไปใน imports
|
||||
UserModule,
|
||||
|
||||
ElasticsearchModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
node:
|
||||
configService.get<string>('ELASTICSEARCH_NODE') ||
|
||||
'http://localhost:9200',
|
||||
auth: {
|
||||
username: configService.get<string>('ELASTICSEARCH_USERNAME') || '',
|
||||
password: configService.get<string>('ELASTICSEARCH_PASSWORD') || '',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ElasticsearchService } from '@nestjs/elasticsearch';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SearchQueryDto } from './dto/search-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SearchService.name);
|
||||
private readonly indexName = 'dms_documents';
|
||||
|
||||
constructor(
|
||||
private readonly esService: ElasticsearchService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.createIndexIfNotExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Index และกำหนด Mapping (Schema)
|
||||
*/
|
||||
private async createIndexIfNotExists() {
|
||||
try {
|
||||
const indexExists = await this.esService.indices.exists({
|
||||
index: this.indexName,
|
||||
});
|
||||
|
||||
if (!indexExists) {
|
||||
// ✅ FIX: Cast 'body' เป็น any เพื่อแก้ปัญหา Type Mismatch ของ Library
|
||||
await this.esService.indices.create({
|
||||
index: this.indexName,
|
||||
body: {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
type: { type: 'keyword' }, // correspondence, rfa, drawing
|
||||
docNumber: { type: 'text' },
|
||||
title: { type: 'text', analyzer: 'standard' },
|
||||
description: { type: 'text', analyzer: 'standard' },
|
||||
status: { type: 'keyword' },
|
||||
projectId: { type: 'integer' },
|
||||
createdAt: { type: 'date' },
|
||||
tags: { type: 'text' },
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
this.logger.log(`Elasticsearch index '${this.indexName}' created.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create index: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index เอกสาร (Create/Update)
|
||||
*/
|
||||
async indexDocument(doc: any) {
|
||||
try {
|
||||
return await this.esService.index({
|
||||
index: this.indexName,
|
||||
id: `${doc.type}_${doc.id}`, // Unique ID: rfa_101
|
||||
document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to index document: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบเอกสารออกจาก Index
|
||||
*/
|
||||
async removeDocument(type: string, id: number) {
|
||||
try {
|
||||
await this.esService.delete({
|
||||
index: this.indexName,
|
||||
id: `${type}_${id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to remove document: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหาเอกสาร (Full-text Search)
|
||||
*/
|
||||
async search(queryDto: SearchQueryDto) {
|
||||
const { q, type, projectId, page = 1, limit = 20 } = queryDto;
|
||||
const from = (page - 1) * limit;
|
||||
|
||||
const mustQueries: any[] = [];
|
||||
|
||||
// 1. Full-text search logic
|
||||
if (q) {
|
||||
mustQueries.push({
|
||||
multi_match: {
|
||||
query: q,
|
||||
fields: ['title^3', 'docNumber^2', 'description', 'tags'], // Boost ความสำคัญ
|
||||
fuzziness: 'AUTO',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mustQueries.push({ match_all: {} });
|
||||
}
|
||||
|
||||
// 2. Filter logic
|
||||
const filterQueries: any[] = [];
|
||||
if (type) filterQueries.push({ term: { type } });
|
||||
if (projectId) filterQueries.push({ term: { projectId } });
|
||||
|
||||
try {
|
||||
const result = await this.esService.search({
|
||||
index: this.indexName,
|
||||
from,
|
||||
size: limit,
|
||||
// ✅ ส่ง Query Structure โดยตรง
|
||||
query: {
|
||||
bool: {
|
||||
must: mustQueries,
|
||||
filter: filterQueries,
|
||||
},
|
||||
},
|
||||
sort: [{ createdAt: { order: 'desc' } }],
|
||||
});
|
||||
|
||||
// 3. Format Result
|
||||
const hits = result.hits.hits;
|
||||
const total =
|
||||
typeof result.hits.total === 'number'
|
||||
? result.hits.total
|
||||
: result.hits.total?.value || 0;
|
||||
|
||||
return {
|
||||
data: hits.map((hit) => hit._source),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
took: result.took,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Search failed: ${(error as Error).message}`);
|
||||
return { data: [], meta: { total: 0, page, limit, took: 0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user