Complete backend MVP
This commit is contained in:
@@ -39,7 +39,9 @@ set(APP_SOURCES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/ApiController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/AdminController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/AuthController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/PublicController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/ReportController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/controllers/UserController.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/common/Auth.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/common/Cors.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/common/Crypto.cc
|
||||
@@ -53,6 +55,7 @@ set(APP_SOURCES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/services/AdminService.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/services/AuthService.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/services/ReportService.cc
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/services/UserService.cc
|
||||
)
|
||||
|
||||
add_executable(${PROJECT_NAME}
|
||||
|
||||
@@ -71,6 +71,7 @@ discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
|
||||
- `docs/MILESTONE_01.md`: what this milestone implemented and what comes next.
|
||||
- `docs/MILESTONE_02.md`: workflow and authorization milestone.
|
||||
- `docs/MILESTONE_03.md`: media upload, OpenAPI generator and API lab milestone.
|
||||
- `docs/MILESTONE_04.md`: backend completion milestone.
|
||||
- `docs/API_SPEC.md`: API surface.
|
||||
- `docs/DATABASE_SCHEMA.md`: schema overview.
|
||||
- `docs/ARCHITECTURE.md`: backend structure.
|
||||
|
||||
@@ -4,10 +4,23 @@
|
||||
#include "common/Http.h"
|
||||
#include "services/AdminService.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
using drogon::HttpResponsePtr;
|
||||
|
||||
namespace
|
||||
{
|
||||
std::optional<std::string> queryValue(const drogon::HttpRequestPtr &req,
|
||||
const std::string &key)
|
||||
{
|
||||
const auto value = req->getParameter(key);
|
||||
if (value.empty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<visione::auth::UserContext> requireUser(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &callback)
|
||||
@@ -128,6 +141,38 @@ void AdminController::overviewStats(
|
||||
sendAdmin(service.overviewStats(*user), std::move(callback));
|
||||
}
|
||||
|
||||
void AdminController::listModeration(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::AdminService service;
|
||||
sendAdmin(service.listModeration(*user,
|
||||
queryValue(req, "status"),
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page")),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void AdminController::updateModeration(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long flagId)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::AdminService service;
|
||||
sendAdmin(service.updateModeration(*user, flagId, visione::http::requireJson(req)),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void AdminController::listUsers(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
@@ -170,4 +215,3 @@ void AdminController::updateUserBan(
|
||||
sendAdmin(service.updateUserBan(*user, userId, visione::http::requireJson(req)),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ class AdminController : public drogon::HttpController<AdminController>
|
||||
ADD_METHOD_TO(AdminController::overviewStats,
|
||||
"/api/v1/admin/stats/overview",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(AdminController::listModeration,
|
||||
"/api/v1/admin/moderation",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(AdminController::updateModeration,
|
||||
"/api/v1/admin/moderation/{1}",
|
||||
drogon::Patch);
|
||||
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
|
||||
ADD_METHOD_TO(AdminController::updateUserRoles,
|
||||
"/api/v1/admin/users/{1}/roles",
|
||||
@@ -49,6 +55,13 @@ class AdminController : public drogon::HttpController<AdminController>
|
||||
void overviewStats(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void listModeration(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void updateModeration(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long flagId);
|
||||
void listUsers(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "controllers/PublicController.h"
|
||||
|
||||
#include "common/Http.h"
|
||||
#include "services/ReportService.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
using drogon::HttpResponsePtr;
|
||||
|
||||
namespace
|
||||
{
|
||||
std::optional<std::string> queryValue(const drogon::HttpRequestPtr &req,
|
||||
const std::string &key)
|
||||
{
|
||||
const auto value = req->getParameter(key);
|
||||
if (value.empty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void PublicController::openDataReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
visione::services::ReportService service;
|
||||
callback(visione::http::json(service.openDataReports(queryValue(req, "page"),
|
||||
queryValue(req, "per_page"))));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <drogon/HttpController.h>
|
||||
|
||||
class PublicController : public drogon::HttpController<PublicController>
|
||||
{
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(PublicController::openDataReports,
|
||||
"/api/v1/open-data/reports",
|
||||
drogon::Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void openDataReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
};
|
||||
@@ -129,6 +129,54 @@ void ReportController::list(
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::assignedReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::ReportService service;
|
||||
sendService(service.assignedReports(*user,
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page")),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::openReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::ReportService service;
|
||||
sendService(service.openReports(*user,
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page")),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::otherOperatorReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::ReportService service;
|
||||
sendService(service.otherOperatorReports(*user,
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page")),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::detail(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
@@ -143,6 +191,20 @@ void ReportController::detail(
|
||||
sendService(service.reportDetail(*user, reportId), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::interventionSummary(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long reportId)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::ReportService service;
|
||||
sendService(service.interventionSummary(*user, reportId), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::updateStatus(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
@@ -201,6 +263,23 @@ void ReportController::listMessages(
|
||||
sendService(service.listMessages(*user, reportId), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::flagMessage(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long reportId,
|
||||
long long messageId)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const auto body = visione::http::requireJson(req);
|
||||
visione::services::ReportService service;
|
||||
sendService(service.flagMessage(*user, reportId, messageId, body),
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::assignTeam(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
@@ -231,6 +310,21 @@ void ReportController::addFeedback(
|
||||
sendService(service.addFeedback(*user, reportId, body), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::flagReport(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long reportId)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const auto body = visione::http::requireJson(req);
|
||||
visione::services::ReportService service;
|
||||
sendService(service.flagReport(*user, reportId, body), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::uploadMedia(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
|
||||
@@ -8,7 +8,19 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
|
||||
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::assignedReports,
|
||||
"/api/v1/operator/reports/assigned",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::openReports,
|
||||
"/api/v1/operator/reports/open",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::otherOperatorReports,
|
||||
"/api/v1/operator/reports/others",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::interventionSummary,
|
||||
"/api/v1/reports/{1}/intervention-summary",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(ReportController::updateStatus,
|
||||
"/api/v1/reports/{1}/status",
|
||||
drogon::Patch);
|
||||
@@ -18,6 +30,9 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
ADD_METHOD_TO(ReportController::addMessage,
|
||||
"/api/v1/reports/{1}/messages",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(ReportController::flagMessage,
|
||||
"/api/v1/reports/{1}/messages/{2}/flags",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(ReportController::listMessages,
|
||||
"/api/v1/reports/{1}/messages",
|
||||
drogon::Get);
|
||||
@@ -27,6 +42,9 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
ADD_METHOD_TO(ReportController::addFeedback,
|
||||
"/api/v1/reports/{1}/feedback",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(ReportController::flagReport,
|
||||
"/api/v1/reports/{1}/flags",
|
||||
drogon::Post);
|
||||
ADD_METHOD_TO(ReportController::uploadMedia,
|
||||
"/api/v1/reports/{1}/media",
|
||||
drogon::Post);
|
||||
@@ -36,9 +54,22 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void list(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void assignedReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void openReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void otherOperatorReports(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void detail(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void interventionSummary(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void updateStatus(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
@@ -51,6 +82,11 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void flagMessage(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId,
|
||||
long long messageId);
|
||||
void listMessages(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
@@ -63,6 +99,10 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void flagReport(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void uploadMedia(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#include "controllers/UserController.h"
|
||||
|
||||
#include "common/Auth.h"
|
||||
#include "common/Http.h"
|
||||
#include "services/UserService.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
using drogon::HttpResponsePtr;
|
||||
|
||||
namespace
|
||||
{
|
||||
std::optional<std::string> queryValue(const drogon::HttpRequestPtr &req,
|
||||
const std::string &key)
|
||||
{
|
||||
const auto value = req->getParameter(key);
|
||||
if (value.empty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<visione::auth::UserContext> requireUser(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &callback)
|
||||
{
|
||||
const auto user = visione::auth::authenticate(req);
|
||||
if (!user)
|
||||
{
|
||||
callback(visione::http::error("unauthorized", drogon::k401Unauthorized));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
bool blockPasswordChangeRequired(
|
||||
const visione::auth::UserContext &user,
|
||||
std::function<void(const HttpResponsePtr &)> &callback)
|
||||
{
|
||||
if (!visione::auth::mustChangePassword(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
callback(visione::http::error("password_change_required",
|
||||
drogon::k403Forbidden));
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void UserController::listNotifications(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::UserService service;
|
||||
callback(visione::http::json(service.listNotifications(*user,
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page"))));
|
||||
}
|
||||
|
||||
void UserController::markNotificationRead(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long notificationId)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::UserService service;
|
||||
auto body = service.markNotificationRead(*user, notificationId);
|
||||
const auto status = body.get("ok", false).asBool() ? drogon::k200OK
|
||||
: drogon::k404NotFound;
|
||||
callback(visione::http::json(std::move(body), status));
|
||||
}
|
||||
|
||||
void UserController::rewards(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
auto user = requireUser(req, callback);
|
||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
||||
{
|
||||
return;
|
||||
}
|
||||
visione::services::UserService service;
|
||||
callback(visione::http::json(service.rewards(*user,
|
||||
queryValue(req, "page"),
|
||||
queryValue(req, "per_page"))));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <drogon/HttpController.h>
|
||||
|
||||
class UserController : public drogon::HttpController<UserController>
|
||||
{
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(UserController::listNotifications,
|
||||
"/api/v1/notifications",
|
||||
drogon::Get);
|
||||
ADD_METHOD_TO(UserController::markNotificationRead,
|
||||
"/api/v1/notifications/{1}/read",
|
||||
drogon::Patch);
|
||||
ADD_METHOD_TO(UserController::rewards, "/api/v1/me/rewards", drogon::Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void listNotifications(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void markNotificationRead(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long notificationId);
|
||||
void rewards(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
};
|
||||
@@ -25,21 +25,39 @@ Authorization: Bearer <token>
|
||||
## Reports
|
||||
- `POST /reports`: create a report.
|
||||
- `GET /reports`: list reports with optional `status`, `category_id`, `area_id`.
|
||||
- `GET /operator/reports/assigned`: list reports assigned to the current operator.
|
||||
- `GET /operator/reports/open`: list open unassigned reports.
|
||||
- `GET /operator/reports/others`: list reports assigned to other operators.
|
||||
- `GET /reports/{id}`: report detail.
|
||||
- `GET /reports/{id}/intervention-summary`: operator-facing report summary with
|
||||
citizen contact data, media and latest messages.
|
||||
- `PATCH /reports/{id}/status`: update status.
|
||||
- `POST /reports/{id}/take`: operator/admin takes an open report.
|
||||
- `POST /reports/{id}/messages`: write a report message.
|
||||
- `GET /reports/{id}/messages`: list report messages.
|
||||
- `POST /reports/{id}/messages/{message_id}/flags`: flag a message for moderation.
|
||||
- `POST /reports/{id}/assignments`: assign a team.
|
||||
- `POST /reports/{id}/feedback`: add citizen feedback.
|
||||
- `POST /reports/{id}/flags`: flag a report for moderation.
|
||||
- `POST /reports/{id}/media`: upload up to five multipart media files. Supported
|
||||
types are JPEG, PNG, WebP, MP4, WebM and QuickTime.
|
||||
|
||||
## User
|
||||
- `GET /notifications`: list authenticated user notifications.
|
||||
- `PATCH /notifications/{id}/read`: mark one notification as read.
|
||||
- `GET /me/rewards`: return current points and reward event history.
|
||||
|
||||
## Admin Reference Data
|
||||
- `GET|POST /admin/categories`
|
||||
- `GET|POST /admin/areas`
|
||||
- `GET|POST /admin/teams`
|
||||
- `GET /admin/stats/overview`
|
||||
- `GET /admin/moderation`: list moderation flags with optional `status`.
|
||||
- `PATCH /admin/moderation/{id}`: resolve or dismiss a moderation flag, optionally
|
||||
rejecting the related report.
|
||||
- `GET /admin/users`
|
||||
- `PATCH /admin/users/{id}/roles`
|
||||
- `PATCH /admin/users/{id}/ban`
|
||||
|
||||
## Public Data
|
||||
- `GET /open-data/reports`: anonymized resolved/closed report export.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Visione Database Schema
|
||||
|
||||
The schema is stored in `migrations/001_initial_schema.sql`.
|
||||
The schema is stored in ordered SQL migrations under `migrations/`.
|
||||
|
||||
Persistence currently uses `drogon::orm::DbClient` with raw parameterized SQL.
|
||||
The project does not use Drogon generated models or `Mapper<T>` while the schema
|
||||
@@ -23,6 +23,9 @@ Important tables:
|
||||
- `messages`: citizen/operator report conversation.
|
||||
- `feedback`: citizen rating and comment after resolution.
|
||||
- `reward_events`: indirect gamification points ledger.
|
||||
- `notifications`: local in-app notifications for status changes, messages and
|
||||
reward events.
|
||||
- `moderation_flags`: report/message review queue for abusive or invalid content.
|
||||
|
||||
GeoJSON is intentionally used instead of MySQL Spatial in milestone 1 to keep
|
||||
the local setup simple and portable.
|
||||
|
||||
+5
-4
@@ -3,8 +3,9 @@
|
||||
Visione is a C++20/Drogon backend for a municipal urban issue reporting system.
|
||||
Citizens create reports with category, position, address, description and media
|
||||
attachments.
|
||||
Operators manage reports, assign teams, update status and chat with citizens.
|
||||
Admins manage roles, users, areas, teams and reference data.
|
||||
Operators manage report queues, assign teams, update status, chat with citizens
|
||||
and use intervention summaries.
|
||||
Admins manage roles, users, areas, teams, reference data, moderation and stats.
|
||||
|
||||
The project runs locally on the host machine. Docker is used only for external
|
||||
services such as MySQL and MailHog. Real production deployment and Dockerizing
|
||||
@@ -17,5 +18,5 @@ Important conventions:
|
||||
- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon`
|
||||
and `https://drogonframework.github.io/drogon-docs/`.
|
||||
- `AGENTS.md` is the durable agent instruction entrypoint.
|
||||
- `docs/MILESTONE_01.md`, `docs/MILESTONE_02.md` and `docs/MILESTONE_03.md`
|
||||
describe what has been implemented and what comes next.
|
||||
- `docs/MILESTONE_01.md`, `docs/MILESTONE_02.md`, `docs/MILESTONE_03.md` and
|
||||
`docs/MILESTONE_04.md` describe what has been implemented and what comes next.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Milestone 04: Backend Completion
|
||||
|
||||
## Implemented in this milestone
|
||||
- Added operator queues for assigned reports, open unassigned reports and reports
|
||||
assigned to other operators.
|
||||
- Added an operator intervention summary with report data, citizen contact data,
|
||||
media and latest messages.
|
||||
- Added local in-app notifications for status changes, team assignment, report
|
||||
messages and reward events.
|
||||
- Added citizen reward ledger APIs and automatic points for resolved reports.
|
||||
- Added report/message moderation flags with admin/operator review and optional
|
||||
report rejection.
|
||||
- Expanded admin statistics with category, team, monthly and segmented
|
||||
resolution-time aggregates.
|
||||
- Added anonymized public open-data report export.
|
||||
- Updated the manual OpenAPI contract for the new backend routes.
|
||||
|
||||
## Intentionally not implemented
|
||||
- Real CIE/SPID integration.
|
||||
- Real SMS, Telegram, WhatsApp or production SMTP notification delivery.
|
||||
- Route optimization for intervention teams.
|
||||
- Frontend-specific pages outside the Streamlit API lab.
|
||||
@@ -36,13 +36,15 @@ Valid milestone 2 transitions:
|
||||
## MVP Scope
|
||||
- Local auth with email OTP delivered through MailHog.
|
||||
- MySQL persistence with migrations and seed data.
|
||||
- Report creation/list/detail/status/message/assignment/feedback flows.
|
||||
- Report creation/list/detail/status/message/assignment/feedback/moderation flows.
|
||||
- Area polygons stored as GeoJSON and matched by backend logic.
|
||||
- Basic stats for administration.
|
||||
- Operator queues for assigned, open and other-operator reports.
|
||||
- Local in-app notifications and reward points ledger.
|
||||
- Public anonymized open-data report export.
|
||||
- Basic and segmented stats for administration.
|
||||
|
||||
## Out of Scope for Milestone 1
|
||||
## Out of Scope for the Local Backend MVP
|
||||
- Real CIE integration.
|
||||
- Real SMTP provider, SMS, Telegram or WhatsApp notifications.
|
||||
- Route optimization.
|
||||
- Public open data export.
|
||||
- Full Docker packaging of the backend.
|
||||
|
||||
+2222
-1126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
type VARCHAR(80) NOT NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
body TEXT NULL,
|
||||
report_id BIGINT UNSIGNED NULL,
|
||||
read_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT notifications_user_fk FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
CONSTRAINT notifications_report_fk FOREIGN KEY (report_id) REFERENCES reports(id),
|
||||
KEY notifications_user_read_created_idx (user_id, read_at, created_at),
|
||||
KEY notifications_report_idx (report_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moderation_flags (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
content_type VARCHAR(30) NOT NULL,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
message_id BIGINT UNSIGNED NULL,
|
||||
reason VARCHAR(120) NOT NULL,
|
||||
details TEXT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'OPEN',
|
||||
created_by_user_id BIGINT UNSIGNED NOT NULL,
|
||||
resolved_by_user_id BIGINT UNSIGNED NULL,
|
||||
resolution_note TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME NULL,
|
||||
CONSTRAINT moderation_flags_report_fk FOREIGN KEY (report_id) REFERENCES reports(id),
|
||||
CONSTRAINT moderation_flags_message_fk FOREIGN KEY (message_id) REFERENCES messages(id),
|
||||
CONSTRAINT moderation_flags_created_by_fk FOREIGN KEY (created_by_user_id) REFERENCES users(id),
|
||||
CONSTRAINT moderation_flags_resolved_by_fk FOREIGN KEY (resolved_by_user_id) REFERENCES users(id),
|
||||
KEY moderation_flags_status_created_idx (status, created_at),
|
||||
KEY moderation_flags_report_idx (report_id),
|
||||
KEY moderation_flags_message_idx (message_id)
|
||||
);
|
||||
|
||||
ALTER TABLE reward_events
|
||||
ADD UNIQUE KEY reward_events_report_reason_unique (report_id, reason);
|
||||
@@ -30,10 +30,31 @@ def operation_id(controller: str, handler: str) -> str:
|
||||
|
||||
def openapi_path(path: str) -> tuple[str, list[dict[str, Any]]]:
|
||||
parameters: list[dict[str, Any]] = []
|
||||
segments = path.split("/")
|
||||
placeholder_names: dict[int, str] = {}
|
||||
|
||||
for position, segment in enumerate(segments):
|
||||
match = re.fullmatch(r"\{(\d+)\}", segment)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
index = int(match.group(1))
|
||||
if index == 1:
|
||||
placeholder_names[index] = "id"
|
||||
continue
|
||||
|
||||
previous = segments[position - 1] if position > 0 else ""
|
||||
if previous.endswith("ies"):
|
||||
stem = previous[:-3] + "y"
|
||||
elif previous.endswith("s") and len(previous) > 1:
|
||||
stem = previous[:-1]
|
||||
else:
|
||||
stem = previous or f"param{index}"
|
||||
placeholder_names[index] = f"{stem}_id"
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
index = int(match.group(1))
|
||||
name = "id" if index == 1 else f"param{index}"
|
||||
name = placeholder_names.get(index, f"param{index}")
|
||||
parameters.append(
|
||||
{
|
||||
"name": name,
|
||||
|
||||
@@ -34,6 +34,27 @@ class GenerateOpenApiTest(unittest.TestCase):
|
||||
self.assertEqual(operation["operationId"], "SampleController.detail")
|
||||
self.assertEqual(operation["parameters"][0]["name"], "id")
|
||||
|
||||
def test_names_secondary_path_parameters_from_previous_segment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
controllers = Path(tmp)
|
||||
(controllers / "SampleController.h").write_text(
|
||||
"""
|
||||
class SampleController {
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(SampleController::flagMessage,
|
||||
"/api/v1/reports/{1}/messages/{2}/flags",
|
||||
drogon::Post);
|
||||
METHOD_LIST_END
|
||||
};
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
routes = generate_openapi.parse_routes(controllers)
|
||||
operation = routes[
|
||||
("/api/v1/reports/{id}/messages/{message_id}/flags", "post")
|
||||
]
|
||||
self.assertEqual(operation["parameters"][1]["name"], "message_id")
|
||||
|
||||
def test_manual_operation_overrides_generated_defaults(self) -> None:
|
||||
generated = {
|
||||
("/api/v1/samples", "get"): {
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
#include "common/Validation.h"
|
||||
#include "repositories/Sql.h"
|
||||
#include "services/UserService.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
namespace
|
||||
@@ -23,6 +26,38 @@ Json::Value success()
|
||||
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;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value AdminService::listCategories()
|
||||
@@ -135,9 +170,135 @@ Json::Value AdminService::overviewStats(const auth::UserContext &user)
|
||||
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["reports_by_team"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT t.name AS team, COUNT(*) AS total "
|
||||
"FROM assignments ass JOIN teams t ON t.id = ass.team_id "
|
||||
"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::canManageReports(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));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AdminService::updateModeration(const auth::UserContext &user,
|
||||
long long flagId,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::canManageReports(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::listUsers(const auth::UserContext &user)
|
||||
{
|
||||
if (!auth::isAdmin(user))
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
class AdminService
|
||||
@@ -17,6 +20,13 @@ class AdminService
|
||||
Json::Value createTeam(const auth::UserContext &user, const Json::Value &body);
|
||||
Json::Value overviewStats(const auth::UserContext &user);
|
||||
Json::Value listUsers(const auth::UserContext &user);
|
||||
Json::Value listModeration(const auth::UserContext &user,
|
||||
const std::optional<std::string> &status,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value updateModeration(const auth::UserContext &user,
|
||||
long long flagId,
|
||||
const Json::Value &body);
|
||||
Json::Value updateUserRoles(const auth::UserContext &user,
|
||||
long long targetUserId,
|
||||
const Json::Value &body);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
#include "common/GeoJson.h"
|
||||
#include "common/Validation.h"
|
||||
#include "repositories/Sql.h"
|
||||
#include "services/UserService.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
@@ -117,6 +119,85 @@ bool canInteractWithReport(const auth::UserContext &user, long long reportId)
|
||||
{
|
||||
return ownsReport(user, reportId) || canModifyReport(user, reportId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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<long long> 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<long long>();
|
||||
}
|
||||
|
||||
std::optional<long long> 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<long long>();
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -258,6 +339,76 @@ Json::Value ReportService::listReports(const auth::UserContext &user,
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::assignedReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &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<std::string> &page,
|
||||
const std::optional<std::string> &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<std::string> &page,
|
||||
const std::optional<std::string> &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)
|
||||
{
|
||||
@@ -288,6 +439,43 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user,
|
||||
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)
|
||||
@@ -330,6 +518,24 @@ Json::Value ReportService::updateStatus(const auth::UserContext &user,
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -366,6 +572,10 @@ Json::Value ReportService::takeReport(const auth::UserContext &user,
|
||||
"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();
|
||||
}
|
||||
|
||||
@@ -384,6 +594,23 @@ Json::Value ReportService::addMessage(const auth::UserContext &user,
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -443,6 +670,19 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -483,6 +723,89 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
|
||||
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<long long>());
|
||||
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<long long>());
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::openDataReports(const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &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)
|
||||
{
|
||||
|
||||
@@ -16,7 +16,17 @@ class ReportService
|
||||
const std::optional<std::string> &status,
|
||||
const std::optional<std::string> &categoryId,
|
||||
const std::optional<std::string> &areaId);
|
||||
Json::Value assignedReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value openReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value otherOperatorReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value reportDetail(const auth::UserContext &user, long long reportId);
|
||||
Json::Value interventionSummary(const auth::UserContext &user, long long reportId);
|
||||
Json::Value updateStatus(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
@@ -31,6 +41,15 @@ class ReportService
|
||||
Json::Value addFeedback(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
Json::Value flagReport(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
Json::Value flagMessage(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
long long messageId,
|
||||
const Json::Value &body);
|
||||
Json::Value openDataReports(const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
|
||||
Json::Value addMedia(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "services/UserService.h"
|
||||
|
||||
#include "repositories/Sql.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value UserService::listNotifications(
|
||||
const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &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["notifications"] = repositories::resultToJson(
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT id, type, title, body, report_id, read_at, created_at "
|
||||
"FROM notifications WHERE user_id = ? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
user.id,
|
||||
perPageValue,
|
||||
offset));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value UserService::markNotificationRead(const auth::UserContext &user,
|
||||
long long notificationId)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto rows = db->execSqlSync(
|
||||
"SELECT id FROM notifications WHERE id = ? AND user_id = ?",
|
||||
notificationId,
|
||||
user.id);
|
||||
if (rows.empty())
|
||||
{
|
||||
return failure("notification_not_found");
|
||||
}
|
||||
db->execSqlSync(
|
||||
"UPDATE notifications SET read_at = COALESCE(read_at, NOW()) WHERE id = ?",
|
||||
notificationId);
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value UserService::rewards(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage)
|
||||
{
|
||||
const auto pageValue = boundedPage(page);
|
||||
const auto perPageValue = boundedPerPage(perPage);
|
||||
const auto offset = (pageValue - 1) * perPageValue;
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto userRows = db->execSqlSync(
|
||||
"SELECT points FROM users WHERE id = ?",
|
||||
user.id);
|
||||
|
||||
auto response = success();
|
||||
response["points"] = userRows.empty() ? 0 : userRows[0]["points"].as<int>();
|
||||
response["page"] = pageValue;
|
||||
response["per_page"] = perPageValue;
|
||||
response["reward_events"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT id, report_id, points, reason, created_at "
|
||||
"FROM reward_events WHERE user_id = ? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
user.id,
|
||||
perPageValue,
|
||||
offset));
|
||||
return response;
|
||||
}
|
||||
|
||||
void UserService::createNotification(long long userId,
|
||||
const std::string &type,
|
||||
const std::string &title,
|
||||
const std::string &body,
|
||||
std::optional<long long> reportId)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
if (reportId)
|
||||
{
|
||||
db->execSqlSync(
|
||||
"INSERT INTO notifications (user_id, type, title, body, report_id) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
body,
|
||||
*reportId);
|
||||
}
|
||||
else
|
||||
{
|
||||
db->execSqlSync(
|
||||
"INSERT INTO notifications (user_id, type, title, body) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
body);
|
||||
}
|
||||
}
|
||||
|
||||
void UserService::awardReportOwner(long long reportId,
|
||||
const std::string &reason,
|
||||
int points)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto reportRows = db->execSqlSync(
|
||||
"SELECT user_id FROM reports WHERE id = ?",
|
||||
reportId);
|
||||
if (reportRows.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const auto userId = reportRows[0]["user_id"].as<long long>();
|
||||
const auto existing = db->execSqlSync(
|
||||
"SELECT id FROM reward_events WHERE report_id = ? AND reason = ?",
|
||||
reportId,
|
||||
reason);
|
||||
if (!existing.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
db->execSqlSync(
|
||||
"INSERT INTO reward_events (user_id, report_id, points, reason) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
userId,
|
||||
reportId,
|
||||
points,
|
||||
reason);
|
||||
db->execSqlSync(
|
||||
"UPDATE users SET points = points + ? WHERE id = ?",
|
||||
points,
|
||||
userId);
|
||||
createNotification(userId,
|
||||
"reward_awarded",
|
||||
"Points awarded",
|
||||
"Your resolved report earned points.",
|
||||
reportId);
|
||||
}
|
||||
} // namespace visione::services
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Auth.h"
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
class UserService
|
||||
{
|
||||
public:
|
||||
Json::Value listNotifications(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
Json::Value markNotificationRead(const auth::UserContext &user,
|
||||
long long notificationId);
|
||||
Json::Value rewards(const auth::UserContext &user,
|
||||
const std::optional<std::string> &page,
|
||||
const std::optional<std::string> &perPage);
|
||||
|
||||
static void createNotification(long long userId,
|
||||
const std::string &type,
|
||||
const std::string &title,
|
||||
const std::string &body,
|
||||
std::optional<long long> reportId);
|
||||
static void awardReportOwner(long long reportId,
|
||||
const std::string &reason,
|
||||
int points);
|
||||
};
|
||||
} // namespace visione::services
|
||||
Reference in New Issue
Block a user