diff --git a/package.json b/package.json index e7c185f..5e900e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "overture-maps-api", - "version": "0.0.8", + "version": "0.0.9", "description": "", "author": "", "private": true, diff --git a/src/app.module.ts b/src/app.module.ts index 156ba63..41a6a19 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,35 +1,38 @@ +import { PlacesModule } from './places/places.module'; +import { PlacesService } from './places/places.service'; // src/app.module.ts -import { Module, NestMiddleware, MiddlewareConsumer, Logger, RequestMethod } from '@nestjs/common'; +import { Module, NestMiddleware, MiddlewareConsumer, Logger, RequestMethod } from '@nestjs/common'; import { PlacesController } from './places/places.controller'; import { BigQueryService } from './bigquery/bigquery.service'; import { GcsService } from './gcs/gcs.service'; import { ConfigModule } from '@nestjs/config'; -import {Request, Response} from 'express' +import { Request, Response } from 'express' import { AuthAPIMiddleware } from './middleware/auth-api.middleware'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ - imports: [ConfigModule.forRoot()], - controllers: [AppController,PlacesController], - providers: [BigQueryService, GcsService,AppService], + imports: [ + PlacesModule, ConfigModule.forRoot()], + controllers: [AppController], + providers: [ BigQueryService, GcsService, AppService], }) export class AppModule { -configure(consumer: MiddlewareConsumer) { - consumer - .apply(LoggerMiddleware) - .forRoutes('*'); + configure(consumer: MiddlewareConsumer) { + consumer + .apply(LoggerMiddleware) + .forRoutes('*'); consumer.apply(AuthAPIMiddleware) - .forRoutes('*'); + .forRoutes('*'); } } class LoggerMiddleware implements NestMiddleware { - use(req:Request, res:Response, next: Function) { - + use(req: Request, res: Response, next: Function) { + Logger.debug(`Request ${req.method} ${req.originalUrl}`) next(); } - } \ No newline at end of file +} \ No newline at end of file diff --git a/src/decorators/authed-user.decorator.ts b/src/decorators/authed-user.decorator.ts new file mode 100644 index 0000000..867f5dc --- /dev/null +++ b/src/decorators/authed-user.decorator.ts @@ -0,0 +1,15 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const AuthedUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.res.locals['user']; // Get the user from locals + }, +); + +//interface for user object +export interface User { + accountId: string; + userId: string; + isDemoAccount?: boolean; + } \ No newline at end of file diff --git a/src/decorators/validate-lat-lng-user.decorator.ts b/src/decorators/validate-lat-lng-user.decorator.ts new file mode 100644 index 0000000..5b3aa23 --- /dev/null +++ b/src/decorators/validate-lat-lng-user.decorator.ts @@ -0,0 +1,22 @@ +import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; + +// Method decorator +export function ValidateLatLngUser(): MethodDecorator { + return function (target, propertyKey, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const request = args[0]; // Assuming the first argument is the request object + const lat = request?.lat; + const lng = request?.lng; + const user = args[1] + + if (lat && lng && user.isDemoAccount) { + throw new ForbiddenException('Demo accounts cannot access this feature'); + } + + // Call the original method if validation passes + return await originalMethod.apply(this, args); + }; + }; +} \ No newline at end of file diff --git a/src/middleware/auth-api.middleware.ts b/src/middleware/auth-api.middleware.ts index 2f4f6b8..145dee4 100644 --- a/src/middleware/auth-api.middleware.ts +++ b/src/middleware/auth-api.middleware.ts @@ -5,11 +5,13 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; +import { User } from '../decorators/authed-user.decorator'; //import CacheService from '../cache/CacheService'; import TheAuthAPI from 'theauthapi'; const DEMO_API_KEY = process.env.DEMO_API_KEY || 'DEMO-API-KEY'; + @Injectable() export class AuthAPIMiddleware implements NestMiddleware { @@ -59,8 +61,9 @@ export class AuthAPIMiddleware implements NestMiddleware { const apiKey = await this.theAuthAPI.apiKeys.authenticateKey(apiKeyString); if (apiKey) { - const userObj = { - metadata: apiKey.customMetaData, + const metaData = apiKey.customMetaData as any; + const userObj:User = { + isDemoAccount: metaData.isDemoAccount || false, accountId: apiKey.customAccountId, userId: apiKey.customUserId, }; @@ -77,13 +80,12 @@ export class AuthAPIMiddleware implements NestMiddleware { //if demo key, set user to demo user if (apiKeyString === DEMO_API_KEY) { - req['user'] = req.res.locals['user'] = { - metadata: { - isDemoAccount:true - }, + const demoUser:User = { + isDemoAccount:true, accountId: 'demo-account-id', - userId: 'demo-user-id', + userId: 'demo-user-id' }; + req['user'] = req.res.locals['user'] = demoUser; next(); return; } diff --git a/src/places/dto/place-response.dto.ts b/src/places/dto/place-response.dto.ts index c3b2691..b01acc9 100644 --- a/src/places/dto/place-response.dto.ts +++ b/src/places/dto/place-response.dto.ts @@ -92,13 +92,13 @@ export class PropertiesDto { addresses?: AddressDto[]; @ApiProperty({ description: 'Theme associated with the place.', example: 'Restaurant' }) - theme: string; + theme?: string; @ApiProperty({ description: 'Type of feature or place.', example: 'Commercial' }) - type: string; + type?: string; - @ApiProperty({ description: 'Version number of the place data.', example: 1 }) - version: number; + @ApiProperty({ description: 'Version number of the place data.', example: "1" }) + version: string; @ApiProperty({ description: 'Source information for the place data.', @@ -111,6 +111,10 @@ export class PropertiesDto { type: () => PlaceNamesDto, }) names: PlaceNamesDto; + + constructor(data={}) { + Object.assign(this, data); + } } export class PlaceResponseDto { @@ -134,29 +138,22 @@ export class PlaceResponseDto { constructor(place: Place) { this.id = place.id; - Object.assign(this, place); + this.geometry = place.geometry; + //if(!this.properties) this.properties = new PropertiesDto(); + //Object.assign(this.properties, place); } } +export const toPlaceDto = (place: Place):PlaceResponseDto => { + + const excludeFieldsFromProperties = ['properties','geometry','distance_m','bbox']; + const properties = {...place}; + excludeFieldsFromProperties.forEach(field => delete properties[field]); + + const rPlace = new PlaceResponseDto(place) + rPlace.properties = properties; + rPlace.geometry = place.geometry; + + return rPlace; +} -export const toPlacesGeoJSONResponseDto = (places: Place[]) => { - const toplevel = - { - "type":"FeatureCollection", - "features":[ - ] - } - places.forEach(place => { - toplevel.features.push({ - "type":"Feature", - "geometry":place.geometry, - "properties":{ - confidence: place.confidence, - ...place.names, - ...place.brand, - ...place.categories - } - }) - }) - return toplevel; - } \ No newline at end of file diff --git a/src/places/interfaces/place.interface.ts b/src/places/interfaces/place.interface.ts index f42cad9..615917e 100644 --- a/src/places/interfaces/place.interface.ts +++ b/src/places/interfaces/place.interface.ts @@ -15,6 +15,8 @@ export interface Place { brand?: Brand; addresses: Address[]; distance_m?: number; + theme?: string; + type?: string; } export interface Geometry { @@ -39,7 +41,7 @@ export interface Place { export interface Names { primary: string; - common?: string; + common?: Record; rules?: any; // No clear type provided, so keeping it as `any` } @@ -55,7 +57,7 @@ export interface Place { export interface BrandNames { primary: string; - common?: string; + common?: Record; rules?: any; // No clear type provided, so keeping it as `any` } diff --git a/src/places/places.controller.ts b/src/places/places.controller.ts index d46fa03..7de81dd 100644 --- a/src/places/places.controller.ts +++ b/src/places/places.controller.ts @@ -3,7 +3,7 @@ import { Controller, Get, Logger, Query, UseGuards } from '@nestjs/common'; import { BigQueryService } from '../bigquery/bigquery.service'; import { GcsService } from '../gcs/gcs.service'; import { GetPlacesDto } from './dto/requests/get-places.dto'; -import { PlaceResponseDto, toPlacesGeoJSONResponseDto } from './dto/place-response.dto'; +import { PlaceResponseDto, toPlaceDto } from './dto/place-response.dto'; import { GetBrandsDto } from './dto/requests/get-brands.dto'; import { IsAuthenticatedGuard } from '../guards/is-authenticated.guard'; import { GetCategoriesDto } from './dto/requests/get-categories.dto'; @@ -11,6 +11,9 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody , ApiQuery, ApiSecurity} fr import { BrandDto } from './dto/models/brand.dto'; import { CountryResponseDto, toCountryResponseDto } from './dto/responses/country-response.dto'; import { CategoryResponseDto, toCategoryResponseDto } from './dto/responses/category-response.dto'; +import { AuthedUser, User } from '../decorators/authed-user.decorator'; +import { ValidateLatLngUser } from '../decorators/validate-lat-lng-user.decorator'; +import { PlacesService } from './places.service'; @ApiTags('places') @ApiSecurity('API_KEY') // Applies the API key security scheme defined in Swagger @@ -21,109 +24,61 @@ export class PlacesController { logger = new Logger('PlacesController'); constructor( - private readonly bigQueryService: BigQueryService, - private readonly gcsService: GcsService, + private placesService: PlacesService + ) {} @Get() + @ValidateLatLngUser() @ApiOperation({ summary: 'Get all Places using Query params as filters' }) - @ApiResponse({ status: 200, description: 'Return all Places.' , type: PlaceResponseDto, isArray: true}) @ApiQuery({type:GetPlacesDto}) - async getPlaces(@Query() query: GetPlacesDto) { - - const { lat, lng, radius, country, min_confidence, brand_wikidata,brand_name,categories,limit } = query; - - const cacheKey = `get-places-${JSON.stringify(query)}`; + @ApiResponse({ status: 200, description: 'Return all Places.' , type: PlaceResponseDto, isArray: true}) + async getPlaces(@Query() query: GetPlacesDto, @AuthedUser() user: User) { - // Check if cached results exist in GCS - let results = await this.getFromCache(cacheKey); - if (!results) { - // if only country is provided, then potentially just use the lat / lng of it's capital city - + // If no cache, query BigQuery with wikidata and country support - results = await this.bigQueryService.getPlacesNearby(lat, lng, radius, brand_wikidata,brand_name, country, categories, min_confidence,limit); - - // Cache the results in GCS - await this.gcsService.storeJSON (results,cacheKey); - - return results.map((place: any) => new PlaceResponseDto(place)); - } - + const results = await this.placesService.getPlaces(query); + const dtoResults = results.map((place: any) =>toPlaceDto(place)); if(query.format === 'geojson') { - return toPlacesGeoJSONResponseDto(results); + return { + "type":"FeatureCollection", + "features": dtoResults + }; } - return results.map((place: any) => new PlaceResponseDto(place)); + return dtoResults } @Get('brands') + @ValidateLatLngUser() @ApiOperation({ summary: 'Get all Brands from Places using Query params as filters' }) @ApiResponse({ status: 200, description: 'Return all Brands, along with a count of all Places for each.' , type: BrandDto, isArray: true}) @ApiQuery({type:GetBrandsDto}) - async getBrands(@Query() query: GetBrandsDto) { - const { country, lat, lng, radius, categories } = query; + async getBrands(@Query() query: GetBrandsDto, @AuthedUser() user: User) { + + return await this.placesService.getBrands(query); - // Check if cached results exist in GCS - const cacheKey = `get-places-brands-${JSON.stringify(query)}`; - const cachedResult = await this.getFromCache(cacheKey); - if (cachedResult) { - return cachedResult; - } - - const results = await this.bigQueryService.getBrandsNearby(country, lat, lng, radius, categories); - await this.gcsService.storeJSON (results,cacheKey); - return results.map((brand: any) => new BrandDto(brand)); } + @Get('countries') + @ValidateLatLngUser() @ApiOperation({ summary: 'Get all Countries from Places using Query params as filters' }) @ApiResponse({ status: 200, description: 'Return all Countries, as well as a count of all the Places and Brands in each.', type:CountryResponseDto, isArray: true}) async getCountries() { - const cacheKey = `get-places-countries`; - const cachedResult = await this.getFromCache(cacheKey); - if (cachedResult) { - return cachedResult; - } - const results = await this.bigQueryService.getPlaceCountsByCountry(); - await this.gcsService.storeJSON (results,cacheKey); - return results.map((country: any) => toCountryResponseDto(country)); + return await this.placesService.getCountries(); + } @Get('categories') + @ValidateLatLngUser() @ApiOperation({ summary: 'Get all Categories from Places using Query params as filters' }) @ApiResponse({ status: 200, description: 'Return all Categories, along with a count of all Brands and Places for each' , type: CategoryResponseDto, isArray: true}) @ApiQuery({type:GetCategoriesDto}) - async getCategories(@Query() query: GetCategoriesDto) { + async getCategories(@Query() query: GetCategoriesDto):Promise { - const cacheKey = `get-places-categories-${JSON.stringify(query)}`; - const cachedResult = await this.getFromCache(cacheKey ); - if (cachedResult) { - return cachedResult; - } + return await this.placesService.getCategories(query); - const results = await this.bigQueryService.getCategories(query.country); - - await this.storeToCache (results, cacheKey); - return results.map((category: any) => toCategoryResponseDto(category)); - } - - async getFromCache(cacheKey:string): Promise { - try{; - const cachedResult = await this.gcsService.getJSON(cacheKey); - this.logger.log(`Cache hit for get-places-categories-${JSON.stringify(cacheKey)} - cachedResult length: ${cachedResult.length}`); - return cachedResult; - }catch(error){ - this.logger.error('Error fetching cached places:', error); - return null; - } } - async storeToCache( data,cacheKey:string): Promise { - try{ - - await this.gcsService.storeJSON (data,cacheKey); - }catch(error){ - this.logger.error('Error saving cached places:', error); - } - } } diff --git a/src/places/places.module.ts b/src/places/places.module.ts new file mode 100644 index 0000000..65cb24a --- /dev/null +++ b/src/places/places.module.ts @@ -0,0 +1,19 @@ +/* +https://docs.nestjs.com/modules +*/ + +import { Module } from '@nestjs/common'; +import { PlacesService } from './places.service'; +import { PlacesController } from './places.controller'; +import { BigQueryService } from '../bigquery/bigquery.service'; +import { GcsService } from '../gcs/gcs.service'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ConfigModule], + controllers: [PlacesController], + providers: [PlacesService, BigQueryService, GcsService], + exports: [PlacesService] + +}) +export class PlacesModule {} diff --git a/src/places/places.service.spec.ts b/src/places/places.service.spec.ts new file mode 100644 index 0000000..005f6b0 --- /dev/null +++ b/src/places/places.service.spec.ts @@ -0,0 +1,227 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { PlacesService } from './places.service'; +import { BigQueryService } from '../bigquery/bigquery.service'; +import { GcsService } from '../gcs/gcs.service'; +import { GetPlacesDto } from './dto/requests/get-places.dto'; +import { PlaceResponseDto, toPlaceDto } from './dto/place-response.dto'; +import { ConfigService } from '@nestjs/config'; + +describe('PlacesService', () => { + let service: PlacesService; + let bigQueryService: BigQueryService; + let gcsService: GcsService; + + const mockBigQueryGetPlacesNearbyResponse = [ + { + id: "08f184d6d16d5c5303f2f0fb615c6051", + geometry: { + coordinates: [ + -1.1516016, + 43.8711004, + ], + type: "Point" + }, + bbox: { + xmin: -1.1516017913818359, + xmax: -1.1516015529632568, + ymin: 43.87109375, + ymax: 43.871101379394531, + }, + version: "0", + sources: [{ + property: "", + dataset: "meta", + record_id: "680834765441518", + update_time: "2024-08-02T00:00:00.000Z", + confidence: null, + }], + names: { + primary: "Intermarché", + common: null, + rules: null, + }, + categories: { + primary: "supermarket", + alternate: ["grocery_store" , "health_food_store" + ], + }, + confidence: 0.54162042175360714, + websites: ["http://www.intermarche.fr/" ], + socials: ["https://www.facebook.com/680834765441518" ], + + emails: null, + phones: ["+33558550353" ], + brand: null, + addresses: [{ + freeform: "rue Jean de Nasse ", + locality: "Castets", + postcode: "40260", + region: null, + country: "FR" + } + ] + } + + ]; + + const mockPlaceResponseDto = [{ + "id": "08f184d6d16d5c5303f2f0fb615c6051", + "geometry": { + "type": "Point", + "coordinates": [ + -1.1516016, + 43.8711004 + ] + }, + "bbox": { + "xmin": -1.151601791381836, + "xmax": -1.1516015529632568, + "ymin": 43.87109375, + "ymax": 43.87110137939453 + }, + "version": "0", + "sources": [{ + "property": "", + "dataset": "meta", + "record_id": "680834765441518", + "update_time": "2024-08-02T00:00:00.000Z", + "confidence": null + } + ] + , + "names": { + "primary": "Intermarché", + "common": null, + "rules": null + }, + "categories": { + "primary": "supermarket", + "alternate": ["grocery_store","health_food_store"] + }, + "confidence": 0.5416204217536071, + "websites": [ "http://www.intermarche.fr/"], + + "socials": ["https://www.facebook.com/680834765441518" + ], + + "emails": null, + "phones": [ "+33558550353" ], + "brand": null, + "addresses": + [{ + "freeform": "rue Jean de Nasse ", + "locality": "Castets", + "postcode": "40260", + "region": null, + "country": "FR" + + }] + + } + ]; + + const mockGcsGetJSON = jest.fn(); + const mockGcsStoreJSON = jest.fn(); + const mockBigQueryGetPlacesNearby = jest.fn(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlacesService, + { + provide: ConfigService, + useValue: { getPlacesNearby: mockBigQueryGetPlacesNearby }, + }, + { + provide: BigQueryService, + useValue: { getPlacesNearby: mockBigQueryGetPlacesNearby }, + }, + { + provide: GcsService, + useValue: { getJSON: mockGcsGetJSON, storeJSON: mockGcsStoreJSON }, + }, + { + provide: Logger, + useValue: { log: jest.fn(), error: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(PlacesService); + bigQueryService = module.get(BigQueryService); + gcsService = module.get(GcsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return places from cache if available', async () => { + mockGcsGetJSON.mockResolvedValueOnce(mockPlaceResponseDto); + + const query: GetPlacesDto = { + lat: 43.8711004, + lng: -1.1516016, + radius: 1000, + country: 'FR', + min_confidence: 0.5, + brand_wikidata: null, + brand_name: 'Intermarché', + categories: ['supermarket'], + limit: 10, + }; + + const result = await service.getPlaces(query); + + expect(mockGcsGetJSON).toHaveBeenCalledWith(`get-places-${JSON.stringify(query)}`); + expect(mockBigQueryGetPlacesNearby).not.toHaveBeenCalled(); + expect(result).toEqual(mockPlaceResponseDto); + }); + + it('should query BigQuery and cache results if cache is empty', async () => { + mockGcsGetJSON.mockResolvedValueOnce(null); + mockBigQueryGetPlacesNearby.mockResolvedValueOnce(mockBigQueryGetPlacesNearbyResponse); + mockGcsStoreJSON.mockResolvedValueOnce(undefined); + + const query: GetPlacesDto = { + lat: 43.8711004, + lng: -1.1516016, + radius: 1000, + country: 'FR', + min_confidence: 0.5, + brand_wikidata: null, + brand_name: 'Intermarché', + categories: ['supermarket'], + limit: 10, + }; + + const result = await service.getPlaces(query); + + expect(mockGcsGetJSON).toHaveBeenCalledWith(`get-places-${JSON.stringify(query)}`); + expect(mockBigQueryGetPlacesNearby).toHaveBeenCalledWith( + query.lat, + query.lng, + query.radius, + query.brand_wikidata, + query.brand_name, + query.country, + query.categories, + query.min_confidence, + query.limit + ); + + expect(mockGcsStoreJSON).toHaveBeenCalledWith(mockBigQueryGetPlacesNearbyResponse, `get-places-${JSON.stringify(query)}`); + expect(result).toEqual(mockPlaceResponseDto); + }); + + it('should handle errors gracefully in getFromCache', async () => { + const cacheKey = 'get-places-categories-sample-key'; + mockGcsGetJSON.mockRejectedValueOnce(new Error('Cache fetch error')); + + const result = await service.getFromCache(cacheKey); + + expect(result).toBeNull(); + expect(mockGcsGetJSON).toHaveBeenCalledWith(cacheKey); + }); +}); diff --git a/src/places/places.service.ts b/src/places/places.service.ts new file mode 100644 index 0000000..487cf6a --- /dev/null +++ b/src/places/places.service.ts @@ -0,0 +1,110 @@ +/* +https://docs.nestjs.com/providers#services +*/ + +import { Injectable, Logger } from '@nestjs/common'; +import { BigQueryService } from '../bigquery/bigquery.service'; +import { GcsService } from '../gcs/gcs.service'; +import { GetPlacesDto } from './dto/requests/get-places.dto'; +import { PlaceResponseDto, toPlaceDto } from './dto/place-response.dto'; +import { GetBrandsDto } from './dto/requests/get-brands.dto'; +import { IsAuthenticatedGuard } from '../guards/is-authenticated.guard'; +import { GetCategoriesDto } from './dto/requests/get-categories.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody , ApiQuery, ApiSecurity} from '@nestjs/swagger'; +import { BrandDto } from './dto/models/brand.dto'; +import { CountryResponseDto, toCountryResponseDto } from './dto/responses/country-response.dto'; +import { CategoryResponseDto, toCategoryResponseDto } from './dto/responses/category-response.dto'; +import { AuthedUser, User } from '../decorators/authed-user.decorator'; +import { ValidateLatLngUser } from '../decorators/validate-lat-lng-user.decorator'; +import { Place } from './interfaces/place.interface'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class PlacesService { + logger = new Logger('PlacesService'); + + constructor( + private readonly configService: ConfigService, + private readonly bigQueryService: BigQueryService, + private readonly gcsService: GcsService, + ) {} + async getPlaces(query: GetPlacesDto):Promise { + + const { lat, lng, radius, country, min_confidence, brand_wikidata,brand_name,categories,limit } = query; + + const cacheKey = `get-places-${JSON.stringify(query)}`; + + // Check if cached results exist in GCS + let results:Place[] = await this.getFromCache(cacheKey); + if (!results) { + // If no cache, query BigQuery with wikidata and country support + results = await this.bigQueryService.getPlacesNearby(lat, lng, radius, brand_wikidata,brand_name, country, categories, min_confidence,limit); + // Cache the results in GCS + await this.gcsService.storeJSON (results,cacheKey); + + } + return results + + } + + async getBrands(query: GetBrandsDto): Promise { + const { country, lat, lng, radius, categories } = query; + + // Check if cached results exist in GCS + const cacheKey = `get-places-brands-${JSON.stringify(query)}`; + const cachedResult = await this.getFromCache(cacheKey); + if (cachedResult) { + return cachedResult; + } + + const results = await this.bigQueryService.getBrandsNearby(country, lat, lng, radius, categories); + await this.gcsService.storeJSON (results,cacheKey); + return results.map((brand: any) => new BrandDto(brand)); + } + + async getCountries():Promise { + const cacheKey = `get-places-countries`; + const cachedResult = await this.getFromCache(cacheKey); + if (cachedResult) { + return cachedResult; + } + + const results = await this.bigQueryService.getPlaceCountsByCountry(); + await this.gcsService.storeJSON (results,cacheKey); + return results.map((country: any) => toCountryResponseDto(country)); + } + + async getCategories(query: GetCategoriesDto): Promise { + + const cacheKey = `get-places-categories-${JSON.stringify(query)}`; + const cachedResult = await this.getFromCache(cacheKey ); + if (cachedResult) { + return cachedResult; + } + + const results = await this.bigQueryService.getCategories(query.country); + + await this.storeToCache (results, cacheKey); + return results.map((category: any) => toCategoryResponseDto(category)); + } + + async getFromCache(cacheKey:string): Promise { + try{; + const cachedResult = await this.gcsService.getJSON(cacheKey); + this.logger.log(`Cache hit for get-places-categories-${JSON.stringify(cacheKey)} - cachedResult length: ${cachedResult.length}`); + return cachedResult; + }catch(error){ + this.logger.error('Error fetching cached places:', error); + return null; + } + } + + async storeToCache( data,cacheKey:string): Promise { + try{ + + await this.gcsService.storeJSON (data,cacheKey); + }catch(error){ + this.logger.error('Error saving cached places:', error); + } + } +} diff --git a/test/mock-data/bq-results/get-places-supermarket-fr.json b/test/mock-data/bq-results/get-places-supermarket-fr.json new file mode 100644 index 0000000..5de6063 --- /dev/null +++ b/test/mock-data/bq-results/get-places-supermarket-fr.json @@ -0,0 +1,125 @@ +[{ + "id": "08f184d6d16d5c5303f2f0fb615c6051", + "geometry": "POINT(-1.1516016 43.8711004)", + "bbox": { + "xmin": "-1.1516017913818359", + "xmax": "-1.1516015529632568", + "ymin": "43.87109375", + "ymax": "43.871101379394531" + }, + "version": "0", + "sources": { + "list": [{ + "element": { + "property": "", + "dataset": "meta", + "record_id": "680834765441518", + "update_time": "2024-08-02T00:00:00.000Z", + "confidence": null + } + }] + }, + "names": { + "primary": "Intermarché", + "common": null, + "rules": null + }, + "categories": { + "primary": "supermarket", + "alternate": { + "list": [{ + "element": "grocery_store" + }, { + "element": "health_food_store" + }] + } + }, + "confidence": "0.54162042175360714", + "websites": { + "list": [{ + "element": "http://www.intermarche.fr/" + }] + }, + "socials": { + "list": [{ + "element": "https://www.facebook.com/680834765441518" + }] + }, + "emails": null, + "phones": { + "list": [{ + "element": "+33558550353" + }] + }, + "brand": null, + "addresses": { + "list": [{ + "element": { + "freeform": "rue Jean de Nasse ", + "locality": "Castets", + "postcode": "40260", + "region": null, + "country": "FR" + } + }] + } +}, { + "id": "08f186b6d36e57a3037a18c4044bf6d9", + "geometry": "POINT(-0.6791638 44.8745056)", + "bbox": { + "xmin": "-0.67916381359100342", + "xmax": "-0.67916369438171387", + "ymin": "44.874504089355469", + "ymax": "44.87451171875" + }, + "version": "0", + "sources": { + "list": [{ + "element": { + "property": "", + "dataset": "meta", + "record_id": "101121412833434", + "update_time": "2024-08-02T00:00:00.000Z", + "confidence": null + } + }] + }, + "names": { + "primary": "CocciMarket Le Haillan", + "common": null, + "rules": null + }, + "categories": { + "primary": "supermarket", + "alternate": { + "list": [{ + "element": "construction_services" + }] + } + }, + "confidence": "0.54162042175360714", + "websites": null, + "socials": { + "list": [{ + "element": "https://www.facebook.com/101121412833434" + }] + }, + "emails": null, + "phones": { + "list": [{ + "element": "+33556025819" + }] + }, + "brand": null, + "addresses": { + "list": [{ + "element": { + "freeform": "194 Avenue Pasteur", + "locality": "Le Haillan", + "postcode": "33185", + "region": null, + "country": "FR" + } + }] + } +}] \ No newline at end of file