feat: implement spatial boundary checks for Bari municipal area in GeoJSON handling

This commit is contained in:
gabdevele
2026-06-15 14:45:38 +02:00 Unverified
parent 088a2ed866
commit aab7420706
13 changed files with 310 additions and 15 deletions
+114 -10
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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));
}
+5
View File
@@ -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.
+4
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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)`.
+3
View File
@@ -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
+39
View File
@@ -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 = ?",
+47 -1
View File
@@ -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;
}
+55
View File
@@ -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)