diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40e2785..15d06b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -602,3 +602,10 @@ model SearchSuggestion { @@index([expiresAt]) @@map("search_suggestions") } + +model Project { + id String @id @default(uuid()) + title String + description String + @@index([title, description], type: FullText) +} diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 4a8f08e..6cf8049 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -142,3 +142,14 @@ Summary: ${payload.description} // }); } } + +async sendEmail(userId: string, subject: string, body: string) { + const unsubscribeLink = `${process.env.APP_URL}/unsubscribe?userId=${userId}`; + const footer = `\n\nIf you no longer wish to receive these emails, click here to unsubscribe: ${unsubscribeLink}`; + + await this.mailer.sendMail({ + to: userId, + subject, + text: body + footer, + }); +} diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 0ef361c..3114877 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -24,6 +24,14 @@ export class SearchController { return this.searchService.searchProperties(req.user.id, searchQuery); } + @Get('projects') + @ApiOperation({ summary: 'Full-text search for projects' }) + @ApiQuery({ name: 'q', required: true, description: 'Search query' }) + @ApiResponse({ status: 200, description: 'Project search results returned successfully' }) + async searchProjects(@Query('q') query: string, @Query() filters: any) { + return this.searchService.searchProjects(query, filters); + } + @Get('suggestions') @ApiOperation({ summary: 'Get search autocomplete suggestions' }) @ApiQuery({ name: 'q', required: false, description: 'Search query' }) @@ -32,6 +40,14 @@ export class SearchController { return this.searchService.getSuggestions(query || ''); } + @Get('terms') + @ApiOperation({ summary: 'Get full-text search term suggestions' }) + @ApiQuery({ name: 'q', required: true, description: 'Search query prefix' }) + @ApiResponse({ status: 200, description: 'Search term suggestions returned successfully' }) + async suggestTerms(@Query('q') query: string) { + return this.searchService.suggestTerms(query); + } + @Get('filters/saved') @ApiOperation({ summary: 'Get user\'s saved filters' }) @ApiResponse({ status: 200, description: 'Saved filters returned successfully' }) diff --git a/src/search/search.service.ts b/src/search/search.service.ts index cef7c4e..b65b915 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -56,10 +56,8 @@ export class SearchService { const queryId = await this.analyticsService.recordSearch(userId, searchQuery); try { - // Build base query let whereClause: any = {}; - // Apply text search if (searchQuery.query) { whereClause.OR = [ { title: { contains: searchQuery.query, mode: 'insensitive' } }, @@ -70,7 +68,6 @@ export class SearchService { ]; } - // Apply geographic filters if (searchQuery.geographic) { whereClause = await this.geographicService.applyGeographicFilter( whereClause, @@ -78,7 +75,6 @@ export class SearchService { ); } - // Apply advanced filters if (searchQuery.filters) { whereClause = await this.filtersService.applyFilters( whereClause, @@ -86,15 +82,18 @@ export class SearchService { ); } - // Execute query with sorting and pagination const { page = 1, limit = 20 } = searchQuery.pagination || {}; const { field = 'createdAt', order = 'desc' } = searchQuery.sort || {}; - // Mock data for now - this would typically query the database - const items: any[] = []; - const total = 0; + const items: any[] = await this.prisma.property.findMany({ + where: whereClause, + orderBy: { [field]: order }, + skip: (page - 1) * limit, + take: limit, + }); + + const total = await this.prisma.property.count({ where: whereClause }); - // Generate facets const facets = await this.facetsService.buildFacets(items, [ 'propertyType', 'status', @@ -104,17 +103,15 @@ export class SearchService { 'bathrooms', ]); - // Get suggestions const suggestions = await this.autocompleteService.getSuggestions( searchQuery.query || '', ); - // Record search history if (searchQuery.query) { this.historyService.record(userId, searchQuery.query); } - const result: SearchResult = { + return { items, total, facets, @@ -124,14 +121,34 @@ export class SearchService { took: Date.now() - startTime, }, }; - - return result; } catch (error) { await this.analyticsService.recordSearchError(queryId, error); throw error; } } + // PostgreSQL full-text search for projects + async searchProjects(query: string, filters?: any) { + return this.prisma.$queryRaw` + SELECT id, title, description, + ts_rank_cd(to_tsvector('english', title || ' ' || description), plainto_tsquery(${query})) AS rank + FROM "Project" + WHERE to_tsvector('english', title || ' ' || description) @@ plainto_tsquery(${query}) + ORDER BY rank DESC + LIMIT 20; + `; + } + + async suggestTerms(query: string) { + return this.prisma.$queryRaw` + SELECT word + FROM ts_stat('SELECT to_tsvector(''english'', title || '' '' || description) FROM "Project"') + WHERE word LIKE ${query || ''} || '%' + ORDER BY nentry DESC + LIMIT 5; + `; + } + async getSuggestions(query: string): Promise { return this.autocompleteService.getSuggestions(query); } @@ -151,5 +168,4 @@ export class SearchService { async getPopularSearches(): Promise { return this.analyticsService.getPopularSearches(); } - }