Files
backend/services/AdminService.cc
T

685 lines
22 KiB
C++

#include "services/AdminService.h"
#include "common/GeoJson.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include "services/UserService.h"
#include <drogon/drogon.h>
#include <algorithm>
#include <sstream>
namespace visione::services
{
namespace
{
using repositories::JsonFieldType;
using repositories::JsonRowSchema;
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;
}
int boundedPage(const std::optional<std::string> &value)
{
if (!value)
{
return 1;
}
try
{
return std::max(1, std::stoi(*value));
}
catch (...)
{
return 1;
}
}
int boundedPerPage(const std::optional<std::string> &value)
{
if (!value)
{
return 50;
}
try
{
return std::clamp(std::stoi(*value), 1, 100);
}
catch (...)
{
return 50;
}
}
const JsonRowSchema &moderationFlagSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"message_id", JsonFieldType::UInt64},
{"created_by_user_id", JsonFieldType::UInt64},
{"resolved_by_user_id", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &adminUserSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"points", JsonFieldType::Int64},
{"banned", JsonFieldType::Bool},
{"must_change_password", JsonFieldType::Bool}};
return schema;
}
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,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name"))
{
return failure("invalid_category");
}
drogon::app().getDbClient()->execSqlSync(
"INSERT INTO report_categories (name, description) VALUES (?, ?)",
body["name"].asString(),
body.get("description", "").asString());
return success();
}
Json::Value AdminService::createArea(const auth::UserContext &user,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name") || !body["boundary_geojson"].isObject())
{
return failure("invalid_area");
}
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
{
return failure("area_outside_allowed_boundary");
}
Json::StreamWriterBuilder writer;
drogon::app().getDbClient()->execSqlSync(
"INSERT INTO areas (name, description, boundary_geojson) "
"VALUES (?, ?, CAST(? AS JSON))",
body["name"].asString(),
body.get("description", "").asString(),
Json::writeString(writer, body["boundary_geojson"]));
return success();
}
Json::Value AdminService::createTeam(const auth::UserContext &user,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name") ||
!validation::hasNumber(body, "team_category_id"))
{
return failure("invalid_team");
}
auto db = drogon::app().getDbClient();
const auto teamCategoryRows = db->execSqlSync(
"SELECT id FROM team_categories WHERE id = ?",
body["team_category_id"].asInt64());
if (teamCategoryRows.empty())
{
return failure("invalid_reference");
}
if (body["area_id"].isNumeric())
{
const auto areaRows = db->execSqlSync(
"SELECT id FROM areas WHERE id = ?",
body["area_id"].asInt64());
if (areaRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync(
"INSERT INTO teams (team_category_id, area_id, name, description) "
"VALUES (?, ?, ?, ?)",
body["team_category_id"].asInt64(),
body["area_id"].asInt64(),
body["name"].asString(),
body.get("description", "").asString());
}
else
{
db->execSqlSync(
"INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)",
body["team_category_id"].asInt64(),
body["name"].asString(),
body.get("description", "").asString());
}
return success();
}
Json::Value AdminService::overviewStats(const auth::UserContext &user)
{
if (!auth::canManageReports(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
auto response = success();
response["reports_by_status"] = repositories::resultToJson(db->execSqlSync(
"SELECT status, COUNT(*) AS total FROM reports GROUP BY status"));
response["reports_by_area"] = repositories::resultToJson(db->execSqlSync(
"SELECT COALESCE(a.name, 'Unassigned') AS area, COUNT(*) AS total "
"FROM reports r LEFT JOIN areas a ON a.id = r.area_id "
"GROUP BY COALESCE(a.name, 'Unassigned')"));
response["avg_resolution_hours"] = repositories::resultToJson(db->execSqlSync(
"SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) AS value "
"FROM reports WHERE status IN ('RESOLVED', 'CLOSED')"));
response["reports_by_category"] = repositories::resultToJson(db->execSqlSync(
"SELECT c.name AS category, COUNT(*) AS total "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"GROUP BY c.name ORDER BY total DESC"));
response["assignments_by_team"] = repositories::resultToJson(db->execSqlSync(
"SELECT t.name AS team, COUNT(DISTINCT ass.id) AS total "
"FROM assignments ass JOIN teams t ON t.id = ass.team_id "
"GROUP BY t.name ORDER BY total DESC"));
response["resolved_reports_by_team"] = repositories::resultToJson(db->execSqlSync(
"SELECT t.name AS team, COUNT(DISTINCT ass.report_id) AS total "
"FROM assignments ass "
"JOIN teams t ON t.id = ass.team_id "
"JOIN reports r ON r.id = ass.report_id "
"WHERE r.status IN ('RESOLVED', 'CLOSED') "
"AND NOT EXISTS ("
" SELECT 1 FROM assignments ass2 "
" WHERE ass2.report_id = ass.report_id AND ass2.assigned_at > ass.assigned_at"
") "
"GROUP BY t.name ORDER BY total DESC"));
response["monthly_reports"] = repositories::resultToJson(db->execSqlSync(
"SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS total "
"FROM reports GROUP BY DATE_FORMAT(created_at, '%Y-%m') "
"ORDER BY month DESC LIMIT 12"));
response["avg_resolution_hours_by_category"] =
repositories::resultToJson(db->execSqlSync(
"SELECT c.name AS category, "
"AVG(TIMESTAMPDIFF(HOUR, r.created_at, r.updated_at)) AS value "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"WHERE r.status IN ('RESOLVED', 'CLOSED') "
"GROUP BY c.name ORDER BY c.name"));
response["avg_resolution_hours_by_area"] =
repositories::resultToJson(db->execSqlSync(
"SELECT COALESCE(a.name, 'Unassigned') AS area, "
"AVG(TIMESTAMPDIFF(HOUR, r.created_at, r.updated_at)) AS value "
"FROM reports r LEFT JOIN areas a ON a.id = r.area_id "
"WHERE r.status IN ('RESOLVED', 'CLOSED') "
"GROUP BY COALESCE(a.name, 'Unassigned') ORDER BY area"));
return response;
}
Json::Value AdminService::listModeration(
const auth::UserContext &user,
const std::optional<std::string> &status,
const std::optional<std::string> &page,
const std::optional<std::string> &perPage)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
const auto pageValue = boundedPage(page);
const auto perPageValue = boundedPerPage(perPage);
const auto offset = (pageValue - 1) * perPageValue;
const auto statusValue = status.value_or("");
auto response = success();
response["page"] = pageValue;
response["per_page"] = perPageValue;
response["flags"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT f.*, creator.email AS created_by_email, "
"resolver.email AS resolved_by_email "
"FROM moderation_flags f "
"JOIN users creator ON creator.id = f.created_by_user_id "
"LEFT JOIN users resolver ON resolver.id = f.resolved_by_user_id "
"WHERE (? = '' OR f.status = ?) "
"ORDER BY f.created_at DESC LIMIT ? OFFSET ?",
statusValue,
statusValue,
perPageValue,
offset),
moderationFlagSchema());
return response;
}
Json::Value AdminService::updateModeration(const auth::UserContext &user,
long long flagId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "status"))
{
return failure("invalid_moderation_payload");
}
const auto status = body["status"].asString();
if (status != "RESOLVED" && status != "DISMISSED")
{
return failure("invalid_moderation_status");
}
auto db = drogon::app().getDbClient();
const auto flag = db->execSqlSync(
"SELECT id, report_id FROM moderation_flags WHERE id = ?",
flagId);
if (flag.empty())
{
return failure("moderation_flag_not_found");
}
db->execSqlSync(
"UPDATE moderation_flags "
"SET status = ?, resolved_by_user_id = ?, resolution_note = ?, "
"resolved_at = NOW() WHERE id = ?",
status,
user.id,
body.get("resolution_note", "").asString(),
flagId);
if (body.get("reject_report", false).asBool())
{
const auto reportId = flag[0]["report_id"].as<long long>();
const auto current = db->execSqlSync(
"SELECT status, user_id FROM reports WHERE id = ?",
reportId);
if (!current.empty() && current[0]["status"].as<std::string>() != "REJECTED")
{
const auto oldStatus = current[0]["status"].as<std::string>();
db->execSqlSync("UPDATE reports SET status = 'REJECTED' WHERE id = ?",
reportId);
db->execSqlSync(
"INSERT INTO report_status_history "
"(report_id, old_status, new_status, changed_by_user_id, note) "
"VALUES (?, ?, 'REJECTED', ?, ?)",
reportId,
oldStatus,
user.id,
body.get("resolution_note", "Rejected after moderation").asString());
UserService::createNotification(
current[0]["user_id"].as<long long>(),
"report_rejected",
"Report rejected",
"Your report was rejected after moderation review.",
reportId);
}
}
return success();
}
Json::Value AdminService::deleteCategory(const auth::UserContext &user,
long long categoryId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync(
"SELECT id FROM report_categories WHERE id = ?",
categoryId);
if (rows.empty())
{
return failure("category_not_found");
}
db->execSqlSync("UPDATE report_categories SET active = FALSE WHERE id = ?",
categoryId);
return success();
}
Json::Value AdminService::updateCategory(const auth::UserContext &user,
long long categoryId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync(
"SELECT id FROM report_categories WHERE id = ?",
categoryId);
if (rows.empty())
{
return failure("category_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE report_categories SET name = ? WHERE id = ?",
body["name"].asString(),
categoryId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE report_categories SET description = ? WHERE id = ?",
body["description"].asString(),
categoryId);
}
if (isActiveField(body))
{
db->execSqlSync("UPDATE report_categories SET active = ? WHERE id = ?",
body["active"].asBool(),
categoryId);
}
return success();
}
Json::Value AdminService::deleteArea(const auth::UserContext &user,
long long areaId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM areas WHERE id = ?", areaId);
if (rows.empty())
{
return failure("area_not_found");
}
db->execSqlSync("UPDATE teams SET area_id = NULL WHERE area_id = ?", areaId);
db->execSqlSync("UPDATE reports SET area_id = NULL WHERE area_id = ?", areaId);
db->execSqlSync("DELETE FROM areas WHERE id = ?", areaId);
return success();
}
Json::Value AdminService::updateArea(const auth::UserContext &user,
long long areaId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM areas WHERE id = ?", areaId);
if (rows.empty())
{
return failure("area_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE areas SET name = ? WHERE id = ?",
body["name"].asString(),
areaId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE areas SET description = ? WHERE id = ?",
body["description"].asString(),
areaId);
}
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 = ?",
Json::writeString(writer, body["boundary_geojson"]),
areaId);
}
return success();
}
Json::Value AdminService::deleteTeam(const auth::UserContext &user,
long long teamId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM teams WHERE id = ?", teamId);
if (rows.empty())
{
return failure("team_not_found");
}
db->execSqlSync("UPDATE teams SET active = FALSE WHERE id = ?", teamId);
return success();
}
Json::Value AdminService::updateTeam(const auth::UserContext &user,
long long teamId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM teams WHERE id = ?", teamId);
if (rows.empty())
{
return failure("team_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE teams SET name = ? WHERE id = ?",
body["name"].asString(),
teamId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE teams SET description = ? WHERE id = ?",
body["description"].asString(),
teamId);
}
if (validation::hasNumber(body, "team_category_id"))
{
const auto categoryRows = db->execSqlSync(
"SELECT id FROM team_categories WHERE id = ?",
body["team_category_id"].asInt64());
if (categoryRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync("UPDATE teams SET team_category_id = ? WHERE id = ?",
body["team_category_id"].asInt64(),
teamId);
}
if (body.isMember("area_id"))
{
if (body["area_id"].isNull())
{
db->execSqlSync("UPDATE teams SET area_id = NULL WHERE id = ?", teamId);
}
else if (body["area_id"].isNumeric())
{
const auto areaRows =
db->execSqlSync("SELECT id FROM areas WHERE id = ?",
body["area_id"].asInt64());
if (areaRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync("UPDATE teams SET area_id = ? WHERE id = ?",
body["area_id"].asInt64(),
teamId);
}
else
{
return failure("invalid_reference");
}
}
if (isActiveField(body))
{
db->execSqlSync("UPDATE teams SET active = ? WHERE id = ?",
body["active"].asBool(),
teamId);
}
return success();
}
Json::Value AdminService::listUsers(const auth::UserContext &user)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto response = success();
response["users"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
"u.phone, u.points, u.banned, u.must_change_password, "
"GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
"u.phone, u.points, u.banned, u.must_change_password "
"ORDER BY u.created_at DESC LIMIT 200"),
adminUserSchema());
return response;
}
Json::Value AdminService::updateUserRoles(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !body["roles"].isArray())
{
return failure("invalid_roles");
}
auto db = drogon::app().getDbClient();
const auto target =
db->execSqlSync("SELECT id FROM users WHERE id = ?", targetUserId);
if (target.empty())
{
return failure("user_not_found");
}
std::vector<long long> roleIds;
roleIds.reserve(body["roles"].size());
for (const auto &role : body["roles"])
{
if (!role.isString())
{
return failure("invalid_roles");
}
const auto roleRows =
db->execSqlSync("SELECT id FROM roles WHERE name = ?", role.asString());
if (roleRows.empty())
{
return failure("role_not_found");
}
roleIds.push_back(roleRows[0]["id"].as<long long>());
}
db->execSqlSync("DELETE FROM user_roles WHERE user_id = ?", targetUserId);
for (const auto roleId : roleIds)
{
db->execSqlSync("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)",
targetUserId,
roleId);
}
return success();
}
Json::Value AdminService::updateUserBan(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !body["banned"].isBool())
{
return failure("invalid_ban_payload");
}
if (targetUserId == user.id)
{
return failure("cannot_ban_self");
}
auto db = drogon::app().getDbClient();
const auto target =
db->execSqlSync("SELECT id FROM users WHERE id = ?", targetUserId);
if (target.empty())
{
return failure("user_not_found");
}
db->execSqlSync("UPDATE users SET banned = ? WHERE id = ?",
body["banned"].asBool(),
targetUserId);
if (body["banned"].asBool())
{
db->execSqlSync(
"UPDATE api_tokens SET revoked_at = NOW() "
"WHERE user_id = ? AND revoked_at IS NULL",
targetUserId);
}
return success();
}
} // namespace visione::services