Complete backend MVP

This commit is contained in:
gabdevele
2026-06-05 11:56:28 +02:00 Unverified
parent 25c280f38b
commit 5974f01daa
25 changed files with 3468 additions and 1137 deletions
+3
View File
@@ -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}
+1
View File
@@ -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.
+45 -1
View File
@@ -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));
}
+13
View File
@@ -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);
+31
View File
@@ -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"))));
}
+17
View File
@@ -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);
};
+94
View File
@@ -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,
+40
View File
@@ -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,
+95
View File
@@ -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"))));
}
+27
View File
@@ -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);
};
+18
View File
@@ -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.
+4 -1
View File
@@ -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
View File
@@ -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.
+22
View File
@@ -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.
+6 -4
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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);
+22 -1
View File
@@ -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,
+21
View File
@@ -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"): {
+161
View File
@@ -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))
+10
View File
@@ -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);
+323
View File
@@ -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)
{
+19
View File
@@ -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,
+197
View File
@@ -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
+33
View File
@@ -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