#include "services/ReportService.h" #include "common/GeoJson.h" #include "common/Validation.h" #include "repositories/Sql.h" #include "services/UserService.h" #include #include #include #include #include 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 statuses = { "OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED", "REJECTED", "CLOSED"}; return statuses.contains(status); } bool canTransition(const std::string &from, const std::string &to) { static const std::map> transitions = { {"OPEN", {"UNDER_REVIEW", "REJECTED"}}, {"UNDER_REVIEW", {"ASSIGNED", "REJECTED"}}, {"ASSIGNED", {"IN_PROGRESS", "REJECTED"}}, {"IN_PROGRESS", {"RESOLVED", "REJECTED"}}, {"RESOLVED", {"CLOSED"}}, {"REJECTED", {"CLOSED"}}, {"CLOSED", {}}}; const auto it = transitions.find(from); return it != transitions.end() && it->second.contains(to); } std::optional 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::istringstream stream(raw); if (Json::parseFromStream(builder, stream, &geojson, &errors) && geojson::pointInPolygon(latitude, longitude, geojson)) { return row["id"].as(); } } 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(); } bool canModifyReport(const auth::UserContext &user, long long reportId) { if (auth::isAdmin(user)) { return true; } if (!auth::canManageReports(user)) { return false; } const auto result = drogon::app().getDbClient()->execSqlSync( "SELECT id FROM reports WHERE id = ? AND assigned_operator_id = ?", reportId, user.id); return !result.empty(); } bool ownsReport(const auth::UserContext &user, long long reportId) { const auto result = drogon::app().getDbClient()->execSqlSync( "SELECT id FROM reports WHERE id = ? AND user_id = ?", reportId, user.id); return !result.empty(); } bool canInteractWithReport(const auth::UserContext &user, long long reportId) { return ownsReport(user, reportId) || canModifyReport(user, reportId); } 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; } } Json::Value pagedReportResponse(const drogon::orm::Result &result, int page, int perPage) { auto response = success(); response["page"] = page; response["per_page"] = perPage; response["reports"] = repositories::resultToJson(result); return response; } std::optional reportOwner(long long reportId) { const auto rows = drogon::app().getDbClient()->execSqlSync( "SELECT user_id FROM reports WHERE id = ?", reportId); if (rows.empty()) { return std::nullopt; } return rows[0]["user_id"].as(); } std::optional assignedOperator(long long reportId) { const auto rows = drogon::app().getDbClient()->execSqlSync( "SELECT assigned_operator_id FROM reports WHERE id = ?", reportId); if (rows.empty() || rows[0]["assigned_operator_id"].isNull()) { return std::nullopt; } return rows[0]["assigned_operator_id"].as(); } void notifyReportOwner(long long reportId, const std::string &type, const std::string &title, const std::string &body) { const auto owner = reportOwner(reportId); if (owner) { UserService::createNotification(*owner, type, title, body, reportId); } } } // 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(); const auto category = db->execSqlSync( "SELECT id FROM report_categories WHERE id = ? AND active = TRUE", body["category_id"].asInt64()); if (category.empty()) { return failure("category_not_found"); } 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(); 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["media"].isArray()) { for (const auto &item : body["media"]) { if (item.isString() && !item.asString().empty()) { db->execSqlSync( "INSERT INTO report_photos (report_id, url, media_kind) " "VALUES (?, ?, 'IMAGE')", reportId, item.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 &status, const std::optional &categoryId, const std::optional &areaId) { auto db = drogon::app().getDbClient(); const auto statusParam = status.value_or(""); const auto categoryParam = categoryId.value_or(""); const auto areaParam = areaId.value_or(""); drogon::orm::Result result(nullptr); 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 " "WHERE (? = '' OR r.status = ?) " "AND (? = '' OR r.category_id = ?) " "AND (? = '' OR r.area_id = ?) " "ORDER BY r.created_at DESC LIMIT 100", statusParam, statusParam, categoryParam, categoryParam, areaParam, areaParam); } 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 = ? " "AND (? = '' OR r.status = ?) " "AND (? = '' OR r.category_id = ?) " "AND (? = '' OR r.area_id = ?) " "ORDER BY r.created_at DESC LIMIT 100", user.id, statusParam, statusParam, categoryParam, categoryParam, areaParam, areaParam); } auto response = success(); response["reports"] = repositories::resultToJson(result); return response; } Json::Value ReportService::assignedReports(const auth::UserContext &user, const std::optional &page, const std::optional &perPage) { if (!auth::canManageReports(user)) { return failure("forbidden"); } const auto pageValue = boundedPage(page); const auto perPageValue = boundedPerPage(perPage); const auto offset = (pageValue - 1) * perPageValue; const auto result = drogon::app().getDbClient()->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.assigned_operator_id = ? " "ORDER BY r.updated_at DESC LIMIT ? OFFSET ?", user.id, perPageValue, offset); return pagedReportResponse(result, pageValue, perPageValue); } Json::Value ReportService::openReports(const auth::UserContext &user, const std::optional &page, const std::optional &perPage) { if (!auth::canManageReports(user)) { return failure("forbidden"); } const auto pageValue = boundedPage(page); const auto perPageValue = boundedPerPage(perPage); const auto offset = (pageValue - 1) * perPageValue; const auto result = drogon::app().getDbClient()->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 = 'OPEN' AND r.assigned_operator_id IS NULL " "ORDER BY r.created_at ASC LIMIT ? OFFSET ?", perPageValue, offset); return pagedReportResponse(result, pageValue, perPageValue); } Json::Value ReportService::otherOperatorReports( const auth::UserContext &user, const std::optional &page, const std::optional &perPage) { if (!auth::canManageReports(user)) { return failure("forbidden"); } const auto pageValue = boundedPage(page); const auto perPageValue = boundedPerPage(perPage); const auto offset = (pageValue - 1) * perPageValue; const auto result = drogon::app().getDbClient()->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.assigned_operator_id IS NOT NULL " "AND r.assigned_operator_id <> ? " "ORDER BY r.updated_at DESC LIMIT ? OFFSET ?", user.id, perPageValue, offset); return pagedReportResponse(result, pageValue, perPageValue); } 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["media"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", reportId)); return response; } Json::Value ReportService::interventionSummary(const auth::UserContext &user, long long reportId) { if (!auth::canManageReports(user)) { 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, " "u.first_name, u.last_name, u.phone, u.email, " "t.name AS assigned_team_name " "FROM reports r " "JOIN users u ON u.id = r.user_id " "JOIN report_categories c ON c.id = r.category_id " "LEFT JOIN areas a ON a.id = r.area_id " "LEFT JOIN assignments ass ON ass.report_id = r.id AND ass.ended_at IS NULL " "LEFT JOIN teams t ON t.id = ass.team_id " "WHERE r.id = ?", reportId); if (report.empty()) { return failure("report_not_found"); } auto response = success(); response["summary"] = repositories::rowToJson(report[0]); response["media"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", reportId)); response["latest_messages"] = repositories::resultToJson(db->execSqlSync( "SELECT m.id, m.user_id, u.first_name, u.last_name, m.body, m.created_at " "FROM messages m JOIN users u ON u.id = m.user_id " "WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5", reportId)); return response; } Json::Value ReportService::updateStatus(const auth::UserContext &user, long long reportId, const Json::Value &body) { if (!canModifyReport(user, reportId)) { 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"); } const auto oldStatus = current[0]["status"].as(); const auto newStatus = body["status"].asString(); if (!canTransition(oldStatus, newStatus)) { return failure("invalid_status_transition"); } db->execSqlSync("UPDATE reports SET status = ? WHERE id = ?", newStatus, reportId); db->execSqlSync( "INSERT INTO report_status_history " "(report_id, old_status, new_status, changed_by_user_id, note) " "VALUES (?, ?, ?, ?, ?)", reportId, oldStatus, newStatus, user.id, body.get("note", "").asString()); notifyReportOwner(reportId, "report_status_changed", "Report status changed", "Your report status is now " + newStatus + "."); if (const auto assignee = assignedOperator(reportId); assignee && *assignee != user.id) { UserService::createNotification(*assignee, "report_status_changed", "Assigned report status changed", "A report assigned to you is now " + newStatus + ".", reportId); } if (newStatus == "RESOLVED" || newStatus == "CLOSED") { UserService::awardReportOwner(reportId, "report_resolved", 10); } return success(); } Json::Value ReportService::takeReport(const auth::UserContext &user, long long reportId) { if (!auth::canManageReports(user)) { return failure("forbidden"); } auto db = drogon::app().getDbClient(); const auto current = db->execSqlSync( "SELECT status, assigned_operator_id FROM reports WHERE id = ?", reportId); if (current.empty()) { return failure("report_not_found"); } if (current[0]["status"].as() != "OPEN" || !current[0]["assigned_operator_id"].isNull()) { return failure("report_not_open"); } db->execSqlSync( "UPDATE reports SET assigned_operator_id = ?, status = 'UNDER_REVIEW' " "WHERE id = ?", user.id, reportId); db->execSqlSync( "INSERT INTO report_status_history " "(report_id, old_status, new_status, changed_by_user_id, note) " "VALUES (?, 'OPEN', 'UNDER_REVIEW', ?, 'Report taken by operator')", reportId, user.id); notifyReportOwner(reportId, "report_taken", "Report taken in charge", "An operator is reviewing your report."); return success(); } Json::Value ReportService::addMessage(const auth::UserContext &user, long long reportId, const Json::Value &body) { if (!canInteractWithReport(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()); if (const auto owner = reportOwner(reportId); owner && *owner != user.id) { UserService::createNotification(*owner, "new_message", "New report message", "There is a new message on your report.", reportId); } if (const auto assignee = assignedOperator(reportId); assignee && *assignee != user.id) { UserService::createNotification(*assignee, "new_message", "New report message", "There is a new message on an assigned report.", reportId); } return success(); } Json::Value ReportService::listMessages(const auth::UserContext &user, long long reportId) { if (!canInteractWithReport(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 (!canModifyReport(user, reportId) || !validation::hasNumber(body, "team_id")) { return failure("forbidden"); } 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"); } const auto oldStatus = current[0]["status"].as(); if (!canTransition(oldStatus, "ASSIGNED")) { return failure("invalid_status_transition"); } 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 (?, ?, 'ASSIGNED', ?, 'Team assigned')", reportId, oldStatus, user.id); notifyReportOwner(reportId, "team_assigned", "Team assigned", "A municipal team has been assigned to your report."); if (const auto assignee = assignedOperator(reportId); assignee && *assignee != user.id) { UserService::createNotification(*assignee, "team_assigned", "Team assigned", "A team has been assigned to your report.", reportId); } 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() != user.id) { return failure("forbidden"); } if (report[0]["status"].as() != "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(); } Json::Value ReportService::flagReport(const auth::UserContext &user, long long reportId, const Json::Value &body) { if (!canAccessReport(user, reportId) || !validation::hasString(body, "reason")) { return failure("invalid_flag"); } auto db = drogon::app().getDbClient(); db->execSqlSync( "INSERT INTO moderation_flags " "(content_type, report_id, reason, details, created_by_user_id) " "VALUES ('REPORT', ?, ?, ?, ?)", reportId, body["reason"].asString(), body.get("details", "").asString(), user.id); auto response = success(); response["flag_id"] = Json::Int64(db->execSqlSync("SELECT LAST_INSERT_ID() AS id")[0]["id"] .as()); return response; } Json::Value ReportService::flagMessage(const auth::UserContext &user, long long reportId, long long messageId, const Json::Value &body) { if (!canInteractWithReport(user, reportId) || !validation::hasString(body, "reason")) { return failure("invalid_flag"); } auto db = drogon::app().getDbClient(); const auto message = db->execSqlSync( "SELECT id FROM messages WHERE id = ? AND report_id = ?", messageId, reportId); if (message.empty()) { return failure("message_not_found"); } db->execSqlSync( "INSERT INTO moderation_flags " "(content_type, report_id, message_id, reason, details, created_by_user_id) " "VALUES ('MESSAGE', ?, ?, ?, ?, ?)", reportId, messageId, body["reason"].asString(), body.get("details", "").asString(), user.id); auto response = success(); response["flag_id"] = Json::Int64(db->execSqlSync("SELECT LAST_INSERT_ID() AS id")[0]["id"] .as()); return response; } Json::Value ReportService::openDataReports(const std::optional &page, const std::optional &perPage) { const auto pageValue = boundedPage(page); const auto perPageValue = boundedPerPage(perPage); const auto offset = (pageValue - 1) * perPageValue; auto response = success(); response["page"] = pageValue; response["per_page"] = perPageValue; response["reports"] = repositories::resultToJson( drogon::app().getDbClient()->execSqlSync( "SELECT r.id, r.category_id, c.name AS category_name, r.area_id, " "a.name AS area_name, r.latitude, r.longitude, r.status, " "r.created_at, r.updated_at " "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 IN ('RESOLVED', 'CLOSED') " "ORDER BY r.updated_at DESC LIMIT ? OFFSET ?", perPageValue, offset)); return response; } Json::Value ReportService::canAddMedia(const auth::UserContext &user, long long reportId) { if (!canAccessReport(user, reportId)) { return failure("forbidden"); } const auto count = drogon::app().getDbClient()->execSqlSync( "SELECT COUNT(*) AS total FROM report_photos WHERE report_id = ?", reportId); if (!count.empty() && count[0]["total"].as() >= 5) { return failure("media_limit_reached"); } auto response = success(); response["remaining_media_slots"] = 5 - (count.empty() ? 0 : count[0]["total"].as()); return response; } Json::Value ReportService::addMedia(const auth::UserContext &user, long long reportId, const std::string &url, const std::string &originalFilename, const std::string &contentType, const std::string &mediaKind, size_t sizeBytes) { auto allowed = canAddMedia(user, reportId); if (!allowed.get("ok", false).asBool() || url.empty()) { return allowed; } drogon::app().getDbClient()->execSqlSync( "INSERT INTO report_photos " "(report_id, url, original_filename, content_type, media_kind, size_bytes) " "VALUES (?, ?, ?, ?, ?, ?)", reportId, url, originalFilename, contentType, mediaKind, static_cast(sizeBytes)); return success(); } } // namespace visione::services