feat: implement spatial boundary checks for Bari municipal area in GeoJSON handling
This commit is contained in:
+114
-10
@@ -1,5 +1,6 @@
|
||||
#include "common/GeoJson.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace visione::geojson
|
||||
@@ -11,17 +12,12 @@ struct Point
|
||||
double x;
|
||||
double y;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson)
|
||||
bool pointInRing(double latitude,
|
||||
double longitude,
|
||||
const Json::Value &ring,
|
||||
bool *onBoundary = nullptr)
|
||||
{
|
||||
if (!geojson.isObject() || geojson["type"].asString() != "Polygon" ||
|
||||
!geojson["coordinates"].isArray() || geojson["coordinates"].empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto &ring = geojson["coordinates"][0];
|
||||
if (!ring.isArray() || ring.size() < 4)
|
||||
{
|
||||
return false;
|
||||
@@ -41,10 +37,27 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso
|
||||
const auto x = longitude;
|
||||
const auto y = latitude;
|
||||
bool inside = false;
|
||||
constexpr double epsilon = 1e-9;
|
||||
for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++)
|
||||
{
|
||||
const auto &pi = points[i];
|
||||
const auto &pj = points[j];
|
||||
const auto cross =
|
||||
(x - pi.x) * (pj.y - pi.y) - (y - pi.y) * (pj.x - pi.x);
|
||||
const auto minX = std::min(pi.x, pj.x) - epsilon;
|
||||
const auto maxX = std::max(pi.x, pj.x) + epsilon;
|
||||
const auto minY = std::min(pi.y, pj.y) - epsilon;
|
||||
const auto maxY = std::max(pi.y, pj.y) + epsilon;
|
||||
if (std::abs(cross) <= epsilon && x >= minX && x <= maxX && y >= minY &&
|
||||
y <= maxY)
|
||||
{
|
||||
if (onBoundary)
|
||||
{
|
||||
*onBoundary = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const bool intersects =
|
||||
((pi.y > y) != (pj.y > y)) &&
|
||||
(x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x);
|
||||
@@ -55,5 +68,96 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
} // namespace visione::geojson
|
||||
|
||||
bool polygonContainsPoint(double latitude,
|
||||
double longitude,
|
||||
const Json::Value &polygon)
|
||||
{
|
||||
if (!polygon.isObject() || polygon["type"].asString() != "Polygon" ||
|
||||
!polygon["coordinates"].isArray() || polygon["coordinates"].empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool onBoundary = false;
|
||||
if (!pointInRing(latitude, longitude, polygon["coordinates"][0], &onBoundary))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (onBoundary)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
for (Json::ArrayIndex i = 1; i < polygon["coordinates"].size(); ++i)
|
||||
{
|
||||
if (pointInRing(latitude, longitude, polygon["coordinates"][i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson)
|
||||
{
|
||||
return polygonContainsPoint(latitude, longitude, geojson);
|
||||
}
|
||||
|
||||
bool pointInGeometry(double latitude, double longitude, const Json::Value &geojson)
|
||||
{
|
||||
if (!geojson.isObject() || !geojson.isMember("type"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto type = geojson["type"].asString();
|
||||
if (type == "Polygon")
|
||||
{
|
||||
return polygonContainsPoint(latitude, longitude, geojson);
|
||||
}
|
||||
if (type == "MultiPolygon" && geojson["coordinates"].isArray())
|
||||
{
|
||||
for (const auto &polygonCoords : geojson["coordinates"])
|
||||
{
|
||||
Json::Value polygon(Json::objectValue);
|
||||
polygon["type"] = "Polygon";
|
||||
polygon["coordinates"] = polygonCoords;
|
||||
if (polygonContainsPoint(latitude, longitude, polygon))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool polygonInsideGeometry(const Json::Value &candidate, const Json::Value &container)
|
||||
{
|
||||
if (!candidate.isObject() || candidate["type"].asString() != "Polygon" ||
|
||||
!candidate["coordinates"].isArray() || candidate["coordinates"].empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto &ring = candidate["coordinates"][0];
|
||||
if (!ring.isArray() || ring.size() < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (const auto &coordinate : ring)
|
||||
{
|
||||
if (!coordinate.isArray() || coordinate.size() < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!pointInGeometry(coordinate[1].asDouble(),
|
||||
coordinate[0].asDouble(),
|
||||
container))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace visione::geojson
|
||||
|
||||
+2
-1
@@ -5,5 +5,6 @@
|
||||
namespace visione::geojson
|
||||
{
|
||||
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson);
|
||||
bool pointInGeometry(double latitude, double longitude, const Json::Value &geojson);
|
||||
bool polygonInsideGeometry(const Json::Value &candidate, const Json::Value &container);
|
||||
} // namespace visione::geojson
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ void sendAdmin(Json::Value body,
|
||||
{
|
||||
status = drogon::k422UnprocessableEntity;
|
||||
}
|
||||
else if (error == "area_outside_allowed_boundary")
|
||||
{
|
||||
status = drogon::k422UnprocessableEntity;
|
||||
}
|
||||
callback(visione::http::json(std::move(body), status));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -79,6 +79,10 @@ void sendService(Json::Value body,
|
||||
{
|
||||
status = drogon::k422UnprocessableEntity;
|
||||
}
|
||||
else if (error == "coordinates_outside_allowed_boundary")
|
||||
{
|
||||
status = drogon::k422UnprocessableEntity;
|
||||
}
|
||||
callback(visione::http::json(std::move(body), status));
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Authorization: Bearer <token>
|
||||
public external-team summary link
|
||||
- `GET /open-data/reports`
|
||||
anonymized reports in `RESOLVED` or `CLOSED`
|
||||
filtered to Bari municipal boundary
|
||||
|
||||
## User Reference / Personal
|
||||
- `GET /report-categories`
|
||||
@@ -63,6 +64,7 @@ Authorization: Bearer <token>
|
||||
## Report Endpoints
|
||||
- `POST /reports`
|
||||
create citizen report, optional inline `media`, area inferred by GeoJSON
|
||||
coordinates must stay inside allowed Bari municipal boundary
|
||||
- `GET /reports`
|
||||
citizen sees own reports; operator/admin see all
|
||||
- `GET /reports/{id}`
|
||||
@@ -111,6 +113,7 @@ Authorization: Bearer <token>
|
||||
- `PATCH /admin/categories/{id}`
|
||||
- `POST /admin/areas`
|
||||
- `PATCH /admin/areas/{id}`
|
||||
area polygon must stay inside allowed Bari municipal boundary
|
||||
- `POST /admin/teams`
|
||||
- `PATCH /admin/teams/{id}`
|
||||
- `GET /admin/users`
|
||||
@@ -153,3 +156,5 @@ Authorization: Bearer <token>
|
||||
- `assignments` is historical external team linkage only.
|
||||
- Feedback does not auto-close report.
|
||||
- Public intervention summary exposes citizen phone by design, but not email or full surname.
|
||||
- Report coordinates are constrained to Bari municipal boundary.
|
||||
- Admin-created area polygons must stay inside Bari municipal boundary.
|
||||
|
||||
@@ -47,6 +47,8 @@ Effective permission helpers:
|
||||
- `take` exists for `OPEN -> UNDER_REVIEW` without team assignment.
|
||||
- External team link is public, revocable, tied to current assignment, hashed in
|
||||
`intervention_access_links`.
|
||||
- Spatial scope is constrained to Bari municipal boundary from
|
||||
`allowed_boundaries.code = 'BARI'`.
|
||||
|
||||
## Status Machine
|
||||
- Allowed statuses: `OPEN`, `UNDER_REVIEW`, `ASSIGNED`, `IN_PROGRESS`,
|
||||
@@ -68,6 +70,7 @@ Effective permission helpers:
|
||||
- `moderation_flags`
|
||||
- `report_category_team_categories`
|
||||
- `intervention_access_links`
|
||||
- `allowed_boundaries`
|
||||
|
||||
## HTTP/Error Conventions
|
||||
- `401`: missing/invalid auth
|
||||
@@ -94,4 +97,5 @@ Output path:
|
||||
hardcoded allowlist, not user input.
|
||||
- Public intervention summary currently excludes email and full surname, but
|
||||
still exposes citizen phone by design.
|
||||
- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard.
|
||||
- No frontend doc set here beyond API/OpenAPI docs.
|
||||
|
||||
@@ -36,6 +36,8 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB
|
||||
- `report_category_team_categories`
|
||||
explicit compatibility mapping:
|
||||
report category -> team category
|
||||
- `allowed_boundaries`
|
||||
active allowed municipal containment geometry, currently seeded with `BARI`
|
||||
|
||||
## Report Domain
|
||||
- `reports`
|
||||
@@ -101,3 +103,4 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB
|
||||
- No dedicated table for external team personnel
|
||||
- No route-optimization/routing tables
|
||||
- No audit table for intervention link access beyond `last_accessed_at`
|
||||
- Containment check for admin area polygons is simple vertex-inside check
|
||||
|
||||
+12
-3
@@ -40,9 +40,10 @@ public revocable link generated by operator/admin.
|
||||
### Citizen flow
|
||||
1. Register/login.
|
||||
2. `POST /reports` with category, coordinates, address, description, optional media array.
|
||||
3. Backend infers `area_id` by GeoJSON polygon match when possible.
|
||||
4. Report starts `OPEN`, status history row inserted.
|
||||
5. Citizen can:
|
||||
3. Backend first rejects coordinates outside Bari municipal boundary.
|
||||
4. Backend infers `area_id` by GeoJSON polygon match when possible.
|
||||
5. Report starts `OPEN`, status history row inserted.
|
||||
6. Citizen can:
|
||||
- list own reports
|
||||
- read detail/history/media
|
||||
- chat on own report
|
||||
@@ -72,6 +73,7 @@ public revocable link generated by operator/admin.
|
||||
|
||||
### Admin flow
|
||||
- Create/update categories, areas, teams
|
||||
- Area GeoJSON must stay inside Bari municipal boundary
|
||||
- List users, edit roles, ban/unban
|
||||
- Read stats
|
||||
- Review moderation queue and resolve/dismiss flags
|
||||
@@ -89,6 +91,13 @@ public revocable link generated by operator/admin.
|
||||
- `GET /interventions/{token}` returns public summary for field team:
|
||||
report data, location, media, assigned team, masked citizen identity, phone.
|
||||
|
||||
## Spatial Boundary Rule
|
||||
- `allowed_boundaries` stores active municipal allowed area.
|
||||
- Current seed uses `BARI`.
|
||||
- Report coordinates outside boundary are rejected.
|
||||
- Admin area polygons outside boundary are rejected.
|
||||
- Open data output is filtered again by same boundary.
|
||||
|
||||
## Current Notifications / Rewards / Moderation
|
||||
- Notifications: local in-app only.
|
||||
- Reward points: awarded once per `(report_id, reason)`.
|
||||
|
||||
@@ -38,6 +38,7 @@ moderate abuse, and inspect operational stats.
|
||||
- create report with category, coordinates, address, description
|
||||
- optional inline media on creation
|
||||
- later media upload up to total 5 files
|
||||
- coordinates must stay inside Bari municipal boundary
|
||||
- area inferred by GeoJSON polygon match
|
||||
- citizen report list/detail/history/media
|
||||
- operator queues:
|
||||
@@ -79,6 +80,7 @@ moderate abuse, and inspect operational stats.
|
||||
### Admin
|
||||
- create/update categories
|
||||
- create/update areas
|
||||
area polygons must stay inside Bari municipal boundary
|
||||
- create/update teams
|
||||
- list users, update roles, ban/unban
|
||||
- global stats including area/category/team resolution aggregates
|
||||
@@ -87,6 +89,7 @@ moderate abuse, and inspect operational stats.
|
||||
- active area list
|
||||
- anonymized resolved/closed open-data export
|
||||
- public intervention token endpoint
|
||||
- open-data output filtered to Bari municipal boundary
|
||||
|
||||
## Current Workflow Summary
|
||||
### Citizen
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
#include "services/AdminService.h"
|
||||
|
||||
#include "common/GeoJson.h"
|
||||
#include "common/Validation.h"
|
||||
#include "repositories/Sql.h"
|
||||
#include "services/UserService.h"
|
||||
@@ -7,6 +8,7 @@
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
@@ -87,6 +89,35 @@ bool isActiveField(const Json::Value &body)
|
||||
{
|
||||
return body.isMember("active") && body["active"].isBool();
|
||||
}
|
||||
|
||||
std::optional<Json::Value> loadAllowedBoundary(const std::string &code)
|
||||
{
|
||||
const auto rows = drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
|
||||
"FROM allowed_boundaries WHERE code = ? AND active = TRUE LIMIT 1",
|
||||
code);
|
||||
if (rows.empty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Json::CharReaderBuilder builder;
|
||||
Json::Value geojson;
|
||||
std::string errors;
|
||||
std::istringstream stream(rows[0]["geojson"].as<std::string>());
|
||||
if (!Json::parseFromStream(builder, stream, &geojson, &errors))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return geojson;
|
||||
}
|
||||
|
||||
bool isAreaInsideAllowedBoundary(const Json::Value &boundaryGeojson)
|
||||
{
|
||||
const auto allowed = loadAllowedBoundary("BARI");
|
||||
return allowed &&
|
||||
geojson::polygonInsideGeometry(boundaryGeojson, *allowed);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value AdminService::createCategory(const auth::UserContext &user,
|
||||
@@ -119,6 +150,10 @@ Json::Value AdminService::createArea(const auth::UserContext &user,
|
||||
{
|
||||
return failure("invalid_area");
|
||||
}
|
||||
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
|
||||
{
|
||||
return failure("area_outside_allowed_boundary");
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder writer;
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
@@ -410,6 +445,10 @@ Json::Value AdminService::updateArea(const auth::UserContext &user,
|
||||
}
|
||||
if (body["boundary_geojson"].isObject())
|
||||
{
|
||||
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
|
||||
{
|
||||
return failure("area_outside_allowed_boundary");
|
||||
}
|
||||
Json::StreamWriterBuilder writer;
|
||||
db->execSqlSync(
|
||||
"UPDATE areas SET boundary_geojson = CAST(? AS JSON) WHERE id = ?",
|
||||
|
||||
@@ -75,6 +75,34 @@ std::string maskedLastName(const std::string &lastName)
|
||||
return std::string(1, static_cast<char>(std::toupper(lastName.front()))) + ".";
|
||||
}
|
||||
|
||||
std::optional<Json::Value> loadAllowedBoundary(const std::string &code)
|
||||
{
|
||||
const auto rows = drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
|
||||
"FROM allowed_boundaries WHERE code = ? AND active = TRUE LIMIT 1",
|
||||
code);
|
||||
if (rows.empty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Json::CharReaderBuilder builder;
|
||||
Json::Value geojson;
|
||||
std::string errors;
|
||||
std::istringstream stream(rows[0]["geojson"].as<std::string>());
|
||||
if (!Json::parseFromStream(builder, stream, &geojson, &errors))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return geojson;
|
||||
}
|
||||
|
||||
bool insideAllowedBoundary(double latitude, double longitude)
|
||||
{
|
||||
const auto boundary = loadAllowedBoundary("BARI");
|
||||
return boundary && geojson::pointInGeometry(latitude, longitude, *boundary);
|
||||
}
|
||||
|
||||
std::optional<long long> findArea(double latitude, double longitude)
|
||||
{
|
||||
const auto result = drogon::app().getDbClient()->execSqlSync(
|
||||
@@ -89,7 +117,7 @@ std::optional<long long> findArea(double latitude, double longitude)
|
||||
const auto raw = row["geojson"].as<std::string>();
|
||||
std::istringstream stream(raw);
|
||||
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
|
||||
geojson::pointInPolygon(latitude, longitude, geojson))
|
||||
geojson::pointInGeometry(latitude, longitude, geojson))
|
||||
{
|
||||
return row["id"].as<long long>();
|
||||
}
|
||||
@@ -397,6 +425,10 @@ Json::Value ReportService::createReport(const auth::UserContext &user,
|
||||
|
||||
const auto latitude = body["latitude"].asDouble();
|
||||
const auto longitude = body["longitude"].asDouble();
|
||||
if (!insideAllowedBoundary(latitude, longitude))
|
||||
{
|
||||
return failure("coordinates_outside_allowed_boundary");
|
||||
}
|
||||
const auto areaId = findArea(latitude, longitude);
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
@@ -1215,6 +1247,20 @@ Json::Value ReportService::openDataReports(const std::optional<std::string> &pag
|
||||
perPageValue,
|
||||
offset),
|
||||
openDataReportSchema());
|
||||
if (const auto boundary = loadAllowedBoundary("BARI"))
|
||||
{
|
||||
Json::Value filtered(Json::arrayValue);
|
||||
for (const auto &report : response["reports"])
|
||||
{
|
||||
if (geojson::pointInGeometry(report["latitude"].asDouble(),
|
||||
report["longitude"].asDouble(),
|
||||
*boundary))
|
||||
{
|
||||
filtered.append(report);
|
||||
}
|
||||
}
|
||||
response["reports"] = std::move(filtered);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,61 @@ DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
|
||||
|
||||
CHECK(visione::geojson::pointInPolygon(41.5, 12.5, polygon));
|
||||
CHECK(!visione::geojson::pointInPolygon(43.0, 12.5, polygon));
|
||||
CHECK(visione::geojson::pointInGeometry(41.5, 12.5, polygon));
|
||||
CHECK(!visione::geojson::pointInGeometry(43.0, 12.5, polygon));
|
||||
}
|
||||
|
||||
DROGON_TEST(GeoJsonAcceptsContainedPolygon)
|
||||
{
|
||||
Json::Value container;
|
||||
container["type"] = "Polygon";
|
||||
Json::Value outer(Json::arrayValue);
|
||||
|
||||
Json::Value a(Json::arrayValue);
|
||||
a.append(12.0);
|
||||
a.append(41.0);
|
||||
Json::Value b(Json::arrayValue);
|
||||
b.append(13.0);
|
||||
b.append(41.0);
|
||||
Json::Value c(Json::arrayValue);
|
||||
c.append(13.0);
|
||||
c.append(42.0);
|
||||
Json::Value d(Json::arrayValue);
|
||||
d.append(12.0);
|
||||
d.append(42.0);
|
||||
outer.append(a);
|
||||
outer.append(b);
|
||||
outer.append(c);
|
||||
outer.append(d);
|
||||
outer.append(a);
|
||||
container["coordinates"].append(outer);
|
||||
|
||||
Json::Value candidate;
|
||||
candidate["type"] = "Polygon";
|
||||
Json::Value inner(Json::arrayValue);
|
||||
Json::Value p1(Json::arrayValue);
|
||||
p1.append(12.2);
|
||||
p1.append(41.2);
|
||||
Json::Value p2(Json::arrayValue);
|
||||
p2.append(12.8);
|
||||
p2.append(41.2);
|
||||
Json::Value p3(Json::arrayValue);
|
||||
p3.append(12.8);
|
||||
p3.append(41.8);
|
||||
Json::Value p4(Json::arrayValue);
|
||||
p4.append(12.2);
|
||||
p4.append(41.8);
|
||||
inner.append(p1);
|
||||
inner.append(p2);
|
||||
inner.append(p3);
|
||||
inner.append(p4);
|
||||
inner.append(p1);
|
||||
candidate["coordinates"].append(inner);
|
||||
|
||||
CHECK(visione::geojson::polygonInsideGeometry(candidate, container));
|
||||
|
||||
candidate["coordinates"][0][3][0] = 14.0;
|
||||
CHECK(!visione::geojson::polygonInsideGeometry(candidate, container));
|
||||
}
|
||||
|
||||
DROGON_TEST(PasswordHashSupportsPbkdf2Only)
|
||||
|
||||
Reference in New Issue
Block a user