251123:2300 Update T1

This commit is contained in:
2025-11-24 08:15:15 +07:00
parent 23006898d9
commit 9360d78ea6
81 changed files with 4232 additions and 347 deletions
@@ -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 } };
}
}
}