144 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|