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
+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