#include "services/AdminService.h" #include "common/GeoJson.h" #include "common/Validation.h" #include "repositories/Sql.h" #include "services/UserService.h" #include #include #include 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 &value) { if (!value) { return 1; } try { return std::max(1, std::stoi(*value)); } catch (...) { return 1; } } int boundedPerPage(const std::optional &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 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()); 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 &status, const std::optional &page, const std::optional &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(); const auto current = db->execSqlSync( "SELECT status, user_id FROM reports WHERE id = ?", reportId); if (!current.empty() && current[0]["status"].as() != "REJECTED") { const auto oldStatus = current[0]["status"].as(); 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(), "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 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()); } 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