Add Visione backend foundation
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
#include "services/AdminService.h"
|
||||
|
||||
#include "common/Validation.h"
|
||||
#include "repositories/Sql.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
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;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value AdminService::listCategories()
|
||||
{
|
||||
auto response = success();
|
||||
response["categories"] = repositories::resultToJson(
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT * FROM report_categories ORDER BY name"));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AdminService::createCategory(const auth::UserContext &user,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::isAdmin(user) || !validation::hasString(body, "name"))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO report_categories (name, description) VALUES (?, ?)",
|
||||
body["name"].asString(),
|
||||
body.get("description", "").asString());
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value AdminService::listAreas()
|
||||
{
|
||||
auto response = success();
|
||||
response["areas"] = repositories::resultToJson(
|
||||
drogon::app().getDbClient()->execSqlSync("SELECT * FROM areas ORDER BY name"));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AdminService::createArea(const auth::UserContext &user,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::isAdmin(user) || !validation::hasString(body, "name") ||
|
||||
!body["boundary_geojson"].isObject())
|
||||
{
|
||||
return failure("invalid_area");
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder writer;
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO areas (name, description, boundary_geojson) "
|
||||
"VALUES (?, ?, CAST(? AS JSON))",
|
||||
body["name"].asString(),
|
||||
body.get("description", "").asString(),
|
||||
Json::writeString(writer, body["boundary_geojson"]));
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value AdminService::listTeams()
|
||||
{
|
||||
auto response = success();
|
||||
response["teams"] = repositories::resultToJson(
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT t.*, tc.name AS category_name, a.name AS area_name "
|
||||
"FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id "
|
||||
"LEFT JOIN areas a ON a.id = t.area_id ORDER BY t.name"));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AdminService::createTeam(const auth::UserContext &user,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::isAdmin(user) || !validation::hasString(body, "name") ||
|
||||
!validation::hasNumber(body, "team_category_id"))
|
||||
{
|
||||
return failure("invalid_team");
|
||||
}
|
||||
|
||||
if (body["area_id"].isNumeric())
|
||||
{
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO teams (team_category_id, area_id, name, description) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
body["team_category_id"].asInt64(),
|
||||
body["area_id"].asInt64(),
|
||||
body["name"].asString(),
|
||||
body.get("description", "").asString());
|
||||
}
|
||||
else
|
||||
{
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)",
|
||||
body["team_category_id"].asInt64(),
|
||||
body["name"].asString(),
|
||||
body.get("description", "").asString());
|
||||
}
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value AdminService::overviewStats(const auth::UserContext &user)
|
||||
{
|
||||
if (!auth::canManageReports(user))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
auto response = success();
|
||||
response["reports_by_status"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT status, COUNT(*) AS total FROM reports GROUP BY status"));
|
||||
response["reports_by_area"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT COALESCE(a.name, 'Unassigned') AS area, COUNT(*) AS total "
|
||||
"FROM reports r LEFT JOIN areas a ON a.id = r.area_id "
|
||||
"GROUP BY COALESCE(a.name, 'Unassigned')"));
|
||||
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')"));
|
||||
return response;
|
||||
}
|
||||
} // namespace visione::services
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Auth.h"
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
class AdminService
|
||||
{
|
||||
public:
|
||||
Json::Value listCategories();
|
||||
Json::Value createCategory(const auth::UserContext &user, const Json::Value &body);
|
||||
Json::Value listAreas();
|
||||
Json::Value createArea(const auth::UserContext &user, const Json::Value &body);
|
||||
Json::Value listTeams();
|
||||
Json::Value createTeam(const auth::UserContext &user, const Json::Value &body);
|
||||
Json::Value overviewStats(const auth::UserContext &user);
|
||||
};
|
||||
} // namespace visione::services
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#include "services/AuthService.h"
|
||||
|
||||
#include "common/Crypto.h"
|
||||
#include "common/Email.h"
|
||||
#include "common/Validation.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
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 otpTtlMinutes()
|
||||
{
|
||||
return drogon::app()
|
||||
.getCustomConfig()["auth"]
|
||||
.get("otp_ttl_minutes", 10)
|
||||
.asInt();
|
||||
}
|
||||
|
||||
int tokenTtlHours()
|
||||
{
|
||||
return drogon::app()
|
||||
.getCustomConfig()["auth"]
|
||||
.get("token_ttl_hours", 24)
|
||||
.asInt();
|
||||
}
|
||||
|
||||
Json::Value issueToken(long long userId)
|
||||
{
|
||||
auto token = crypto::randomToken();
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO api_tokens (user_id, token_hash, expires_at) "
|
||||
"VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? HOUR))",
|
||||
userId,
|
||||
crypto::hashSecret(token),
|
||||
tokenTtlHours());
|
||||
|
||||
auto body = success();
|
||||
body["token"] = token;
|
||||
body["token_type"] = "Bearer";
|
||||
body["expires_in_hours"] = tokenTtlHours();
|
||||
return body;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value AuthService::registerCitizen(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(
|
||||
body, {"first_name", "last_name", "email", "password", "fiscal_code"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
auto response = failure("missing_fields");
|
||||
for (const auto &field : missing)
|
||||
{
|
||||
response["fields"].append(field);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const auto email = body["email"].asString();
|
||||
const auto fiscalCode = body["fiscal_code"].asString();
|
||||
if (!validation::isValidEmail(email))
|
||||
{
|
||||
return failure("invalid_email");
|
||||
}
|
||||
if (!validation::isValidFiscalCode(fiscalCode))
|
||||
{
|
||||
return failure("invalid_fiscal_code");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto duplicate = db->execSqlSync(
|
||||
"SELECT id FROM users WHERE email = ? OR fiscal_code = ? LIMIT 1",
|
||||
email,
|
||||
fiscalCode);
|
||||
if (!duplicate.empty())
|
||||
{
|
||||
return failure("user_already_exists");
|
||||
}
|
||||
|
||||
db->execSqlSync(
|
||||
"INSERT INTO users "
|
||||
"(first_name, last_name, email, password_hash, fiscal_code, birth_date, "
|
||||
"birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
|
||||
body["first_name"].asString(),
|
||||
body["last_name"].asString(),
|
||||
email,
|
||||
crypto::hashPassword(body["password"].asString()),
|
||||
fiscalCode,
|
||||
body.get("birth_date", "").asString(),
|
||||
body.get("birth_place", "").asString(),
|
||||
body.get("phone", "").asString());
|
||||
|
||||
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
|
||||
const auto userId = inserted[0]["id"].as<long long>();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO user_roles (user_id, role_id) "
|
||||
"SELECT ?, id FROM roles WHERE name = 'citizen'",
|
||||
userId);
|
||||
|
||||
auto response = success();
|
||||
response["user_id"] = Json::Int64(userId);
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AuthService::requestOtp(const Json::Value &body)
|
||||
{
|
||||
if (!validation::hasString(body, "email"))
|
||||
{
|
||||
return failure("missing_email");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto user = db->execSqlSync(
|
||||
"SELECT id, email FROM users WHERE email = ? AND banned = FALSE LIMIT 1",
|
||||
body["email"].asString());
|
||||
if (user.empty())
|
||||
{
|
||||
return failure("user_not_found");
|
||||
}
|
||||
|
||||
const auto code = crypto::randomNumericCode(6);
|
||||
const auto userId = user[0]["id"].as<long long>();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO otp_challenges (user_id, code_hash, expires_at) "
|
||||
"VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))",
|
||||
userId,
|
||||
crypto::hashSecret(code),
|
||||
otpTtlMinutes());
|
||||
|
||||
email::EmailService mail;
|
||||
if (!mail.sendOtp(user[0]["email"].as<std::string>(), code))
|
||||
{
|
||||
LOG_WARN << "Could not send OTP email through MailHog";
|
||||
return failure("otp_email_failed");
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["delivery"] = "mailhog";
|
||||
response["expires_in_minutes"] = otpTtlMinutes();
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AuthService::verifyOtp(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(body, {"email", "code"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
return failure("missing_fields");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto challenge = db->execSqlSync(
|
||||
"SELECT c.id, c.user_id FROM otp_challenges c "
|
||||
"JOIN users u ON u.id = c.user_id "
|
||||
"WHERE u.email = ? AND c.code_hash = ? AND c.consumed_at IS NULL "
|
||||
"AND c.expires_at > NOW() ORDER BY c.id DESC LIMIT 1",
|
||||
body["email"].asString(),
|
||||
crypto::hashSecret(body["code"].asString()));
|
||||
if (challenge.empty())
|
||||
{
|
||||
return failure("invalid_otp");
|
||||
}
|
||||
|
||||
db->execSqlSync("UPDATE otp_challenges SET consumed_at = NOW() WHERE id = ?",
|
||||
challenge[0]["id"].as<long long>());
|
||||
return issueToken(challenge[0]["user_id"].as<long long>());
|
||||
}
|
||||
|
||||
Json::Value AuthService::loginWithPassword(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(body, {"email", "password"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
return failure("missing_fields");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto user = db->execSqlSync(
|
||||
"SELECT id, password_hash FROM users WHERE email = ? AND banned = FALSE "
|
||||
"LIMIT 1",
|
||||
body["email"].asString());
|
||||
if (user.empty() ||
|
||||
!crypto::verifyPassword(body["password"].asString(),
|
||||
user[0]["password_hash"].as<std::string>()))
|
||||
{
|
||||
return failure("invalid_credentials");
|
||||
}
|
||||
return issueToken(user[0]["id"].as<long long>());
|
||||
}
|
||||
|
||||
Json::Value AuthService::currentUser(const auth::UserContext &user)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto result = db->execSqlSync(
|
||||
"SELECT id, first_name, last_name, email, fiscal_code, phone, points, "
|
||||
"must_change_password FROM users WHERE id = ?",
|
||||
user.id);
|
||||
|
||||
auto response = success();
|
||||
if (!result.empty())
|
||||
{
|
||||
response["user"]["id"] = Json::Int64(result[0]["id"].as<long long>());
|
||||
response["user"]["first_name"] = result[0]["first_name"].as<std::string>();
|
||||
response["user"]["last_name"] = result[0]["last_name"].as<std::string>();
|
||||
response["user"]["email"] = result[0]["email"].as<std::string>();
|
||||
response["user"]["fiscal_code"] = result[0]["fiscal_code"].as<std::string>();
|
||||
response["user"]["phone"] = result[0]["phone"].as<std::string>();
|
||||
response["user"]["points"] = result[0]["points"].as<int>();
|
||||
response["user"]["must_change_password"] =
|
||||
result[0]["must_change_password"].as<bool>();
|
||||
response["user"]["role"] = user.role;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
} // namespace visione::services
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Auth.h"
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
class AuthService
|
||||
{
|
||||
public:
|
||||
Json::Value registerCitizen(const Json::Value &body);
|
||||
Json::Value requestOtp(const Json::Value &body);
|
||||
Json::Value verifyOtp(const Json::Value &body);
|
||||
Json::Value loginWithPassword(const Json::Value &body);
|
||||
Json::Value currentUser(const auth::UserContext &user);
|
||||
};
|
||||
} // namespace visione::services
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
#include "services/ReportService.h"
|
||||
|
||||
#include "common/GeoJson.h"
|
||||
#include "common/Validation.h"
|
||||
#include "repositories/Sql.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <set>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
namespace
|
||||
{
|
||||
Json::Value failure(const std::string &code)
|
||||
{
|
||||
Json::Value body;
|
||||
body["ok"] = false;
|
||||
body["error"] = code;
|
||||
return body;
|
||||
}
|
||||
|
||||
Json::Value success()
|
||||
{
|
||||
Json::Value body;
|
||||
body["ok"] = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
bool isAllowedStatus(const std::string &status)
|
||||
{
|
||||
static const std::set<std::string> statuses = {
|
||||
"OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS",
|
||||
"RESOLVED", "REJECTED", "CLOSED"};
|
||||
return statuses.contains(status);
|
||||
}
|
||||
|
||||
std::optional<long long> findArea(double latitude, double longitude)
|
||||
{
|
||||
const auto result = drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
|
||||
"FROM areas WHERE active = TRUE");
|
||||
|
||||
Json::CharReaderBuilder builder;
|
||||
for (const auto &row : result)
|
||||
{
|
||||
Json::Value geojson;
|
||||
std::string errors;
|
||||
const auto raw = row["geojson"].as<std::string>();
|
||||
std::istringstream stream(raw);
|
||||
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
|
||||
geojson::pointInPolygon(latitude, longitude, geojson))
|
||||
{
|
||||
return row["id"].as<long long>();
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool canAccessReport(const auth::UserContext &user, long long reportId)
|
||||
{
|
||||
if (auth::canManageReports(user))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const auto result = drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT id FROM reports WHERE id = ? AND user_id = ?",
|
||||
reportId,
|
||||
user.id);
|
||||
return !result.empty();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value ReportService::createReport(const auth::UserContext &user,
|
||||
const Json::Value &body)
|
||||
{
|
||||
const auto missing =
|
||||
validation::missingStrings(body, {"address", "description"});
|
||||
if (!missing.empty() || !validation::hasNumber(body, "category_id") ||
|
||||
!validation::hasNumber(body, "latitude") ||
|
||||
!validation::hasNumber(body, "longitude"))
|
||||
{
|
||||
return failure("invalid_report_payload");
|
||||
}
|
||||
|
||||
const auto latitude = body["latitude"].asDouble();
|
||||
const auto longitude = body["longitude"].asDouble();
|
||||
const auto areaId = findArea(latitude, longitude);
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
if (areaId)
|
||||
{
|
||||
db->execSqlSync(
|
||||
"INSERT INTO reports "
|
||||
"(user_id, category_id, area_id, latitude, longitude, address, "
|
||||
"description) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
user.id,
|
||||
body["category_id"].asInt64(),
|
||||
*areaId,
|
||||
latitude,
|
||||
longitude,
|
||||
body["address"].asString(),
|
||||
body["description"].asString());
|
||||
}
|
||||
else
|
||||
{
|
||||
db->execSqlSync(
|
||||
"INSERT INTO reports "
|
||||
"(user_id, category_id, latitude, longitude, address, description) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
user.id,
|
||||
body["category_id"].asInt64(),
|
||||
latitude,
|
||||
longitude,
|
||||
body["address"].asString(),
|
||||
body["description"].asString());
|
||||
}
|
||||
|
||||
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
|
||||
const auto reportId = inserted[0]["id"].as<long long>();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO report_status_history "
|
||||
"(report_id, old_status, new_status, changed_by_user_id, note) "
|
||||
"VALUES (?, NULL, 'OPEN', ?, 'Report created')",
|
||||
reportId,
|
||||
user.id);
|
||||
|
||||
if (body["photos"].isArray())
|
||||
{
|
||||
for (const auto &photo : body["photos"])
|
||||
{
|
||||
if (photo.isString() && !photo.asString().empty())
|
||||
{
|
||||
db->execSqlSync(
|
||||
"INSERT INTO report_photos (report_id, url) VALUES (?, ?)",
|
||||
reportId,
|
||||
photo.asString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["report_id"] = Json::Int64(reportId);
|
||||
if (areaId)
|
||||
{
|
||||
response["area_id"] = Json::Int64(*areaId);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::listReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &status,
|
||||
const std::optional<std::string> &categoryId,
|
||||
const std::optional<std::string> &areaId)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
drogon::orm::Result result(nullptr);
|
||||
|
||||
if (status)
|
||||
{
|
||||
result = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.status = ? "
|
||||
"ORDER BY r.created_at DESC LIMIT 100",
|
||||
*status);
|
||||
}
|
||||
else if (categoryId)
|
||||
{
|
||||
result = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.category_id = ? "
|
||||
"ORDER BY r.created_at DESC LIMIT 100",
|
||||
*categoryId);
|
||||
}
|
||||
else if (areaId)
|
||||
{
|
||||
result = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.area_id = ? "
|
||||
"ORDER BY r.created_at DESC LIMIT 100",
|
||||
*areaId);
|
||||
}
|
||||
else if (auth::canManageReports(user))
|
||||
{
|
||||
result = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id "
|
||||
"ORDER BY r.created_at DESC LIMIT 100");
|
||||
}
|
||||
else
|
||||
{
|
||||
result = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id "
|
||||
"WHERE r.user_id = ? ORDER BY r.created_at DESC LIMIT 100",
|
||||
user.id);
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["reports"] = repositories::resultToJson(result);
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::reportDetail(const auth::UserContext &user,
|
||||
long long reportId)
|
||||
{
|
||||
if (!canAccessReport(user, reportId))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto report = db->execSqlSync(
|
||||
"SELECT r.*, c.name AS category_name, a.name AS area_name "
|
||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||
"LEFT JOIN areas a ON a.id = r.area_id WHERE r.id = ?",
|
||||
reportId);
|
||||
if (report.empty())
|
||||
{
|
||||
return failure("report_not_found");
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["report"] = repositories::rowToJson(report[0]);
|
||||
response["history"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
|
||||
reportId));
|
||||
response["photos"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
||||
reportId));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::updateStatus(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::canManageReports(user))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
if (!validation::hasString(body, "status") ||
|
||||
!isAllowedStatus(body["status"].asString()))
|
||||
{
|
||||
return failure("invalid_status");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto current = db->execSqlSync("SELECT status FROM reports WHERE id = ?",
|
||||
reportId);
|
||||
if (current.empty())
|
||||
{
|
||||
return failure("report_not_found");
|
||||
}
|
||||
|
||||
db->execSqlSync("UPDATE reports SET status = ? WHERE id = ?",
|
||||
body["status"].asString(),
|
||||
reportId);
|
||||
db->execSqlSync(
|
||||
"INSERT INTO report_status_history "
|
||||
"(report_id, old_status, new_status, changed_by_user_id, note) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
reportId,
|
||||
current[0]["status"].as<std::string>(),
|
||||
body["status"].asString(),
|
||||
user.id,
|
||||
body.get("note", "").asString());
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value ReportService::addMessage(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!canAccessReport(user, reportId) || !validation::hasString(body, "body"))
|
||||
{
|
||||
return failure("invalid_message");
|
||||
}
|
||||
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO messages (report_id, user_id, body) VALUES (?, ?, ?)",
|
||||
reportId,
|
||||
user.id,
|
||||
body["body"].asString());
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value ReportService::listMessages(const auth::UserContext &user,
|
||||
long long reportId)
|
||||
{
|
||||
if (!canAccessReport(user, reportId))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["messages"] = repositories::resultToJson(
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"SELECT m.*, u.first_name, u.last_name FROM messages m "
|
||||
"JOIN users u ON u.id = m.user_id WHERE m.report_id = ? "
|
||||
"ORDER BY m.created_at",
|
||||
reportId));
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::assignTeam(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!auth::canManageReports(user) || !validation::hasNumber(body, "team_id"))
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO assignments (report_id, team_id, assigned_by_user_id) "
|
||||
"VALUES (?, ?, ?)",
|
||||
reportId,
|
||||
body["team_id"].asInt64(),
|
||||
user.id);
|
||||
db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?",
|
||||
reportId);
|
||||
db->execSqlSync(
|
||||
"INSERT INTO report_status_history "
|
||||
"(report_id, old_status, new_status, changed_by_user_id, note) "
|
||||
"VALUES (?, NULL, 'ASSIGNED', ?, 'Team assigned')",
|
||||
reportId,
|
||||
user.id);
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value ReportService::addFeedback(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body)
|
||||
{
|
||||
if (!validation::hasNumber(body, "rating"))
|
||||
{
|
||||
return failure("missing_rating");
|
||||
}
|
||||
const auto rating = body["rating"].asInt();
|
||||
if (rating < 1 || rating > 5)
|
||||
{
|
||||
return failure("invalid_rating");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto report = db->execSqlSync(
|
||||
"SELECT user_id, status FROM reports WHERE id = ?",
|
||||
reportId);
|
||||
if (report.empty() || report[0]["user_id"].as<long long>() != user.id)
|
||||
{
|
||||
return failure("forbidden");
|
||||
}
|
||||
if (report[0]["status"].as<std::string>() != "RESOLVED")
|
||||
{
|
||||
return failure("report_not_resolved");
|
||||
}
|
||||
|
||||
db->execSqlSync(
|
||||
"INSERT INTO feedback (report_id, user_id, rating, comment) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
reportId,
|
||||
user.id,
|
||||
rating,
|
||||
body.get("comment", "").asString());
|
||||
return success();
|
||||
}
|
||||
} // namespace visione::services
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Auth.h"
|
||||
|
||||
#include <json/json.h>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
class ReportService
|
||||
{
|
||||
public:
|
||||
Json::Value createReport(const auth::UserContext &user, const Json::Value &body);
|
||||
Json::Value listReports(const auth::UserContext &user,
|
||||
const std::optional<std::string> &status,
|
||||
const std::optional<std::string> &categoryId,
|
||||
const std::optional<std::string> &areaId);
|
||||
Json::Value reportDetail(const auth::UserContext &user, long long reportId);
|
||||
Json::Value updateStatus(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
Json::Value addMessage(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
Json::Value listMessages(const auth::UserContext &user, long long reportId);
|
||||
Json::Value assignTeam(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
Json::Value addFeedback(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const Json::Value &body);
|
||||
};
|
||||
} // namespace visione::services
|
||||
|
||||
Reference in New Issue
Block a user