xpeditis2.0/apps/backend/src/application/services/fuzzy-search.service.ts
David-Henri ARNAUD c5c15eb1f9 feature phase 3
2025-10-13 17:54:32 +02:00

144 lines
4.4 KiB
TypeScript

/**
* Fuzzy Search Service
*
* Provides fuzzy search capabilities for bookings using PostgreSQL full-text search
* and Levenshtein distance for typo tolerance
*/
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
@Injectable()
export class FuzzySearchService {
private readonly logger = new Logger(FuzzySearchService.name);
constructor(
@InjectRepository(BookingOrmEntity)
private readonly bookingOrmRepository: Repository<BookingOrmEntity>,
) {}
/**
* Fuzzy search for bookings by booking number, shipper, or consignee
* Uses PostgreSQL full-text search with trigram similarity
*/
async fuzzySearchBookings(
searchTerm: string,
organizationId: string,
limit: number = 20,
): Promise<BookingOrmEntity[]> {
if (!searchTerm || searchTerm.length < 2) {
return [];
}
this.logger.log(
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
);
// Use PostgreSQL full-text search with similarity
// This requires pg_trgm extension to be enabled
const results = await this.bookingOrmRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.containers', 'containers')
.where('booking.organization_id = :organizationId', { organizationId })
.andWhere(
`(
similarity(booking.booking_number, :searchTerm) > 0.3
OR booking.booking_number ILIKE :likeTerm
OR similarity(booking.shipper_name, :searchTerm) > 0.3
OR booking.shipper_name ILIKE :likeTerm
OR similarity(booking.consignee_name, :searchTerm) > 0.3
OR booking.consignee_name ILIKE :likeTerm
)`,
{
searchTerm,
likeTerm: `%${searchTerm}%`,
},
)
.orderBy(
`GREATEST(
similarity(booking.booking_number, :searchTerm),
similarity(booking.shipper_name, :searchTerm),
similarity(booking.consignee_name, :searchTerm)
)`,
'DESC',
)
.setParameter('searchTerm', searchTerm)
.limit(limit)
.getMany();
this.logger.log(`Found ${results.length} results for fuzzy search`);
return results;
}
/**
* Search for bookings using PostgreSQL full-text search with ts_vector
* This provides better performance for large datasets
*/
async fullTextSearch(
searchTerm: string,
organizationId: string,
limit: number = 20,
): Promise<BookingOrmEntity[]> {
if (!searchTerm || searchTerm.length < 2) {
return [];
}
this.logger.log(
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
);
// Convert search term to tsquery format
const tsquery = searchTerm
.split(/\s+/)
.filter((term) => term.length > 0)
.map((term) => `${term}:*`)
.join(' & ');
const results = await this.bookingOrmRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.containers', 'containers')
.where('booking.organization_id = :organizationId', { organizationId })
.andWhere(
`(
to_tsvector('english', booking.booking_number) @@ to_tsquery('english', :tsquery)
OR to_tsvector('english', booking.shipper_name) @@ to_tsquery('english', :tsquery)
OR to_tsvector('english', booking.consignee_name) @@ to_tsquery('english', :tsquery)
OR booking.booking_number ILIKE :likeTerm
)`,
{
tsquery,
likeTerm: `%${searchTerm}%`,
},
)
.orderBy('booking.created_at', 'DESC')
.limit(limit)
.getMany();
this.logger.log(`Found ${results.length} results for full-text search`);
return results;
}
/**
* Combined search that tries fuzzy search first, falls back to full-text if no results
*/
async search(
searchTerm: string,
organizationId: string,
limit: number = 20,
): Promise<BookingOrmEntity[]> {
// Try fuzzy search first (more tolerant to typos)
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
// If no results, try full-text search
if (results.length === 0) {
results = await this.fullTextSearch(searchTerm, organizationId, limit);
}
return results;
}
}