Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
16 changes: 16 additions & 0 deletions src/search/search.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -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' })
Expand Down
46 changes: 31 additions & 15 deletions src/search/search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand All @@ -70,31 +68,32 @@ export class SearchService {
];
}

// Apply geographic filters
if (searchQuery.geographic) {
whereClause = await this.geographicService.applyGeographicFilter(
whereClause,
searchQuery.geographic,
);
}

// Apply advanced filters
if (searchQuery.filters) {
whereClause = await this.filtersService.applyFilters(
whereClause,
searchQuery.filters,
);
}

// 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',
Expand All @@ -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<any> = {
return {
items,
total,
facets,
Expand All @@ -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<string[]> {
return this.autocompleteService.getSuggestions(query);
}
Expand All @@ -151,5 +168,4 @@ export class SearchService {
async getPopularSearches(): Promise<string[]> {
return this.analyticsService.getPopularSearches();
}

}
Loading