From 38e943c18ea70570c2c335355a8a810192e5e393 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 24 Jul 2024 16:16:25 +0200 Subject: [PATCH] location radius search --- bizmatch-server/src/drizzle/import.ts | 12 +- bizmatch-server/src/geo/geo.service.ts | 4 +- .../src/listings/business-listing.service.ts | 20 ++- .../listings/commercial-property.service.ts | 10 +- .../src/listings/listings.module.ts | 6 +- bizmatch-server/src/mail/mail.module.ts | 5 +- bizmatch-server/src/models/main.model.ts | 6 +- bizmatch-server/src/user/user.module.ts | 6 +- bizmatch-server/src/user/user.service.ts | 18 ++- bizmatch-server/src/utils.ts | 19 ++- .../app/components/header/header.component.ts | 6 +- .../search-modal/search-modal.component.html | 133 +++++++++++++++--- .../search-modal/search-modal.component.ts | 18 +++ .../business-listings.component.html | 2 +- bizmatch/src/app/utils/utils.ts | 8 +- 15 files changed, 213 insertions(+), 60 deletions(-) diff --git a/bizmatch-server/src/drizzle/import.ts b/bizmatch-server/src/drizzle/import.ts index a0efc28..e74ddc2 100644 --- a/bizmatch-server/src/drizzle/import.ts +++ b/bizmatch-server/src/drizzle/import.ts @@ -96,8 +96,8 @@ for (let index = 0; index < usersData.length; index++) { user.companyLocation = userData.companyLocation; const [city, state] = user.companyLocation.split('-').map(e => e.trim()); const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city); - const latitude = cityGeo.latitude; - const longitude = cityGeo.longitude; + user.latitude = cityGeo.latitude; + user.longitude = cityGeo.longitude; user.offeredServices = userData.offeredServices; user.gender = userData.gender; user.customerType = 'professional'; @@ -151,8 +151,8 @@ for (let index = 0; index < commercialJsonData.length; index++) { commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercial.type)).value; const cityGeo = geos.states.find(s => s.state_code === commercial.state).cities.find(c => c.name === commercial.city); try { - const latitude = cityGeo.latitude; - const longitude = cityGeo.longitude; + commercial.latitude = cityGeo.latitude; + commercial.longitude = cityGeo.longitude; } catch (e) { console.log(`----------------> ERROR ${commercial.state} - ${commercial.city}`); } @@ -191,8 +191,8 @@ for (let index = 0; index < businessJsonData.length; index++) { business.imageName = emailToDirName(user.email); const cityGeo = geos.states.find(s => s.state_code === business.state).cities.find(c => c.name === business.city); try { - const latitude = cityGeo.latitude; - const longitude = cityGeo.longitude; + business.latitude = cityGeo.latitude; + business.longitude = cityGeo.longitude; } catch (e) { console.log(`----------------> ERROR ${business.state} - ${business.city}`); } diff --git a/bizmatch-server/src/geo/geo.service.ts b/bizmatch-server/src/geo/geo.service.ts index f912fbd..f486497 100644 --- a/bizmatch-server/src/geo/geo.service.ts +++ b/bizmatch-server/src/geo/geo.service.ts @@ -59,7 +59,9 @@ export class GeoService { } }); }); - return state ? result.filter(e => e.state_code.toLowerCase() === state.toLowerCase()) : result; } + getCityWithCoords(state: string, city: string): City { + return this.geo.states.find(s => s.state_code === state).cities.find(c => c.name === city); + } } diff --git a/bizmatch-server/src/listings/business-listing.service.ts b/bizmatch-server/src/listings/business-listing.service.ts index e4b240e..ab17c26 100644 --- a/bizmatch-server/src/listings/business-listing.service.ts +++ b/bizmatch-server/src/listings/business-listing.service.ts @@ -6,18 +6,10 @@ import { Logger } from 'winston'; import * as schema from '../drizzle/schema.js'; import { businesses, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; +import { GeoService } from '../geo/geo.service.js'; import { BusinessListing, CommercialPropertyListing } from '../models/db.model'; import { BusinessListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; - -const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern - -const getDistanceQuery = (lat: number, lon: number) => sql` - ${EARTH_RADIUS_KM} * 2 * ASIN(SQRT( - POWER(SIN((${lat} - ${businesses.latitude}) * PI() / 180 / 2), 2) + - COS(${lat} * PI() / 180) * COS(${businesses.latitude} * PI() / 180) * - POWER(SIN((${lon} - ${businesses.longitude}) * PI() / 180 / 2), 2) - )) -`; +import { getDistanceQuery } from '../utils.js'; @Injectable() export class BusinessListingService { @@ -25,15 +17,19 @@ export class BusinessListingService { @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService: FileService, + private geoService: GeoService, ) {} private getWhereConditions(criteria: BusinessListingCriteria): SQL[] { const whereConditions: SQL[] = []; - if (criteria.city) { + if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(ilike(businesses.city, `%${criteria.city}%`)); } - + if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { + const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city); + whereConditions.push(sql`${getDistanceQuery(businesses, parseFloat(cityGeo.latitude), parseFloat(cityGeo.longitude))} <= ${criteria.radius}`); + } if (criteria.types && criteria.types.length > 0) { whereConditions.push(inArray(businesses.type, criteria.types)); } diff --git a/bizmatch-server/src/listings/commercial-property.service.ts b/bizmatch-server/src/listings/commercial-property.service.ts index 1abc58b..5bcbbbb 100644 --- a/bizmatch-server/src/listings/commercial-property.service.ts +++ b/bizmatch-server/src/listings/commercial-property.service.ts @@ -6,8 +6,10 @@ import { Logger } from 'winston'; import * as schema from '../drizzle/schema.js'; import { commercials, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; +import { GeoService } from '../geo/geo.service.js'; import { CommercialPropertyListing } from '../models/db.model'; import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; +import { getDistanceQuery } from '../utils.js'; @Injectable() export class CommercialPropertyService { @@ -15,14 +17,18 @@ export class CommercialPropertyService { @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService: FileService, + private geoService: GeoService, ) {} private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] { const whereConditions: SQL[] = []; - if (criteria.city) { + if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(ilike(schema.commercials.city, `%${criteria.city}%`)); } - + if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { + const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city); + whereConditions.push(sql`${getDistanceQuery(commercials, parseFloat(cityGeo.latitude), parseFloat(cityGeo.longitude))} <= ${criteria.radius}`); + } if (criteria.types && criteria.types.length > 0) { whereConditions.push(inArray(schema.commercials.type, criteria.types)); } diff --git a/bizmatch-server/src/listings/listings.module.ts b/bizmatch-server/src/listings/listings.module.ts index ce0b754..7a31ead 100644 --- a/bizmatch-server/src/listings/listings.module.ts +++ b/bizmatch-server/src/listings/listings.module.ts @@ -7,14 +7,16 @@ import { BrokerListingsController } from './broker-listings.controller.js'; import { BusinessListingsController } from './business-listings.controller.js'; import { CommercialPropertyListingsController } from './commercial-property-listings.controller.js'; +import { GeoModule } from '../geo/geo.module.js'; +import { GeoService } from '../geo/geo.service.js'; import { BusinessListingService } from './business-listing.service.js'; import { CommercialPropertyService } from './commercial-property.service.js'; import { UnknownListingsController } from './unknown-listings.controller.js'; @Module({ - imports: [DrizzleModule, AuthModule], + imports: [DrizzleModule, AuthModule, GeoModule], controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController], - providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService], + providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService], exports: [BusinessListingService, CommercialPropertyService], }) export class ListingsModule {} diff --git a/bizmatch-server/src/mail/mail.module.ts b/bizmatch-server/src/mail/mail.module.ts index 854c08c..c2bc75d 100644 --- a/bizmatch-server/src/mail/mail.module.ts +++ b/bizmatch-server/src/mail/mail.module.ts @@ -5,6 +5,8 @@ import path, { join } from 'path'; import { fileURLToPath } from 'url'; import { DrizzleModule } from '../drizzle/drizzle.module.js'; import { FileService } from '../file/file.service.js'; +import { GeoModule } from '../geo/geo.module.js'; +import { GeoService } from '../geo/geo.service.js'; import { UserModule } from '../user/user.module.js'; import { UserService } from '../user/user.service.js'; import { MailController } from './mail.controller.js'; @@ -17,6 +19,7 @@ const password = process.env.amazon_password; imports: [ DrizzleModule, UserModule, + GeoModule, MailerModule.forRoot({ transport: { host: 'email-smtp.us-east-2.amazonaws.com', @@ -39,7 +42,7 @@ const password = process.env.amazon_password; }, }), ], - providers: [MailService, UserService, FileService], + providers: [MailService, UserService, FileService, GeoService], controllers: [MailController], }) export class MailModule {} diff --git a/bizmatch-server/src/models/main.model.ts b/bizmatch-server/src/models/main.model.ts index b2b7aa7..3b3d974 100644 --- a/bizmatch-server/src/models/main.model.ts +++ b/bizmatch-server/src/models/main.model.ts @@ -58,12 +58,14 @@ export interface ListCriteria { length: number; page: number; types: string[]; + state: string; city: string; prompt: string; + searchType: 'exact' | 'radius'; + radius: number; criteriaType: 'business' | 'commercialProperty' | 'broker'; } export interface BusinessListingCriteria extends ListCriteria { - state: string; county: string; minPrice: number; maxPrice: number; @@ -83,7 +85,6 @@ export interface BusinessListingCriteria extends ListCriteria { criteriaType: 'business'; } export interface CommercialPropertyListingCriteria extends ListCriteria { - state: string; county: string; minPrice: number; maxPrice: number; @@ -95,7 +96,6 @@ export interface UserListingCriteria extends ListCriteria { lastname: string; companyName: string; counties: string[]; - states: string[]; criteriaType: 'broker'; } diff --git a/bizmatch-server/src/user/user.module.ts b/bizmatch-server/src/user/user.module.ts index f24ba9c..7552b0b 100644 --- a/bizmatch-server/src/user/user.module.ts +++ b/bizmatch-server/src/user/user.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { DrizzleModule } from '../drizzle/drizzle.module.js'; import { FileService } from '../file/file.service.js'; +import { GeoModule } from '../geo/geo.module.js'; +import { GeoService } from '../geo/geo.service.js'; import { UserController } from './user.controller.js'; import { UserService } from './user.service.js'; @Module({ - imports: [DrizzleModule], + imports: [DrizzleModule, GeoModule], controllers: [UserController], - providers: [UserService, FileService], + providers: [UserService, FileService, GeoService], }) export class UserModule {} diff --git a/bizmatch-server/src/user/user.service.ts b/bizmatch-server/src/user/user.service.ts index 7f8ec61..4754a1b 100644 --- a/bizmatch-server/src/user/user.service.ts +++ b/bizmatch-server/src/user/user.service.ts @@ -6,8 +6,10 @@ import { Logger } from 'winston'; import * as schema from '../drizzle/schema.js'; import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; +import { GeoService } from '../geo/geo.service.js'; import { User } from '../models/db.model.js'; import { emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model.js'; +import { getDistanceQuery } from '../utils.js'; type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number]; @Injectable() @@ -16,6 +18,7 @@ export class UserService { @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService: FileService, + private geoService: GeoService, ) {} // private getConditions(criteria: UserListingCriteria): any[] { // const conditions = []; @@ -32,10 +35,13 @@ export class UserService { private getWhereConditions(criteria: UserListingCriteria): SQL[] { const whereConditions: SQL[] = []; - if (criteria.city) { + if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(ilike(schema.users.companyLocation, `%${criteria.city}%`)); } - + if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { + const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city); + whereConditions.push(sql`${getDistanceQuery(schema.users, parseFloat(cityGeo.latitude), parseFloat(cityGeo.longitude))} <= ${criteria.radius}`); + } if (criteria.types && criteria.types.length > 0) { // whereConditions.push(inArray(schema.users.customerSubType, criteria.types)); whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[])); @@ -57,10 +63,12 @@ export class UserService { whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'county' ILIKE ${`%${county}%`})`))); } - if (criteria.states && criteria.states.length > 0) { - whereConditions.push(or(...criteria.states.map(state => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${state})`))); + // if (criteria.states && criteria.states.length > 0) { + // whereConditions.push(or(...criteria.states.map(state => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${state})`))); + // } + if (criteria.state) { + whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${criteria.state})`); } - return whereConditions; } async searchUserListings(criteria: UserListingCriteria) { diff --git a/bizmatch-server/src/utils.ts b/bizmatch-server/src/utils.ts index a50b082..e5137ef 100644 --- a/bizmatch-server/src/utils.ts +++ b/bizmatch-server/src/utils.ts @@ -1,3 +1,8 @@ +import { sql } from 'drizzle-orm'; +import { businesses, commercials, users } from './drizzle/schema.js'; +export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern +export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen + export function convertStringToNullUndefined(value) { // Konvertiert den Wert zu Kleinbuchstaben für eine case-insensitive Überprüfung const lowerCaseValue = typeof value === 'boolean' ? value : value?.toLowerCase(); @@ -10,4 +15,16 @@ export function convertStringToNullUndefined(value) { // Gibt den Originalwert zurück, wenn es sich nicht um 'null' oder 'undefined' handelt return value; -} \ No newline at end of file +} + +export const getDistanceQuery = (schema: typeof businesses | typeof commercials | typeof users, lat: number, lon: number, unit: 'km' | 'miles' = 'miles') => { + const radius = unit === 'km' ? EARTH_RADIUS_KM : EARTH_RADIUS_MILES; + + return sql` + ${radius} * 2 * ASIN(SQRT( + POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) + + COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) * + POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2) + )) + `; +}; diff --git a/bizmatch/src/app/components/header/header.component.ts b/bizmatch/src/app/components/header/header.component.ts index 9c516ec..d0ec527 100644 --- a/bizmatch/src/app/components/header/header.component.ts +++ b/bizmatch/src/app/components/header/header.component.ts @@ -172,11 +172,11 @@ export class HeaderComponent { } getNumberOfFiltersSet() { if (this.criteria?.criteriaType === 'broker') { - return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page']); + return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); } else if (this.criteria?.criteriaType === 'business') { - return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page']); + return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); } else if (this.criteria?.criteriaType === 'commercialProperty') { - return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page']); + return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); } else { return 0; } diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.html b/bizmatch/src/app/components/search-modal/search-modal.component.html index 5f57a63..92f7159 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.html +++ b/bizmatch/src/app/components/search-modal/search-modal.component.html @@ -21,7 +21,7 @@
- +
@@ -35,13 +35,44 @@ [loading]="cityLoading" typeToSearchText="Please enter 2 or more characters" [typeahead]="cityInput$" - [(ngModel)]="criteria.city" + [ngModel]="criteria.city" + (ngModelChange)="setCity($event)" > @for (city of cities$ | async; track city.id) { - {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} }
+ +
+ +
+ + +
+
+ +
+ +
+ @for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) { + + } +
+
@@ -113,16 +144,6 @@ placeholder="e.g. Restaurant" />
-
- - -
@@ -200,6 +221,16 @@ />
+
+ + +
} @if(criteria.criteriaType==='commercialProperty'){ @@ -207,7 +238,7 @@
- +
@@ -220,13 +251,44 @@ [loading]="cityLoading" typeToSearchText="Please enter 2 or more characters" [typeahead]="cityInput$" - [(ngModel)]="criteria.city" + [ngModel]="criteria.city" + (ngModelChange)="setCity($event)" > @for (city of cities$ | async; track city.id) { - {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} }
+ +
+ +
+ + +
+
+ +
+ +
+ @for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) { + + } +
+
@@ -284,7 +346,7 @@
- +
@@ -292,7 +354,7 @@ [items]="counties$ | async" bindLabel="name" class="custom" - [multiple]="true" + [multiple]="false" [hideSelected]="true" [trackByFn]="trackByFn" [minTermLength]="2" @@ -317,13 +379,44 @@ [loading]="cityLoading" typeToSearchText="Please enter 2 or more characters" [typeahead]="cityInput$" - [(ngModel)]="criteria.city" + [ngModel]="criteria.city" + (ngModelChange)="setCity($event)" > @for (city of cities$ | async; track city.id) { - {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} }
+ +
+ +
+ + +
+
+ +
+ +
+ @for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) { + + } +
+
diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.ts b/bizmatch/src/app/components/search-modal/search-modal.component.ts index df7a978..82ba32c 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.ts +++ b/bizmatch/src/app/components/search-modal/search-modal.component.ts @@ -89,6 +89,24 @@ export class SearchModalComponent { ), ); } + setCity(city) { + if (city) { + this.criteria.city = city.city; + this.criteria.state = city.state_code; + } else { + this.criteria.city = null; + this.criteria.radius = null; + this.criteria.searchType = 'exact'; + } + } + setState(state: string) { + if (state) { + this.criteria.state = state; + } else { + this.criteria.state = null; + this.setCity(null); + } + } private setupCriteriaChangeListener() { this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => this.setTotalNumberOfResults()); } diff --git a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html index 17e53ec..6712c1d 100644 --- a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html +++ b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html @@ -99,7 +99,7 @@

Asking price: {{ listing.price | currency }}

Sales revenue: {{ listing.salesRevenue | currency }}

Net profit: {{ listing.cashFlow | currency }}

-

Location: {{ selectOptions.getState(listing.state) }}

+

Location: {{ listing.city }} - {{ selectOptions.getState(listing.state) }}

Established: {{ listing.established }}

Company logo
diff --git a/bizmatch/src/app/utils/utils.ts b/bizmatch/src/app/utils/utils.ts index 29cf0f1..324c03e 100644 --- a/bizmatch/src/app/utils/utils.ts +++ b/bizmatch/src/app/utils/utils.ts @@ -115,6 +115,8 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria { franchiseResale: false, title: '', brokerName: '', + searchType: 'exact', + radius: null, }; } @@ -132,6 +134,8 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper minPrice: null, maxPrice: null, title: '', + searchType: 'exact', + radius: null, }; } @@ -148,7 +152,9 @@ export function createEmptyUserListingCriteria(): UserListingCriteria { lastname: '', companyName: '', counties: [], - states: [], + state: '', + searchType: 'exact', + radius: null, }; } export function createLogger(name: string, level: number = INFO, options: any = {}) {