From e995a5a971df19c9c55e6a2758dfaa1f591610b1 Mon Sep 17 00:00:00 2001 From: Andrei Malykh Date: Wed, 8 Jan 2025 19:57:06 +0300 Subject: [PATCH 1/4] Add route building --- server/src/app.module.ts | 2 + .../modules/routes/dto/create-route.dto.ts | 10 ++--- .../{routes.entity.ts => route.entity.ts} | 0 .../src/modules/routes/routes.controller.ts | 24 +++++----- server/src/modules/routes/routes.service.ts | 45 +++++++------------ 5 files changed, 33 insertions(+), 48 deletions(-) rename server/src/modules/routes/entities/{routes.entity.ts => route.entity.ts} (100%) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 003c9b4..dee5b70 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -6,6 +6,7 @@ import {Neo4jConfig} from './modules/neo4j/neo4j-config.interface'; import {PointsOfInterestModule} from './modules/points-of-interest/points-of-interest.module'; import {UsersModule} from "./modules/users/users.module"; import {AuthorizationModule} from "./modules/authorization/authorization.module"; +import { RoutesModule } from './modules/routes/routes.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import {AuthorizationModule} from "./modules/authorization/authorization.module" PointsOfInterestModule, UsersModule, AuthorizationModule, + RoutesModule ], controllers: [AppController], providers: [], diff --git a/server/src/modules/routes/dto/create-route.dto.ts b/server/src/modules/routes/dto/create-route.dto.ts index 59242d4..01dd253 100644 --- a/server/src/modules/routes/dto/create-route.dto.ts +++ b/server/src/modules/routes/dto/create-route.dto.ts @@ -1,4 +1,4 @@ -import {IsString, IsNumber, MaxLength, MinLength} from "class-validator"; +import { IsString, MaxLength, MinLength, IsArray, ArrayNotEmpty } from "class-validator"; export class CreateRouteDto { @IsString() @@ -10,9 +10,7 @@ export class CreateRouteDto { @MaxLength(2000) description: string; - @IsNumber() - length: number; - - @IsNumber() - duration: number; + @IsArray() + @ArrayNotEmpty() + poiList: string[]; } diff --git a/server/src/modules/routes/entities/routes.entity.ts b/server/src/modules/routes/entities/route.entity.ts similarity index 100% rename from server/src/modules/routes/entities/routes.entity.ts rename to server/src/modules/routes/entities/route.entity.ts diff --git a/server/src/modules/routes/routes.controller.ts b/server/src/modules/routes/routes.controller.ts index 419641b..03364e0 100644 --- a/server/src/modules/routes/routes.controller.ts +++ b/server/src/modules/routes/routes.controller.ts @@ -1,28 +1,26 @@ -import {Body, Controller, Get, Param, Post, UsePipes, ValidationPipe} from '@nestjs/common'; -import {RoutesService} from './routes.service'; -import {CreateRouteDto} from "./dto/create-route.dto"; -import {Public} from "../authorization/public.decorator"; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; +import { RoutesService } from './routes.service'; +import { Public } from '../authorization/public.decorator'; -@Controller("/api/route") +@Controller("/api/routes") export class RoutesController { constructor(private readonly RoutesService: RoutesService) { } - @UsePipes(new ValidationPipe()) - @Post() - async create(@Body() createRoutesDto: CreateRouteDto) { - return await this.RoutesService.create(createRoutesDto); + @Public() + @HttpCode(HttpStatus.OK) + @Post("build") + async build(@Body() poiList: number[]) { + return await this.RoutesService.build(poiList); } - @Public() @Get() - findAll() { + async findAll() { return this.RoutesService.findAll(); } - @Public() @Get(':id') - findOne(@Param('id') id: string) { + async findOne(@Param('id') id: string) { return this.RoutesService.findOne(id); } } diff --git a/server/src/modules/routes/routes.service.ts b/server/src/modules/routes/routes.service.ts index 718f03f..6093e85 100644 --- a/server/src/modules/routes/routes.service.ts +++ b/server/src/modules/routes/routes.service.ts @@ -1,44 +1,31 @@ import {Injectable, NotFoundException} from "@nestjs/common"; import {CreateRouteDto} from "./dto/create-route.dto"; import {Neo4jService} from "../neo4j/neo4j.service"; -import Route from "./entities/routes.entity"; +import Route from "./entities/route.entity"; @Injectable() export class RoutesService { - // private static readonly FIND_INTERSECTION_RADIUS: number = 100000; - constructor(private readonly neo4jService: Neo4jService) { } - async create(dto: CreateRouteDto) { + async build(poiList: number[]) { const session = this.neo4jService.getWriteSession(); try { - // const result = await session.run( - // `WITH point({latitude: ${dto.location.latitude}, longitude: ${dto.location.longitude}}) AS l - // CALL (l) { - // MATCH (i: Intersection WHERE point.distance(i.location, l) < ${(PointsOfInterestService.FIND_INTERSECTION_RADIUS)}) - // RETURN i - // ORDER BY point.distance(i.location, l) ASC - // LIMIT 1 - // } - // WITH i, l - // CREATE (poi :Route { - // name: "${dto.name}", - // description: "${dto.description}", - // location: l, - // created_at: Datetime()} - // )-[r: CLOSE_TO_THE]->(i) - // RETURN poi`); - // const node = result.records.at(0).get('poi'); - // return new Route( - // node.elementId, - // node.properties.name, - // node.properties.description, - // node.properties.length, - // node.properties.duration, - // node.properties.created_at.toString() - // ); + const result = await session.run( + `CALL () { + UNWIND $poi_list AS poi_id + MATCH(poi :PointOfInterest WHERE elementId(poi) = poi_id)-[:CLOSE_TO_THE]->(i :Intersection) + RETURN collect(DISTINCT i) AS i_list + } + WITH i_list[0..-1] AS i1, i_list[1..] AS i2 + UNWIND apoc.coll.zip(i1, i2) AS segment + CALL apoc.algo.aStarConfig(segment[0], segment[1], "ROAD_SEGMENT", {pointPropName: "location", weight: "length"}) + YIELD weight, path + RETURN weight AS length, path`, + {poi_list: poiList} + ); + return result.records.map(record => record.toObject()); } finally { await session.close(); } From 959674d4d25f96610c3b7667e2a01900337a4a05 Mon Sep 17 00:00:00 2001 From: Andrei Malykh Date: Sat, 11 Jan 2025 02:52:40 +0300 Subject: [PATCH 2/4] Implement routes api --- .../modules/routes/dto/create-route.dto.ts | 6 +- .../modules/routes/entities/route.entity.ts | 5 +- .../src/modules/routes/routes.controller.ts | 12 +- server/src/modules/routes/routes.service.ts | 115 ++++++++++++++++-- 4 files changed, 122 insertions(+), 16 deletions(-) diff --git a/server/src/modules/routes/dto/create-route.dto.ts b/server/src/modules/routes/dto/create-route.dto.ts index 01dd253..214b8f1 100644 --- a/server/src/modules/routes/dto/create-route.dto.ts +++ b/server/src/modules/routes/dto/create-route.dto.ts @@ -1,4 +1,4 @@ -import { IsString, MaxLength, MinLength, IsArray, ArrayNotEmpty } from "class-validator"; +import { IsString, MaxLength, MinLength, IsArray, ArrayMinSize } from "class-validator"; export class CreateRouteDto { @IsString() @@ -11,6 +11,6 @@ export class CreateRouteDto { description: string; @IsArray() - @ArrayNotEmpty() - poiList: string[]; + @ArrayMinSize(2) + points: string[]; } diff --git a/server/src/modules/routes/entities/route.entity.ts b/server/src/modules/routes/entities/route.entity.ts index 093f1a5..c74ecab 100644 --- a/server/src/modules/routes/entities/route.entity.ts +++ b/server/src/modules/routes/entities/route.entity.ts @@ -1,3 +1,4 @@ +import PointOfInterest from "src/modules/points-of-interest/entities/point-of-interest.entity"; export default class Route { id: string; @@ -6,14 +7,16 @@ export default class Route { length: number; duration: number; createdAt: string; + points: PointOfInterest[] - constructor(id: string, name: string, description: string, length: number, duration: number, createdAt: string) { + constructor(id: string, name: string, description: string, length: number, duration: number, createdAt: string, points: PointOfInterest[]) { this.id = id; this.name = name; this.description = description; this.length = length; this.duration = duration; this.createdAt = createdAt; + this.points = points } } diff --git a/server/src/modules/routes/routes.controller.ts b/server/src/modules/routes/routes.controller.ts index 03364e0..2de2e97 100644 --- a/server/src/modules/routes/routes.controller.ts +++ b/server/src/modules/routes/routes.controller.ts @@ -1,16 +1,24 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UsePipes, ValidationPipe } from '@nestjs/common'; import { RoutesService } from './routes.service'; import { Public } from '../authorization/public.decorator'; +import { CreateRouteDto } from './dto/create-route.dto'; @Controller("/api/routes") export class RoutesController { constructor(private readonly RoutesService: RoutesService) { } + @UsePipes(new ValidationPipe()) + @Post() + async create(@Body() createRouteDto: CreateRouteDto, @Req() req: any) { + console.log(req.user) + return this.RoutesService.create(createRouteDto, req.user.sub); + } + @Public() @HttpCode(HttpStatus.OK) @Post("build") - async build(@Body() poiList: number[]) { + async build(@Body() poiList: string[]) { return await this.RoutesService.build(poiList); } diff --git a/server/src/modules/routes/routes.service.ts b/server/src/modules/routes/routes.service.ts index 9b0c9d8..f9f7539 100644 --- a/server/src/modules/routes/routes.service.ts +++ b/server/src/modules/routes/routes.service.ts @@ -1,6 +1,8 @@ -import {Injectable, NotFoundException} from "@nestjs/common"; -import {Neo4jService} from "../neo4j/neo4j.service"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; import Route from "./entities/route.entity"; +import { CreateRouteDto } from "./dto/create-route.dto"; +import PointOfInterest from "../points-of-interest/entities/point-of-interest.entity"; @Injectable() @@ -8,7 +10,68 @@ export class RoutesService { constructor(private readonly neo4jService: Neo4jService) { } - async build(poiList: number[]) { + + async create(dto: CreateRouteDto, userId: string) { + const session = this.neo4jService.getWriteSession(); + try { + const transaction = session.beginTransaction(); + const result = await transaction.run(` + MATCH (user: User WHERE elementId(user) = "${userId}") + CREATE (user)-[:CREATED]->(route :Route { + name: "${dto.name}", + description: "${dto.description}", + created_at: Datetime() + }) + WITH route, $poi_list AS points + UNWIND points AS point + OPTIONAL MATCH (poi :PointOfInterest WHERE elementId(poi)=point) + WITH route, poi, + CASE WHEN poi IS NULL THEN TRUE ELSE FALSE END AS node_not_found, + RANGE(0, SIZE(points) - 1) AS order_list, point + CALL apoc.util.validate(node_not_found,'Point of interest with id ' + point + ' not found', [404]) + WITH collect(poi) AS points, order_list, route + UNWIND apoc.coll.zip(points, order_list) AS poi + WITH poi[0] AS val, poi[1] AS i, route + CREATE (route)-[:INCLUDE{order: i}]->(val) + RETURN elementId(route) AS Id + LIMIT 1 + `, + { poi_list: dto.points } + ); + const routeId = result.records.at(0)?.get('Id'); + if (!routeId) { + throw new NotFoundException(`User with id ${userId} not found`) + } + transaction.run(` + MATCH (route: Route WHERE elementId(route) = $routeId) + CALL () { + MATCH + (route)-[inc1: INCLUDE]-> + (: PointOfInterest)-[:CLOSE_TO_THE]->(i1: Intersection), + (route)-[inc2: INCLUDE{order: inc1.order + 1}]-> + (: PointOfInterest)-[:CLOSE_TO_THE]->(i2: Intersection) + RETURN i1, i2 + ORDER BY inc1.order + } + WITH route, i1, i2 + CALL apoc.algo.aStarConfig(i1, i2, "ROAD_SEGMENT", {pointPropName: "location", weight: "length"}) + YIELD weight + WITH SUM(weight) as total_distance, route + SET route.length = total_distance + SET route.duration = total_distance / 78 + RETURN route + `, + { routeId: routeId } + ); + await transaction.commit(); + return await this.findOne(routeId); + } finally { + await session.close(); + } + } + + + async build(poiList: string[]) { const session = this.neo4jService.getWriteSession(); try { const result = await session.run( @@ -21,8 +84,8 @@ export class RoutesService { UNWIND apoc.coll.zip(i1, i2) AS segment CALL apoc.algo.aStarConfig(segment[0], segment[1], "ROAD_SEGMENT", {pointPropName: "location", weight: "length"}) YIELD weight, path - RETURN weight AS length, path`, - {poi_list: poiList} + RETURN weight AS length, path`, + { poi_list: poiList } ); return result.records.map(record => record.toObject()); } finally { @@ -33,18 +96,33 @@ export class RoutesService { async findAll() { const session = this.neo4jService.getReadSession(); try { - const result = await session.run( - `MATCH (route :Route) RETURN route` + const result = await session.run(` + MATCH (route :Route) + CALL (route) { + MATCH (route)-[inc :INCLUDE]->(poi :PointOfInterest) + RETURN poi + ORDER BY inc.order + } + RETURN route, collect(poi) AS poi_list + ` ) return result.records.map(record => { const node = record.get('route'); + const poiList: PointOfInterest[] = record.get('poi_list').map(poi => new PointOfInterest( + poi.elementId, + poi.properties.name, + poi.properties.description, + poi.properties.location, + poi.properties.created_at.toString(), + )); return new Route( node.elementId, node.properties.name, node.properties.description, node.properties.length, node.properties.duration, - node.properties.created_at.toString() + node.properties.created_at.toString(), + poiList ); }); } finally { @@ -56,9 +134,25 @@ export class RoutesService { const session = this.neo4jService.getReadSession(); try { const result = await session.run( - `MATCH (route :Route WHERE elementId(route) = "${id}") RETURN route` + ` + MATCH (route :Route WHERE elementId(route) = "${id}") + CALL (route) { + MATCH (route)-[inc :INCLUDE]->(poi :PointOfInterest) + RETURN poi + ORDER BY inc.order + } + RETURN route, collect(poi) AS poi_list + ` ) + console.log(result.records) const node = result.records.at(0)?.get('route'); + const poiList: PointOfInterest[] = result.records.at(0)?.get('poi_list').map(poi => new PointOfInterest( + poi.elementId, + poi.properties.name, + poi.properties.description, + poi.properties.location, + poi.properties.created_at.toString(), + )); if (!node) { throw new NotFoundException(`Route with id: ${id} not found`); } @@ -68,7 +162,8 @@ export class RoutesService { node.properties.description, node.properties.length, node.properties.duration, - node.properties.created_at.toString() + node.properties.created_at.toString(), + poiList ); } finally { await session.close(); From 036baa7cb88bb6d13bc43203cf304ccbce05d8d9 Mon Sep 17 00:00:00 2001 From: Andrei Malykh Date: Sat, 11 Jan 2025 02:53:26 +0300 Subject: [PATCH 3/4] Add error handling for apoc validating --- server/src/modules/neo4j/neo4j-error.filter.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/modules/neo4j/neo4j-error.filter.ts b/server/src/modules/neo4j/neo4j-error.filter.ts index 5a632e8..4cdfcd1 100644 --- a/server/src/modules/neo4j/neo4j-error.filter.ts +++ b/server/src/modules/neo4j/neo4j-error.filter.ts @@ -11,8 +11,10 @@ export class Neo4jErrorFilter implements ExceptionFilter { let statusCode = 500; let error = 'Internal Server Error'; - let message: string[] = undefined; + let message: string[] = [exception.message]; + // Neo.ClientError.Schema.ConstraintValidationFailed + // Node already exists with label `...` and property `...`' if (exception.message.includes('already exists with')) { const [_, property] = exception.message.match(/`([a-z0-9]+)`/gi); message = [`${property.replace(/`/g, '')} already taken`]; @@ -20,6 +22,15 @@ export class Neo4jErrorFilter implements ExceptionFilter { error = 'Bad Request'; } + // Neo.ClientError.Procedure.ProcedureCallFailed + // Failed to invoke procedure `apoc.util.validate`: Caused by: java.lang.RuntimeException: Point of interest with id ... not found + if (exception.message.includes('not found')) { + const messagePosition = exception.message.lastIndexOf(': ') + message = [exception.message.substring(messagePosition + 2)]; + statusCode = 404; + error = 'Not Found'; + } + response .status(statusCode) .json({ From c5eba0700d3bb6b7c54441c5b538e049c5300744 Mon Sep 17 00:00:00 2001 From: Andrei Malykh Date: Sat, 11 Jan 2025 02:55:02 +0300 Subject: [PATCH 4/4] Style fixes --- .../points-of-interest/points-of-interest.service.ts | 9 +++++---- server/src/modules/users/users.module.ts | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/modules/points-of-interest/points-of-interest.service.ts b/server/src/modules/points-of-interest/points-of-interest.service.ts index fcabe79..2c29a9d 100644 --- a/server/src/modules/points-of-interest/points-of-interest.service.ts +++ b/server/src/modules/points-of-interest/points-of-interest.service.ts @@ -1,6 +1,6 @@ -import {Injectable, NotFoundException} from "@nestjs/common"; -import {CreatePointOfInterestDto} from "./dto/create-point-of-interest.dto"; -import {Neo4jService} from "../neo4j/neo4j.service"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { CreatePointOfInterestDto } from "./dto/create-point-of-interest.dto"; +import { Neo4jService } from "../neo4j/neo4j.service"; import PointOfInterest from "./entities/point-of-interest.entity"; @@ -29,7 +29,8 @@ export class PointsOfInterestService { location: l, created_at: Datetime()} )-[r: CLOSE_TO_THE]->(i) - RETURN poi`); + RETURN poi` + ); const node = result.records.at(0).get('poi'); return new PointOfInterest( node.elementId, diff --git a/server/src/modules/users/users.module.ts b/server/src/modules/users/users.module.ts index bab9f17..276e9b6 100644 --- a/server/src/modules/users/users.module.ts +++ b/server/src/modules/users/users.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; -import {JwtGuard} from "../authorization/jwt.guard"; @Module({ controllers: [UsersController],