From d7a0769b468e72c869550c60051b04731795b759 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:35:03 -0500 Subject: [PATCH] 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 --- .../professionals/professionals.controller.ts | 5 ++- .../professionals/professionals.service.ts | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/src/professionals/professionals.controller.ts b/backend/src/professionals/professionals.controller.ts index 8c44a3f..e6add81 100644 --- a/backend/src/professionals/professionals.controller.ts +++ b/backend/src/professionals/professionals.controller.ts @@ -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') diff --git a/backend/src/professionals/professionals.service.ts b/backend/src/professionals/professionals.service.ts index 2c76cb8..e19c514 100644 --- a/backend/src/professionals/professionals.service.ts +++ b/backend/src/professionals/professionals.service.ts @@ -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);