Add Visione backend foundation

This commit is contained in:
gabdevele
2026-06-02 19:22:00 +02:00 Unverified
commit 580789ac83
52 changed files with 5008 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
#include "services/ReportService.h"
#include "common/GeoJson.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include <drogon/drogon.h>
#include <set>
namespace visione::services
{
namespace
{
Json::Value failure(const std::string &code)
{
Json::Value body;
body["ok"] = false;
body["error"] = code;
return body;
}
Json::Value success()
{
Json::Value body;
body["ok"] = true;
return body;
}
bool isAllowedStatus(const std::string &status)
{
static const std::set<std::string> statuses = {
"OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS",
"RESOLVED", "REJECTED", "CLOSED"};
return statuses.contains(status);
}
std::optional<long long> findArea(double latitude, double longitude)
{
const auto result = drogon::app().getDbClient()->execSqlSync(
"SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
"FROM areas WHERE active = TRUE");
Json::CharReaderBuilder builder;
for (const auto &row : result)
{
Json::Value geojson;
std::string errors;
const auto raw = row["geojson"].as<std::string>();
std::istringstream stream(raw);
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
geojson::pointInPolygon(latitude, longitude, geojson))
{
return row["id"].as<long long>();
}
}
return std::nullopt;
}
bool canAccessReport(const auth::UserContext &user, long long reportId)
{
if (auth::canManageReports(user))
{
return true;
}
const auto result = drogon::app().getDbClient()->execSqlSync(
"SELECT id FROM reports WHERE id = ? AND user_id = ?",
reportId,
user.id);
return !result.empty();
}
} // namespace
Json::Value ReportService::createReport(const auth::UserContext &user,
const Json::Value &body)
{
const auto missing =
validation::missingStrings(body, {"address", "description"});
if (!missing.empty() || !validation::hasNumber(body, "category_id") ||
!validation::hasNumber(body, "latitude") ||
!validation::hasNumber(body, "longitude"))
{
return failure("invalid_report_payload");
}
const auto latitude = body["latitude"].asDouble();
const auto longitude = body["longitude"].asDouble();
const auto areaId = findArea(latitude, longitude);
auto db = drogon::app().getDbClient();
if (areaId)
{
db->execSqlSync(
"INSERT INTO reports "
"(user_id, category_id, area_id, latitude, longitude, address, "
"description) VALUES (?, ?, ?, ?, ?, ?, ?)",
user.id,
body["category_id"].asInt64(),
*areaId,
latitude,
longitude,
body["address"].asString(),
body["description"].asString());
}
else
{
db->execSqlSync(
"INSERT INTO reports "
"(user_id, category_id, latitude, longitude, address, description) "
"VALUES (?, ?, ?, ?, ?, ?)",
user.id,
body["category_id"].asInt64(),
latitude,
longitude,
body["address"].asString(),
body["description"].asString());
}
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
const auto reportId = inserted[0]["id"].as<long long>();
db->execSqlSync(
"INSERT INTO report_status_history "
"(report_id, old_status, new_status, changed_by_user_id, note) "
"VALUES (?, NULL, 'OPEN', ?, 'Report created')",
reportId,
user.id);
if (body["photos"].isArray())
{
for (const auto &photo : body["photos"])
{
if (photo.isString() && !photo.asString().empty())
{
db->execSqlSync(
"INSERT INTO report_photos (report_id, url) VALUES (?, ?)",
reportId,
photo.asString());
}
}
}
auto response = success();
response["report_id"] = Json::Int64(reportId);
if (areaId)
{
response["area_id"] = Json::Int64(*areaId);
}
return response;
}
Json::Value ReportService::listReports(const auth::UserContext &user,
const std::optional<std::string> &status,
const std::optional<std::string> &categoryId,
const std::optional<std::string> &areaId)
{
auto db = drogon::app().getDbClient();
drogon::orm::Result result(nullptr);
if (status)
{
result = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.status = ? "
"ORDER BY r.created_at DESC LIMIT 100",
*status);
}
else if (categoryId)
{
result = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.category_id = ? "
"ORDER BY r.created_at DESC LIMIT 100",
*categoryId);
}
else if (areaId)
{
result = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.area_id = ? "
"ORDER BY r.created_at DESC LIMIT 100",
*areaId);
}
else if (auth::canManageReports(user))
{
result = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id "
"ORDER BY r.created_at DESC LIMIT 100");
}
else
{
result = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id "
"WHERE r.user_id = ? ORDER BY r.created_at DESC LIMIT 100",
user.id);
}
auto response = success();
response["reports"] = repositories::resultToJson(result);
return response;
}
Json::Value ReportService::reportDetail(const auth::UserContext &user,
long long reportId)
{
if (!canAccessReport(user, reportId))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto report = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.id = ?",
reportId);
if (report.empty())
{
return failure("report_not_found");
}
auto response = success();
response["report"] = repositories::rowToJson(report[0]);
response["history"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
reportId));
response["photos"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId));
return response;
}
Json::Value ReportService::updateStatus(const auth::UserContext &user,
long long reportId,
const Json::Value &body)
{
if (!auth::canManageReports(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "status") ||
!isAllowedStatus(body["status"].asString()))
{
return failure("invalid_status");
}
auto db = drogon::app().getDbClient();
const auto current = db->execSqlSync("SELECT status FROM reports WHERE id = ?",
reportId);
if (current.empty())
{
return failure("report_not_found");
}
db->execSqlSync("UPDATE reports SET status = ? WHERE id = ?",
body["status"].asString(),
reportId);
db->execSqlSync(
"INSERT INTO report_status_history "
"(report_id, old_status, new_status, changed_by_user_id, note) "
"VALUES (?, ?, ?, ?, ?)",
reportId,
current[0]["status"].as<std::string>(),
body["status"].asString(),
user.id,
body.get("note", "").asString());
return success();
}
Json::Value ReportService::addMessage(const auth::UserContext &user,
long long reportId,
const Json::Value &body)
{
if (!canAccessReport(user, reportId) || !validation::hasString(body, "body"))
{
return failure("invalid_message");
}
drogon::app().getDbClient()->execSqlSync(
"INSERT INTO messages (report_id, user_id, body) VALUES (?, ?, ?)",
reportId,
user.id,
body["body"].asString());
return success();
}
Json::Value ReportService::listMessages(const auth::UserContext &user,
long long reportId)
{
if (!canAccessReport(user, reportId))
{
return failure("forbidden");
}
auto response = success();
response["messages"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT m.*, u.first_name, u.last_name FROM messages m "
"JOIN users u ON u.id = m.user_id WHERE m.report_id = ? "
"ORDER BY m.created_at",
reportId));
return response;
}
Json::Value ReportService::assignTeam(const auth::UserContext &user,
long long reportId,
const Json::Value &body)
{
if (!auth::canManageReports(user) || !validation::hasNumber(body, "team_id"))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
db->execSqlSync(
"INSERT INTO assignments (report_id, team_id, assigned_by_user_id) "
"VALUES (?, ?, ?)",
reportId,
body["team_id"].asInt64(),
user.id);
db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?",
reportId);
db->execSqlSync(
"INSERT INTO report_status_history "
"(report_id, old_status, new_status, changed_by_user_id, note) "
"VALUES (?, NULL, 'ASSIGNED', ?, 'Team assigned')",
reportId,
user.id);
return success();
}
Json::Value ReportService::addFeedback(const auth::UserContext &user,
long long reportId,
const Json::Value &body)
{
if (!validation::hasNumber(body, "rating"))
{
return failure("missing_rating");
}
const auto rating = body["rating"].asInt();
if (rating < 1 || rating > 5)
{
return failure("invalid_rating");
}
auto db = drogon::app().getDbClient();
const auto report = db->execSqlSync(
"SELECT user_id, status FROM reports WHERE id = ?",
reportId);
if (report.empty() || report[0]["user_id"].as<long long>() != user.id)
{
return failure("forbidden");
}
if (report[0]["status"].as<std::string>() != "RESOLVED")
{
return failure("report_not_resolved");
}
db->execSqlSync(
"INSERT INTO feedback (report_id, user_id, rating, comment) "
"VALUES (?, ?, ?, ?)",
reportId,
user.id,
rating,
body.get("comment", "").asString());
return success();
}
} // namespace visione::services