Complete backend MVP
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user