/** * 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, ) {} /** * 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 { 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 { 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 { // 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; } }