diff --git a/fastify/assets/@types/fastify.d.ts b/fastify/assets/@types/fastify.d.ts index c99bb5a..0591b97 100644 --- a/fastify/assets/@types/fastify.d.ts +++ b/fastify/assets/@types/fastify.d.ts @@ -113,6 +113,10 @@ declare module 'fastify' { researchUsers(userId: number, username: string, limit: number = 5, offset: number = 0, radius: number = 25, filters?: BrowsingFilter, sort?: BrowsingSort): Promise>; }; + mapService: { + getNearUsers(level: number, latitude: number, longitude: number, radius: number): Promise<{users: MapUser[], clusters: MapUserCluster[]}>; + } + nodemailer: any; facebookOAuth2: OAuth2Namespace; diff --git a/fastify/assets/srcs/app.ts b/fastify/assets/srcs/app.ts index 154cdc8..2ada908 100644 --- a/fastify/assets/srcs/app.ts +++ b/fastify/assets/srcs/app.ts @@ -16,6 +16,7 @@ import mailServicePlugin from './services/MailService' import notificationService from './services/NotificationsServices' import reportService from './services/ReportService' import browsingService from './services/BrowsingService' +import mapService from './services/MapService' // import custom plugins import authenticate from './plugins/authenticate' import checkImageConformity from './plugins/checkImageConformity' @@ -85,6 +86,8 @@ export const buildApp = () => { app.register(browsingService); + app.register(mapService); + app.register(pg, { connectionString: process.env.PG }); diff --git a/fastify/assets/srcs/controllers/private/map/index.ts b/fastify/assets/srcs/controllers/private/map/index.ts new file mode 100644 index 0000000..2097815 --- /dev/null +++ b/fastify/assets/srcs/controllers/private/map/index.ts @@ -0,0 +1,27 @@ +import { FastifyRequest, FastifyReply, FastifyRequestUser } from 'fastify'; +import { AppError, UnauthorizedError } from '../../../utils/error'; + +export const getNearUsersHandler = async ( + request: FastifyRequest, + reply: FastifyReply +) => { + const params = request.query as { level: string, latitude: string, longitude: string, radius: string }; + const level = Number(params.level); + const latitude = Number(params.latitude); + const longitude = Number(params.longitude); + let radius = Number(params.radius); + + try { + const user = request.user as FastifyRequestUser | undefined; + const userId = user?.id; + if (!userId) + throw new UnauthorizedError(); + const data = await request.server.mapService.getNearUsers(level, latitude, longitude, radius); + return reply.code(200).send(data); + } catch (error) { + console.log(error); + if (error instanceof AppError) + return reply.status(error.statusCode).send({ error: error.message }); + return reply.status(500).send({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/fastify/assets/srcs/models/User/index.ts b/fastify/assets/srcs/models/User/index.ts index af5b0bd..7c19967 100644 --- a/fastify/assets/srcs/models/User/index.ts +++ b/fastify/assets/srcs/models/User/index.ts @@ -27,6 +27,20 @@ type UserLocation = { country: string; }; +export type MapUserCluster = { + latitude: number + longitude: number + count: number +} + +export type MapUser = { + id: number + firstName: string + profilePictureUrl: string + latitude: number + longitude: number +} + type BlockedUser = { id: number; blockerId: number; @@ -111,6 +125,36 @@ export default class UserModel { return result.rows[0]; } + getUsersFromLocation = async (lat: number, lgn: number, radius: number): Promise => { + const result = await this.fastify.pg.query( + `SELECT u.id, u.first_name, u.profile_pictures[u.profile_picture_index] AS profile_picture, locations.latitude, locations.longitude + FROM locations + JOIN users AS u ON locations.user_id = u.id + WHERE locations.user_id IS NOT NULL + AND 6371 * acos(least(1, greatest(-1, cos(radians(locations.latitude)) * cos(radians($1)) * cos(radians($2) - radians(locations.longitude)) + sin(radians(locations.latitude)) * sin(radians($1))))) < $3`, + [lat, lgn, radius] + ); + + return result.rows.map((row: { id: number, first_name: string, profile_picture_url: string, latitude: number, longitude: number }) => { return { id: row.id, firstName: row.first_name, profilePictureUrl: row.profile_picture_url, latitude: row.latitude, longitude: row.longitude }}); + } + + getUsersCountByLocation = async (level: number, lat: number, lgn: number, radius: number): Promise => { + const areaName = (level == 1) ? 'city' : (level == 2) ? 'country' : null; + if (!areaName) + return []; + + const result = await this.fastify.pg.query( + `SELECT count(*) AS count, avg(latitude) AS latitude, avg(longitude) AS longitude + FROM locations + WHERE ${areaName} IS NOT NULL + AND user_id IS NOT NULL + AND 6371 * acos(least(1, greatest(-1, cos(radians(latitude)) * cos(radians($1)) * cos(radians($2) - radians(longitude)) + sin(radians(latitude)) * sin(radians($1))))) < $3 + GROUP BY ${areaName}`, + [lat, lgn, radius] + ); + return result.rows.map((row: { count: number, latitude: number, longitude: number }) => { return { count: row.count, latitude: row.latitude, longitude: row.longitude }}); + } + setVerified = async (id: number) => { await this.fastify.pg.query( 'UPDATE users SET is_verified=true WHERE id=$1', [id] diff --git a/fastify/assets/srcs/routes/private/index.ts b/fastify/assets/srcs/routes/private/index.ts index 11fcd4b..4a5926d 100644 --- a/fastify/assets/srcs/routes/private/index.ts +++ b/fastify/assets/srcs/routes/private/index.ts @@ -7,6 +7,7 @@ import reportRoutes from './report'; import browsingRoutes from './browsing'; import researchRoutes from './research'; import matchRoutes from './match'; +import mapRoutes from './map'; import statics from '@fastify/static'; import path from 'path'; @@ -23,6 +24,7 @@ export default async function privateRoutes(fastify: FastifyInstance, options: F fastify.register(browsingRoutes, { prefix: '/browsing', preHandler: fastify.checkIsCompleted }); fastify.register(researchRoutes, { prefix: '/research', preHandler: fastify.checkIsCompleted }); fastify.register(matchRoutes, { prefix: '/match', preHandler: fastify.checkIsCompleted }); + fastify.register(mapRoutes, { prefix: '/map', preHandler: fastify.checkIsCompleted }); fastify.register(wsRoutes, { prefix: '/ws', preHandler: fastify.checkIsCompleted }); fastify.register(statics, { root: path.join(__dirname, '../../../uploads'), diff --git a/fastify/assets/srcs/routes/private/map/index.ts b/fastify/assets/srcs/routes/private/map/index.ts new file mode 100644 index 0000000..b81cf85 --- /dev/null +++ b/fastify/assets/srcs/routes/private/map/index.ts @@ -0,0 +1,64 @@ +import { FastifyInstance } from 'fastify'; +import { getNearUsersHandler } from '../../../controllers/private/map'; + +const mapRoutes = async (fastify: FastifyInstance) => { + fastify.get('/', { + schema: { + debug: true, + querystring: { + type: 'object', + properties: { + level: { type: 'string' }, + latitude: { type: 'string' }, + longitude: { type: 'string' }, + radius: { type: 'string' } + }, + required: ['level', 'latitude', 'longitude', 'radius'], + additionalProperties: false + }, + response: { + 200: { + type: 'object', + properties: { + users: { type: 'array', items: { + type: 'object', + properties: { + id: { type: 'integer' }, + firstName: { type: 'string' }, + profilePicture: { type: 'string', nullable: true }, + latitude: { type: 'number' }, + longitude: { type: 'number' }, + } + }}, + clusters: { type: 'array', items: { + type: 'object', + properties: { + latitude: { type: 'number' }, + longitude: { type: 'number' }, + count: { type: 'integer' }, + } + }} + }, + additionalProperties: false + }, + 400: { + type: 'object', + properties: { + error: { type: 'string', } + }, + additionalProperties: false + }, + 500: { + type: 'object', + properties: { + error: { type: 'string' } + }, + additionalProperties: false + } + } + }, + handler: getNearUsersHandler + }); +} + +export default mapRoutes; \ No newline at end of file diff --git a/fastify/assets/srcs/services/MapService.ts b/fastify/assets/srcs/services/MapService.ts new file mode 100644 index 0000000..0d39eac --- /dev/null +++ b/fastify/assets/srcs/services/MapService.ts @@ -0,0 +1,39 @@ +import UserModel from "../models/User"; +import fp from 'fastify-plugin'; +import { MapUser, MapUserCluster } from "../models/User"; +import { FastifyInstance } from 'fastify'; +import { BadRequestError } from "../utils/error"; + +class MapService { + private fastify: FastifyInstance; + private userModel: UserModel; + + constructor(fastify: FastifyInstance) { + this.fastify = fastify; + this.userModel = new UserModel(fastify); + } + + async getNearUsers(level: number, latitude: number, longitude: number, radius: number): Promise<{ users: MapUser[], clusters: MapUserCluster[] }> { + if (isNaN(level) || isNaN(latitude) || isNaN(longitude) || isNaN(radius)) + throw new BadRequestError(); + if (level !== 0 && level !== 1 && level !== 2) + throw new BadRequestError(); + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || radius < 0) + throw new BadRequestError(); + + const data = { + users: [] as MapUser[], + clusters: [] as MapUserCluster[] + } + if (level == 0) + data.users = await this.userModel.getUsersFromLocation(latitude, longitude, radius); + else + data.clusters = await this.userModel.getUsersCountByLocation(level, latitude, longitude, radius); + return data; + } +} + +export default fp(async (fastify: FastifyInstance) => { + const mapService = new MapService(fastify); + fastify.decorate('mapService', mapService); +}); \ No newline at end of file diff --git a/fastify/assets/test/integration/private/map.test.ts b/fastify/assets/test/integration/private/map.test.ts new file mode 100644 index 0000000..92d7d4a --- /dev/null +++ b/fastify/assets/test/integration/private/map.test.ts @@ -0,0 +1,102 @@ +import { expect } from 'chai'; +import { app } from '../../setup'; +import { FastifyInstance } from 'fastify'; + +// import fixtures +import { quickUser } from '../fixtures/auth.fixtures'; + +// import utils +import { setLocalisation } from '../utils/browsing'; + +describe('Map integration tests', async () => { + + it('should be able to fetch user on level 0', async () => { + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + await setLocalisation(app, token2, 48.8570, 2.3530); + await setLocalisation(app, token3, 40.7128, -74.0060); // New York + + const response = await app.inject({ + method: 'GET', + url: `/private/map?level=0&latitude=48.8566&longitude=2.3522&radius=0.5`, // Paris coordinates + headers: { + 'Cookie': `jwt=${token3}` + } + }); + expect(response.statusCode).to.equal(200); + const data = JSON.parse(response.body); + expect(data).to.have.property('users'); + expect(data).to.have.property('clusters'); + expect(data.users).to.be.an('array').and.have.lengthOf(2); + expect(data.clusters).to.be.an('array').and.have.lengthOf(0); + }); + + it('should be able to fetch user on level 1', async () => { + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + await setLocalisation(app, token2, 48.8570, 2.3530); + await setLocalisation(app, token3, 40.7128, -74.0060); // New York + + const response = await app.inject({ + method: 'GET', + url: `/private/map?level=1&latitude=48.8566&longitude=2.3522&radius=0.5`, // Paris coordinates + headers: { + 'Cookie': `jwt=${token3}` + } + }); + expect(response.statusCode).to.equal(200); + const data = JSON.parse(response.body); + expect(data).to.have.property('users'); + expect(data).to.have.property('clusters'); + expect(data.users).to.be.an('array').and.have.lengthOf(0); + expect(data.clusters).to.be.an('array').and.have.length.that.is.above(0); + expect(data.clusters[0]).to.have.property('count').that.is.above(1); + }); + + it('should be able to fetch user on level 2', async () => { + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + await setLocalisation(app, token2, 45.7640, 4.8357); + await setLocalisation(app, token3, 40.7128, -74.0060); // New York + + const response = await app.inject({ + method: 'GET', + url: `/private/map?level=2&latitude=48.8566&longitude=2.3522&radius=0.5`, // Paris coordinates + headers: { + 'Cookie': `jwt=${token3}` + } + }); + expect(response.statusCode).to.equal(200); + const data = JSON.parse(response.body); + expect(data).to.have.property('users'); + expect(data).to.have.property('clusters'); + expect(data.users).to.be.an('array').and.have.lengthOf(0); + expect(data.clusters).to.be.an('array').and.have.length.that.is.above(0); + expect(data.clusters[0]).to.have.property('count').that.is.above(0) + expect(data.clusters[0]).to.have.property('latitude').that.is.a('number') + expect(data.clusters[0]).to.have.property('longitude').that.is.a('number'); + + const moreLargeResponse = await app.inject({ + method: 'GET', + url: `/private/map?level=2&latitude=48.8566&longitude=2.3522&radius=15000`, // Paris coordinates + headers: { + 'Cookie': `jwt=${token3}` + } + }); + expect(moreLargeResponse.statusCode).to.equal(200); + const moreLargeData = JSON.parse(moreLargeResponse.body); + expect(moreLargeData).to.have.property('users'); + expect(moreLargeData).to.have.property('clusters'); + expect(moreLargeData.users).to.be.an('array').and.have.lengthOf(0); + expect(moreLargeData.clusters).to.be.an('array').and.have.length.that.is.above(1); + }); +}); \ No newline at end of file diff --git a/fastify/assets/test/integration/ws/like.test.ts b/fastify/assets/test/integration/ws/like.test.ts index 7ffdddb..482bc70 100644 --- a/fastify/assets/test/integration/ws/like.test.ts +++ b/fastify/assets/test/integration/ws/like.test.ts @@ -261,7 +261,6 @@ describe('Websocket like test', () => { 'Cookie': `jwt=${tokenA}` } }); - console.log('get matches response:', res.body); expect(res.statusCode).to.equal(200); const resData = JSON.parse(res.body); const matches = resData.matches;