feat: filter professionals by city and proximity

- findAllActive now accepts city, lat, lng params
- City filter: case-insensitive contains on users.city
- Proximity sort: Haversine within city before services/score ranking
- Controller exposes ?city=&lat=&lng= query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-21 20:35:03 -05:00
co-authored by Claude Sonnet 4.6
parent 00a6f637f6
commit d7a0769b46
2 changed files with 31 additions and 7 deletions
@@ -16,7 +16,10 @@ export class ProfessionalsController {
@Get()
findAllActive(@Req() req) {
const search = req.query.search as string | undefined;
return this.pros.findAllActive(search);
const city = req.query.city as string | undefined;
const lat = req.query.lat ? parseFloat(req.query.lat as string) : undefined;
const lng = req.query.lng ? parseFloat(req.query.lng as string) : undefined;
return this.pros.findAllActive(search, city, lat, lng);
}
@Get('pending')
@@ -20,10 +20,14 @@ export class ProfessionalsService {
} catch (_) {}
}
async findAllActive(search?: string) {
async findAllActive(search?: string, city?: string, lat?: number, lng?: number) {
const where: any = { is_active: true };
if (city && city.trim()) {
where.users = { city: { contains: city.trim(), mode: 'insensitive' } };
}
if (search && search.trim()) {
// Each word must match at least one of: profession, user name, or city
const words = search.trim().split(/\s+/).filter(w => w.length > 1);
if (words.length > 0) {
where.AND = words.map(word => ({
@@ -47,11 +51,28 @@ export class ProfessionalsService {
},
});
// Sort: most completed services first, then by average score
const sorted = professionals
const haversineKm = (lat1: number, lng1: number, lat2: number, lng2: number) => {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const sorted = [...professionals]
.sort((a, b) => {
const diff = (b._count?.services ?? 0) - (a._count?.services ?? 0);
if (diff !== 0) return diff;
if (lat !== undefined && lng !== undefined) {
const aLat = a.latitude ? Number(a.latitude) : null;
const aLng = a.longitude ? Number(a.longitude) : null;
const bLat = b.latitude ? Number(b.latitude) : null;
const bLng = b.longitude ? Number(b.longitude) : null;
if (aLat && aLng && bLat && bLng) {
const distDiff = haversineKm(lat, lng, aLat, aLng) - haversineKm(lat, lng, bLat, bLng);
if (Math.abs(distDiff) > 0.5) return distDiff;
}
}
const countDiff = (b._count?.services ?? 0) - (a._count?.services ?? 0);
if (countDiff !== 0) return countDiff;
return Number(b.average_score ?? 0) - Number(a.average_score ?? 0);
})
.slice(0, 7);