Skip to content

Commit 5488abb

Browse files
authored
Merge pull request #67 from ahmadogo/propSearch
Implement Comprehensive Property Search and Filtering Engine
2 parents ded56fd + a2ea3c3 commit 5488abb

8 files changed

Lines changed: 229 additions & 16 deletions

prisma/schema.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ model Property {
6565
valuations PropertyValuation[]
6666
documents Document[]
6767
68+
latitude Float?
69+
longitude Float?
70+
71+
72+
@@index([latitude, longitude])
6873
@@index([ownerId])
6974
@@index([status])
7075
@@index([createdAt])
@@ -193,6 +198,9 @@ model SystemLog {
193198
@@map("system_logs")
194199
}
195200

201+
202+
203+
196204
enum UserRole {
197205
ADMIN
198206
AGENT
@@ -210,6 +218,7 @@ enum PropertyStatus {
210218
LISTED
211219
SOLD
212220
REMOVED
221+
PUBLISHED
213222
}
214223

215224
enum TransactionStatus {

src/models/property.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Property as PrismaProperty, PropertyStatus } from '@prisma/client';
1+
import { PropertyStatus } from '@prisma/client';
22
import { Decimal } from '@prisma/client/runtime/library';
33

44
export { PropertyStatus };
55

6-
export class Property implements PrismaProperty {
6+
export class Property {
77
id: string;
88
title: string;
99
description: string | null;

src/properties/dto/create-property.dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export enum PropertyStatus {
2929
PENDING = 'PENDING',
3030
SOLD = 'SOLD',
3131
RENTED = 'RENTED',
32+
3233
}
3334

3435
export class AddressDto {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsOptional, IsNumber, IsEnum, IsString, Min } from 'class-validator';
3+
import { Type } from 'class-transformer';
4+
import { PropertyStatus } from '@prisma/client';
5+
6+
export class PropertySearchDto {
7+
@ApiPropertyOptional()
8+
@IsOptional()
9+
@Type(() => Number)
10+
@IsNumber()
11+
latitude?: number;
12+
13+
@ApiPropertyOptional()
14+
@IsOptional()
15+
@Type(() => Number)
16+
@IsNumber()
17+
longitude?: number;
18+
19+
@ApiPropertyOptional({ default: 5 })
20+
@IsOptional()
21+
@Type(() => Number)
22+
@IsNumber()
23+
@Min(0)
24+
radiusKm?: number = 5;
25+
26+
@ApiPropertyOptional()
27+
@IsOptional()
28+
@IsString()
29+
location?: string;
30+
31+
@ApiPropertyOptional()
32+
@IsOptional()
33+
@Type(() => Number)
34+
@IsNumber()
35+
minPrice?: number;
36+
37+
@ApiPropertyOptional()
38+
@IsOptional()
39+
@Type(() => Number)
40+
@IsNumber()
41+
maxPrice?: number;
42+
43+
@ApiPropertyOptional()
44+
@IsOptional()
45+
@IsEnum(PropertyStatus)
46+
status?: PropertyStatus;
47+
48+
@ApiPropertyOptional({ default: 1 })
49+
@IsOptional()
50+
@Type(() => Number)
51+
@IsNumber()
52+
page?: number = 1;
53+
54+
@ApiPropertyOptional({ default: 10 })
55+
@IsOptional()
56+
@Type(() => Number)
57+
@IsNumber()
58+
limit?: number = 10;
59+
}

src/properties/properties.controller.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@ne
33
import { PropertiesService } from './properties.service';
44
import { CreatePropertyDto, UpdatePropertyDto, PropertyQueryDto, PropertyResponseDto, PropertyStatus } from './dto';
55
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
6+
import { PropertySearchService } from './search/property-search.service';
7+
import { PropertySearchDto } from './dto/property-search.dto';
68

79
@ApiTags('properties')
810
@Controller('properties')
911
@ApiBearerAuth()
1012
@UseGuards(JwtAuthGuard)
1113
export class PropertiesController {
12-
constructor(private readonly propertiesService: PropertiesService) {}
13-
14+
constructor(
15+
private readonly propertiesService: PropertiesService,
16+
private readonly propertySearchService: PropertySearchService,
17+
) {}
1418
@Post()
1519
@ApiOperation({ summary: 'Create a new property' })
1620
@ApiResponse({ status: 201, description: 'Property created successfully.', type: PropertyResponseDto })
@@ -26,17 +30,12 @@ export class PropertiesController {
2630
return this.propertiesService.findAll(query);
2731
}
2832

29-
@Get('search/nearby')
30-
@ApiOperation({ summary: 'Search properties near a location' })
31-
@ApiResponse({ status: 200, description: 'Properties found nearby.' })
32-
searchNearby(
33-
@Query('latitude') latitude: number,
34-
@Query('longitude') longitude: number,
35-
@Query('radiusKm') radiusKm?: number,
36-
@Query() query?: PropertyQueryDto,
37-
) {
38-
return this.propertiesService.searchNearby(latitude, longitude, radiusKm, query);
39-
}
33+
@Get('search')
34+
@ApiOperation({ summary: 'Advanced property search (geospatial + filters)' })
35+
@ApiResponse({ status: 200, description: 'Search results.' })
36+
search(@Query() dto: PropertySearchDto, @Request() req) {
37+
return this.propertySearchService.search(dto, req.user.id);
38+
}
4039

4140
@Get('statistics')
4241
@ApiOperation({ summary: 'Get property statistics' })

src/properties/properties.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { PropertiesController } from './properties.controller';
33
import { PropertiesService } from './properties.service';
44
import { PrismaModule } from '../database/prisma/prisma.module';
55
import { ValuationModule } from '../valuation/valuation.module';
6+
import { PropertySearchService } from './search/property-search.service';
67

78
@Module({
89
imports: [PrismaModule, ValuationModule],
910
controllers: [PropertiesController],
10-
providers: [PropertiesService],
11+
providers: [PropertiesService, PropertySearchService],
1112
exports: [PropertiesService],
1213
})
1314
export class PropertiesModule {}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { PrismaService } from '../../database/prisma/prisma.service';
3+
import { PropertySearchDto } from '../dto/property-search.dto';
4+
import { SearchAnalyticsService } from './search-analytics.service';
5+
6+
@Injectable()
7+
export class PropertySearchService {
8+
constructor(
9+
private readonly prisma: PrismaService,
10+
private readonly analytics: SearchAnalyticsService,
11+
) {}
12+
async search(dto: PropertySearchDto, userId?: string) {
13+
const {
14+
latitude,
15+
longitude,
16+
radiusKm = 5,
17+
page = 1,
18+
limit = 10,
19+
minPrice,
20+
maxPrice,
21+
location,
22+
status = 'PUBLISHED',
23+
} = dto;
24+
25+
const offset = (page - 1) * limit;
26+
27+
// Geospatial search
28+
if (latitude && longitude) {
29+
return this.prisma.$queryRawUnsafe(`
30+
SELECT *,
31+
ST_Distance(
32+
coordinates,
33+
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)
34+
) AS distance
35+
FROM properties
36+
WHERE status = '${status}'
37+
${minPrice ? `AND price >= ${minPrice}` : ''}
38+
${maxPrice ? `AND price <= ${maxPrice}` : ''}
39+
${location ? `AND location ILIKE '%${location}%'` : ''}
40+
AND ST_DWithin(
41+
coordinates,
42+
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326),
43+
${radiusKm * 1000}
44+
)
45+
ORDER BY distance ASC
46+
LIMIT ${limit}
47+
OFFSET ${offset};
48+
`);
49+
}
50+
51+
// Normal search
52+
return this.prisma.property.findMany({
53+
where: {
54+
status,
55+
...(location && { location: { contains: location, mode: 'insensitive' } }),
56+
...(minPrice && { price: { gte: minPrice } }),
57+
...(maxPrice && { price: { lte: maxPrice } }),
58+
},
59+
skip: offset,
60+
take: limit,
61+
orderBy: { createdAt: 'desc' },
62+
});
63+
}
64+
65+
private async geoSearch(dto: PropertySearchDto) {
66+
const {
67+
latitude,
68+
longitude,
69+
radiusKm = 5,
70+
page = 1,
71+
limit = 10,
72+
minPrice,
73+
maxPrice,
74+
location,
75+
status = 'PUBLISHED',
76+
} = dto;
77+
78+
const offset = (page - 1) * limit;
79+
80+
return this.prisma.$queryRawUnsafe(`
81+
SELECT *,
82+
ST_Distance(
83+
coordinates,
84+
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)
85+
) AS distance
86+
FROM properties
87+
WHERE status = '${status}'
88+
${minPrice ? `AND price >= ${minPrice}` : ''}
89+
${maxPrice ? `AND price <= ${maxPrice}` : ''}
90+
${location ? `AND location ILIKE '%${location}%'` : ''}
91+
AND ST_DWithin(
92+
coordinates,
93+
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326),
94+
${radiusKm * 1000}
95+
)
96+
ORDER BY distance ASC
97+
LIMIT ${limit}
98+
OFFSET ${offset};
99+
`);
100+
}
101+
102+
private async normalSearch(dto: PropertySearchDto) {
103+
const {
104+
page = 1,
105+
limit = 10,
106+
minPrice,
107+
maxPrice,
108+
location,
109+
status = 'PUBLISHED',
110+
} = dto;
111+
112+
const offset = (page - 1) * limit;
113+
114+
return this.prisma.property.findMany({
115+
where: {
116+
status,
117+
...(location && { location: { contains: location, mode: 'insensitive' } }),
118+
...(minPrice && { price: { gte: minPrice } }),
119+
...(maxPrice && { price: { lte: maxPrice } }),
120+
},
121+
skip: offset,
122+
take: limit,
123+
orderBy: { createdAt: 'desc' },
124+
});
125+
}
126+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { PrismaService } from '../../database/prisma/prisma.service';
3+
import { PropertySearchDto } from '../dto/property-search.dto';
4+
5+
@Injectable()
6+
export class SearchAnalyticsService {
7+
constructor(private readonly prisma: PrismaService) {}
8+
9+
// async logSearch(userId: string | undefined, dto: PropertySearchDto, resultCount: number) {
10+
// await this.prisma.searchLog.create({
11+
// data: {
12+
// userId: userId ?? null,
13+
// filters: dto,
14+
// resultCount,
15+
// },
16+
// });
17+
// }
18+
}

0 commit comments

Comments
 (0)