diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d66a24..0c11926 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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} diff --git a/README.md b/README.md index 517c685..13cda15 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/controllers/AdminController.cc b/controllers/AdminController.cc index 75c0b3c..b979100 100644 --- a/controllers/AdminController.cc +++ b/controllers/AdminController.cc @@ -4,10 +4,23 @@ #include "common/Http.h" #include "services/AdminService.h" +#include + using drogon::HttpResponsePtr; namespace { +std::optional 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 requireUser( const drogon::HttpRequestPtr &req, std::function &callback) @@ -128,6 +141,38 @@ void AdminController::overviewStats( sendAdmin(service.overviewStats(*user), std::move(callback)); } +void AdminController::listModeration( + const drogon::HttpRequestPtr &req, + std::function &&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 &&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 &&callback) @@ -170,4 +215,3 @@ void AdminController::updateUserBan( sendAdmin(service.updateUserBan(*user, userId, visione::http::requireJson(req)), std::move(callback)); } - diff --git a/controllers/AdminController.h b/controllers/AdminController.h index 28ba1ba..396cbd5 100644 --- a/controllers/AdminController.h +++ b/controllers/AdminController.h @@ -19,6 +19,12 @@ class AdminController : public drogon::HttpController 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 void overviewStats( const drogon::HttpRequestPtr &req, std::function &&callback); + void listModeration( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void updateModeration( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long flagId); void listUsers( const drogon::HttpRequestPtr &req, std::function &&callback); diff --git a/controllers/PublicController.cc b/controllers/PublicController.cc new file mode 100644 index 0000000..65b0478 --- /dev/null +++ b/controllers/PublicController.cc @@ -0,0 +1,31 @@ +#include "controllers/PublicController.h" + +#include "common/Http.h" +#include "services/ReportService.h" + +#include + +using drogon::HttpResponsePtr; + +namespace +{ +std::optional 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 &&callback) +{ + visione::services::ReportService service; + callback(visione::http::json(service.openDataReports(queryValue(req, "page"), + queryValue(req, "per_page")))); +} diff --git a/controllers/PublicController.h b/controllers/PublicController.h new file mode 100644 index 0000000..1f27b86 --- /dev/null +++ b/controllers/PublicController.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +class PublicController : public drogon::HttpController +{ + 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 &&callback); +}; diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 1a8c2bf..3f0c04b 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -129,6 +129,54 @@ void ReportController::list( std::move(callback)); } +void ReportController::assignedReports( + const drogon::HttpRequestPtr &req, + std::function &&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 &&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 &&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 &&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 &&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 &&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 &&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 &&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 &&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 &&callback, diff --git a/controllers/ReportController.h b/controllers/ReportController.h index d7f90bc..e324999 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -8,7 +8,19 @@ class ReportController : public drogon::HttpController 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 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 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 std::function &&callback); void list(const drogon::HttpRequestPtr &req, std::function &&callback); + void assignedReports( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void openReports( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void otherOperatorReports( + const drogon::HttpRequestPtr &req, + std::function &&callback); void detail(const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void interventionSummary( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); void updateStatus( const drogon::HttpRequestPtr &req, std::function &&callback, @@ -51,6 +82,11 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void flagMessage( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId, + long long messageId); void listMessages( const drogon::HttpRequestPtr &req, std::function &&callback, @@ -63,6 +99,10 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void flagReport( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); void uploadMedia( const drogon::HttpRequestPtr &req, std::function &&callback, diff --git a/controllers/UserController.cc b/controllers/UserController.cc new file mode 100644 index 0000000..656677a --- /dev/null +++ b/controllers/UserController.cc @@ -0,0 +1,95 @@ +#include "controllers/UserController.h" + +#include "common/Auth.h" +#include "common/Http.h" +#include "services/UserService.h" + +#include + +using drogon::HttpResponsePtr; + +namespace +{ +std::optional 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 requireUser( + const drogon::HttpRequestPtr &req, + std::function &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 &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 &&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 &&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 &&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")))); +} diff --git a/controllers/UserController.h b/controllers/UserController.h new file mode 100644 index 0000000..e3d2095 --- /dev/null +++ b/controllers/UserController.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class UserController : public drogon::HttpController +{ + 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 &&callback); + void markNotificationRead( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long notificationId); + void rewards(const drogon::HttpRequestPtr &req, + std::function &&callback); +}; diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index e56826f..e0f2a8d 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -25,21 +25,39 @@ Authorization: Bearer ## 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. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index efbf2f1..7040c5a 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -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` 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. diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 4d22b01..a53829b 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -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. diff --git a/docs/MILESTONE_04.md b/docs/MILESTONE_04.md new file mode 100644 index 0000000..6422a82 --- /dev/null +++ b/docs/MILESTONE_04.md @@ -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. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 47c3432..ade9325 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -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. diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index c8938bf..f94e88a 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -1,15 +1,1597 @@ { - "openapi": "3.0.3", + "components": { + "responses": { + "BadRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request or business validation failure." + }, + "Forbidden": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authenticated user cannot perform this action." + }, + "NotFound": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Requested resource was not found." + }, + "Unauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid bearer token." + } + }, + "schemas": { + "AdminUser": { + "additionalProperties": true, + "properties": { + "banned": { + "type": "boolean" + }, + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "must_change_password": { + "type": "boolean" + }, + "phone": { + "type": "string" + }, + "points": { + "type": "integer" + }, + "roles": { + "description": "Comma-separated role names from SQL GROUP_CONCAT.", + "type": "string" + } + }, + "type": "object" + }, + "Area": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "boundary_geojson": { + "$ref": "#/components/schemas/GeoJsonObject" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "AreaListResponse": { + "additionalProperties": false, + "properties": { + "areas": { + "items": { + "$ref": "#/components/schemas/Area" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "areas" + ], + "type": "object" + }, + "AssignTeamRequest": { + "additionalProperties": false, + "properties": { + "team_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "team_id" + ], + "type": "object" + }, + "Category": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "CategoryListResponse": { + "additionalProperties": false, + "properties": { + "categories": { + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "categories" + ], + "type": "object" + }, + "ChangePasswordRequest": { + "additionalProperties": false, + "properties": { + "current_password": { + "format": "password", + "type": "string" + }, + "new_password": { + "format": "password", + "minLength": 10, + "type": "string" + } + }, + "required": [ + "current_password", + "new_password" + ], + "type": "object" + }, + "CreateAreaRequest": { + "additionalProperties": false, + "properties": { + "boundary_geojson": { + "$ref": "#/components/schemas/GeoJsonObject" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "boundary_geojson" + ], + "type": "object" + }, + "CreateCategoryRequest": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CreateReportRequest": { + "additionalProperties": false, + "properties": { + "address": { + "type": "string" + }, + "category_id": { + "format": "int64", + "type": "integer" + }, + "description": { + "type": "string" + }, + "latitude": { + "format": "double", + "type": "number" + }, + "longitude": { + "format": "double", + "type": "number" + }, + "media": { + "items": { + "format": "uri-reference", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "category_id", + "latitude", + "longitude", + "address", + "description" + ], + "type": "object" + }, + "CreateReportResponse": { + "additionalProperties": false, + "properties": { + "area_id": { + "format": "int64", + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "report_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "ok", + "report_id" + ], + "type": "object" + }, + "CreateTeamRequest": { + "additionalProperties": false, + "properties": { + "area_id": { + "format": "int64", + "type": "integer" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "team_category_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "team_category_id", + "name" + ], + "type": "object" + }, + "CurrentUser": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "must_change_password": { + "type": "boolean" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "phone": { + "type": "string" + }, + "points": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "roles": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "first_name", + "last_name", + "email", + "fiscal_code", + "points", + "must_change_password", + "role", + "roles", + "permissions" + ], + "type": "object" + }, + "CurrentUserResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/CurrentUser" + } + }, + "required": [ + "ok", + "user" + ], + "type": "object" + }, + "EmailRequest": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "FeedbackRequest": { + "additionalProperties": false, + "properties": { + "comment": { + "type": "string" + }, + "rating": { + "maximum": 5, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "rating" + ], + "type": "object" + }, + "FlagRequest": { + "additionalProperties": false, + "properties": { + "details": { + "type": "string" + }, + "reason": { + "maxLength": 120, + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "FlagResponse": { + "additionalProperties": false, + "properties": { + "flag_id": { + "format": "int64", + "type": "integer" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "flag_id" + ], + "type": "object" + }, + "GeoJsonObject": { + "additionalProperties": true, + "description": "GeoJSON polygon or multipolygon boundary.", + "type": "object" + }, + "HealthResponse": { + "additionalProperties": false, + "properties": { + "service": { + "example": "visione-backend", + "type": "string" + }, + "status": { + "example": "ok", + "type": "string" + } + }, + "required": [ + "status", + "service" + ], + "type": "object" + }, + "InterventionSummaryResponse": { + "additionalProperties": false, + "properties": { + "latest_messages": { + "items": { + "$ref": "#/components/schemas/Message" + }, + "type": "array" + }, + "media": { + "items": { + "$ref": "#/components/schemas/Media" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "summary": { + "$ref": "#/components/schemas/Report" + } + }, + "required": [ + "ok", + "summary", + "media", + "latest_messages" + ], + "type": "object" + }, + "Media": { + "additionalProperties": true, + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "media_kind": { + "enum": [ + "IMAGE", + "VIDEO" + ], + "type": "string" + }, + "original_filename": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "MediaUploadRequest": { + "additionalProperties": false, + "properties": { + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "maxItems": 5, + "type": "array" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "MediaUploadResponse": { + "additionalProperties": false, + "properties": { + "media": { + "items": { + "$ref": "#/components/schemas/Media" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "media" + ], + "type": "object" + }, + "Message": { + "additionalProperties": true, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "MessageListResponse": { + "additionalProperties": false, + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/Message" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "messages" + ], + "type": "object" + }, + "MessageRequest": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "ModerationFlag": { + "additionalProperties": true, + "properties": { + "content_type": { + "enum": [ + "REPORT", + "MESSAGE" + ], + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by_email": { + "type": "string" + }, + "created_by_user_id": { + "format": "int64", + "type": "integer" + }, + "details": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "message_id": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "reason": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + }, + "resolution_note": { + "nullable": true, + "type": "string" + }, + "resolved_at": { + "nullable": true, + "type": "string" + }, + "resolved_by_email": { + "nullable": true, + "type": "string" + }, + "resolved_by_user_id": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "status": { + "enum": [ + "OPEN", + "RESOLVED", + "DISMISSED" + ], + "type": "string" + } + }, + "type": "object" + }, + "ModerationListResponse": { + "additionalProperties": false, + "properties": { + "flags": { + "items": { + "$ref": "#/components/schemas/ModerationFlag" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + } + }, + "required": [ + "ok", + "page", + "per_page", + "flags" + ], + "type": "object" + }, + "Notification": { + "additionalProperties": true, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "read_at": { + "nullable": true, + "type": "string" + }, + "report_id": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "NotificationListResponse": { + "additionalProperties": false, + "properties": { + "notifications": { + "items": { + "$ref": "#/components/schemas/Notification" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + } + }, + "required": [ + "ok", + "page", + "per_page", + "notifications" + ], + "type": "object" + }, + "OkResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "OpenApiDocument": { + "additionalProperties": true, + "description": "Generated OpenAPI 3 document.", + "type": "object" + }, + "OpenDataReport": { + "additionalProperties": true, + "properties": { + "area_id": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "area_name": { + "nullable": true, + "type": "string" + }, + "category_id": { + "format": "int64", + "type": "integer" + }, + "category_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, + "OpenDataReportListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "reports": { + "items": { + "$ref": "#/components/schemas/OpenDataReport" + }, + "type": "array" + } + }, + "required": [ + "ok", + "page", + "per_page", + "reports" + ], + "type": "object" + }, + "OtpRequestResponse": { + "additionalProperties": false, + "properties": { + "delivery": { + "example": "mailhog", + "type": "string" + }, + "expires_in_minutes": { + "type": "integer" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "delivery", + "expires_in_minutes" + ], + "type": "object" + }, + "OverviewStatsResponse": { + "additionalProperties": false, + "properties": { + "avg_resolution_hours": { + "items": { + "additionalProperties": true, + "properties": { + "value": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "avg_resolution_hours_by_area": { + "items": { + "additionalProperties": true, + "properties": { + "area": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "avg_resolution_hours_by_category": { + "items": { + "additionalProperties": true, + "properties": { + "category": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "monthly_reports": { + "items": { + "additionalProperties": true, + "properties": { + "month": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "reports_by_area": { + "items": { + "additionalProperties": true, + "properties": { + "area": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "reports_by_category": { + "items": { + "additionalProperties": true, + "properties": { + "category": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "reports_by_status": { + "items": { + "additionalProperties": true, + "properties": { + "status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "reports_by_team": { + "items": { + "additionalProperties": true, + "properties": { + "team": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "ok", + "reports_by_status", + "reports_by_area", + "avg_resolution_hours", + "reports_by_category", + "reports_by_team", + "monthly_reports", + "avg_resolution_hours_by_category", + "avg_resolution_hours_by_area" + ], + "type": "object" + }, + "PagedReportListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "reports": { + "items": { + "$ref": "#/components/schemas/Report" + }, + "type": "array" + } + }, + "required": [ + "ok", + "page", + "per_page", + "reports" + ], + "type": "object" + }, + "PasswordLoginRequest": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "password": { + "format": "password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + }, + "ReadyResponse": { + "additionalProperties": false, + "properties": { + "dependencies": { + "additionalProperties": false, + "properties": { + "mysql": { + "type": "boolean" + } + }, + "required": [ + "mysql" + ], + "type": "object" + }, + "errors": { + "additionalProperties": false, + "properties": { + "mysql": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + } + }, + "required": [ + "status", + "dependencies" + ], + "type": "object" + }, + "RegisterCitizenRequest": { + "additionalProperties": false, + "properties": { + "birth_date": { + "format": "date", + "type": "string" + }, + "birth_place": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "first_name": { + "type": "string" + }, + "fiscal_code": { + "maxLength": 16, + "minLength": 16, + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "format": "password", + "minLength": 10, + "type": "string" + }, + "phone": { + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password", + "fiscal_code" + ], + "type": "object" + }, + "RegisterCitizenResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "ok", + "user_id" + ], + "type": "object" + }, + "Report": { + "additionalProperties": true, + "properties": { + "address": { + "type": "string" + }, + "area_id": { + "format": "int64", + "type": "integer" + }, + "area_name": { + "type": "string" + }, + "assigned_operator_id": { + "format": "int64", + "type": "integer" + }, + "category_id": { + "format": "int64", + "type": "integer" + }, + "category_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "category_id", + "latitude", + "longitude", + "address", + "description", + "status" + ], + "type": "object" + }, + "ReportDetailResponse": { + "additionalProperties": false, + "properties": { + "history": { + "items": { + "$ref": "#/components/schemas/StatusHistoryItem" + }, + "type": "array" + }, + "media": { + "items": { + "$ref": "#/components/schemas/Media" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "report": { + "$ref": "#/components/schemas/Report" + } + }, + "required": [ + "ok", + "report", + "history", + "media" + ], + "type": "object" + }, + "ReportListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "reports": { + "items": { + "$ref": "#/components/schemas/Report" + }, + "type": "array" + } + }, + "required": [ + "ok", + "reports" + ], + "type": "object" + }, + "ReportStatus": { + "enum": [ + "OPEN", + "UNDER_REVIEW", + "ASSIGNED", + "IN_PROGRESS", + "RESOLVED", + "REJECTED", + "CLOSED" + ], + "type": "string" + }, + "RewardEvent": { + "additionalProperties": true, + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "points": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "report_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "RewardsResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "points": { + "type": "integer" + }, + "reward_events": { + "items": { + "$ref": "#/components/schemas/RewardEvent" + }, + "type": "array" + } + }, + "required": [ + "ok", + "points", + "page", + "per_page", + "reward_events" + ], + "type": "object" + }, + "StatusHistoryItem": { + "additionalProperties": true, + "properties": { + "changed_by_user_id": { + "format": "int64", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "new_status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "note": { + "type": "string" + }, + "old_status": { + "$ref": "#/components/schemas/ReportStatus" + }, + "report_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "Team": { + "additionalProperties": true, + "properties": { + "active": { + "type": "boolean" + }, + "area_id": { + "format": "int64", + "type": "integer" + }, + "area_name": { + "type": "string" + }, + "category_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + }, + "team_category_id": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "TeamListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "teams": { + "items": { + "$ref": "#/components/schemas/Team" + }, + "type": "array" + } + }, + "required": [ + "ok", + "teams" + ], + "type": "object" + }, + "TokenResponse": { + "additionalProperties": false, + "properties": { + "expires_in_hours": { + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "token": { + "type": "string" + }, + "token_type": { + "example": "Bearer", + "type": "string" + } + }, + "required": [ + "ok", + "token", + "token_type", + "expires_in_hours" + ], + "type": "object" + }, + "UpdateModerationRequest": { + "additionalProperties": false, + "properties": { + "reject_report": { + "type": "boolean" + }, + "resolution_note": { + "type": "string" + }, + "status": { + "enum": [ + "RESOLVED", + "DISMISSED" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UpdateStatusRequest": { + "additionalProperties": false, + "properties": { + "note": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ReportStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UpdateUserBanRequest": { + "additionalProperties": false, + "properties": { + "banned": { + "type": "boolean" + } + }, + "required": [ + "banned" + ], + "type": "object" + }, + "UpdateUserRolesRequest": { + "additionalProperties": false, + "properties": { + "roles": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "roles" + ], + "type": "object" + }, + "UserListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "users": { + "items": { + "$ref": "#/components/schemas/AdminUser" + }, + "type": "array" + } + }, + "required": [ + "ok", + "users" + ], + "type": "object" + }, + "VerifyOtpRequest": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + } + }, + "required": [ + "email", + "code" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + } + } + }, "info": { "description": "Visione OpenAPI contract maintained manually and merged with Drogon route extraction.", "title": "Visione API", "version": "0.1.0" }, - "servers": [ - { - "url": "http://localhost:5555" - } - ], + "openapi": "3.0.3", "paths": { "/api/v1/admin/areas": { "get": { @@ -163,6 +1745,127 @@ ] } }, + "/api/v1/admin/moderation": { + "get": { + "description": "Lists moderation flags for admin/operator review.", + "operationId": "AdminController.listModeration", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "OPEN", + "RESOLVED", + "DISMISSED" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModerationListResponse" + } + } + }, + "description": "Paged moderation flags." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List moderation flags", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/admin/moderation/{id}": { + "patch": { + "description": "Resolves or dismisses a moderation flag. Optionally rejects the related report.", + "operationId": "AdminController.updateModeration", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateModerationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Moderation update result." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update moderation flag", + "tags": [ + "Moderation" + ] + } + }, "/api/v1/admin/stats/overview": { "get": { "description": "Returns report statistics for administration dashboards.", @@ -689,6 +2392,195 @@ ] } }, + "/api/v1/me/rewards": { + "get": { + "description": "Returns current points and reward events for the authenticated user.", + "operationId": "UserController.rewards", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RewardsResponse" + } + } + }, + "description": "Reward ledger." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reward ledger", + "tags": [ + "User" + ] + } + }, + "/api/v1/notifications": { + "get": { + "description": "Lists notifications for the authenticated user.", + "operationId": "UserController.listNotifications", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationListResponse" + } + } + }, + "description": "Paged notification list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List notifications", + "tags": [ + "User" + ] + } + }, + "/api/v1/notifications/{id}/read": { + "patch": { + "description": "Marks a notification as read for the authenticated user.", + "operationId": "UserController.markNotificationRead", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Notification update result." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Mark notification read", + "tags": [ + "User" + ] + } + }, + "/api/v1/open-data/reports": { + "get": { + "description": "Returns anonymized resolved/closed report data for public reuse.", + "operationId": "PublicController.openDataReports", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenDataReportListResponse" + } + } + }, + "description": "Paged open-data report list." + } + }, + "summary": "Open data reports", + "tags": [ + "Public" + ] + } + }, "/api/v1/openapi.json": { "get": { "description": "Returns the generated OpenAPI JSON document.", @@ -715,6 +2607,159 @@ ] } }, + "/api/v1/operator/reports/assigned": { + "get": { + "description": "Lists reports assigned to the authenticated operator.", + "operationId": "ReportController.assignedReports", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedReportListResponse" + } + } + }, + "description": "Paged assigned report list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Assigned reports", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/operator/reports/open": { + "get": { + "description": "Lists open reports not yet taken by an operator.", + "operationId": "ReportController.openReports", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedReportListResponse" + } + } + }, + "description": "Paged open report list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Open unassigned reports", + "tags": [ + "Reports" + ] + } + }, + "/api/v1/operator/reports/others": { + "get": { + "description": "Lists reports assigned to another operator.", + "operationId": "ReportController.otherOperatorReports", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedReportListResponse" + } + } + }, + "description": "Paged report list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Other operator reports", + "tags": [ + "Reports" + ] + } + }, "/api/v1/ready": { "get": { "description": "Checks whether MySQL is reachable.", @@ -1009,6 +3054,107 @@ ] } }, + "/api/v1/reports/{id}/flags": { + "post": { + "description": "Creates a moderation flag for a report.", + "operationId": "ReportController.flagReport", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagResponse" + } + } + }, + "description": "Created moderation flag." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Flag report", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/reports/{id}/intervention-summary": { + "get": { + "description": "Returns operator-facing report data, media and the latest messages for intervention planning.", + "operationId": "ReportController.interventionSummary", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterventionSummaryResponse" + } + } + }, + "description": "Intervention summary." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Intervention summary", + "tags": [ + "Reports" + ] + } + }, "/api/v1/reports/{id}/media": { "post": { "description": "Uploads report images or videos as multipart files.", @@ -1165,6 +3311,72 @@ ] } }, + "/api/v1/reports/{id}/messages/{message_id}/flags": { + "post": { + "description": "Creates a moderation flag for a report message.", + "operationId": "ReportController.flagMessage", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlagResponse" + } + } + }, + "description": "Created moderation flag." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Flag message", + "tags": [ + "Moderation" + ] + } + }, "/api/v1/reports/{id}/status": { "patch": { "description": "Updates a report status following allowed transitions.", @@ -1270,1125 +3482,9 @@ } } }, - "components": { - "responses": { - "BadRequest": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Invalid request or business validation failure." - }, - "Forbidden": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Authenticated user cannot perform this action." - }, - "NotFound": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Requested resource was not found." - }, - "Unauthorized": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Missing or invalid bearer token." - } - }, - "schemas": { - "AdminUser": { - "additionalProperties": true, - "properties": { - "banned": { - "type": "boolean" - }, - "email": { - "format": "email", - "type": "string" - }, - "first_name": { - "type": "string" - }, - "fiscal_code": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "last_name": { - "type": "string" - }, - "must_change_password": { - "type": "boolean" - }, - "phone": { - "type": "string" - }, - "points": { - "type": "integer" - }, - "roles": { - "description": "Comma-separated role names from SQL GROUP_CONCAT.", - "type": "string" - } - }, - "type": "object" - }, - "Area": { - "additionalProperties": true, - "properties": { - "active": { - "type": "boolean" - }, - "boundary_geojson": { - "$ref": "#/components/schemas/GeoJsonObject" - }, - "description": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "type": "object" - }, - "AreaListResponse": { - "additionalProperties": false, - "properties": { - "areas": { - "items": { - "$ref": "#/components/schemas/Area" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok", - "areas" - ], - "type": "object" - }, - "AssignTeamRequest": { - "additionalProperties": false, - "properties": { - "team_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "team_id" - ], - "type": "object" - }, - "Category": { - "additionalProperties": true, - "properties": { - "active": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "type": "object" - }, - "CategoryListResponse": { - "additionalProperties": false, - "properties": { - "categories": { - "items": { - "$ref": "#/components/schemas/Category" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok", - "categories" - ], - "type": "object" - }, - "ChangePasswordRequest": { - "additionalProperties": false, - "properties": { - "current_password": { - "format": "password", - "type": "string" - }, - "new_password": { - "format": "password", - "minLength": 10, - "type": "string" - } - }, - "required": [ - "current_password", - "new_password" - ], - "type": "object" - }, - "CreateAreaRequest": { - "additionalProperties": false, - "properties": { - "boundary_geojson": { - "$ref": "#/components/schemas/GeoJsonObject" - }, - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name", - "boundary_geojson" - ], - "type": "object" - }, - "CreateCategoryRequest": { - "additionalProperties": false, - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "CreateReportRequest": { - "additionalProperties": false, - "properties": { - "address": { - "type": "string" - }, - "category_id": { - "format": "int64", - "type": "integer" - }, - "description": { - "type": "string" - }, - "latitude": { - "format": "double", - "type": "number" - }, - "longitude": { - "format": "double", - "type": "number" - }, - "media": { - "items": { - "format": "uri-reference", - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "category_id", - "latitude", - "longitude", - "address", - "description" - ], - "type": "object" - }, - "CreateReportResponse": { - "additionalProperties": false, - "properties": { - "area_id": { - "format": "int64", - "type": "integer" - }, - "ok": { - "type": "boolean" - }, - "report_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "ok", - "report_id" - ], - "type": "object" - }, - "CreateTeamRequest": { - "additionalProperties": false, - "properties": { - "area_id": { - "format": "int64", - "type": "integer" - }, - "description": { - "type": "string" - }, - "name": { - "type": "string" - }, - "team_category_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "team_category_id", - "name" - ], - "type": "object" - }, - "CurrentUser": { - "additionalProperties": false, - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "first_name": { - "type": "string" - }, - "fiscal_code": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "last_name": { - "type": "string" - }, - "must_change_password": { - "type": "boolean" - }, - "permissions": { - "items": { - "type": "string" - }, - "type": "array" - }, - "phone": { - "type": "string" - }, - "points": { - "type": "integer" - }, - "role": { - "type": "string" - }, - "roles": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "first_name", - "last_name", - "email", - "fiscal_code", - "points", - "must_change_password", - "role", - "roles", - "permissions" - ], - "type": "object" - }, - "CurrentUserResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - }, - "user": { - "$ref": "#/components/schemas/CurrentUser" - } - }, - "required": [ - "ok", - "user" - ], - "type": "object" - }, - "EmailRequest": { - "additionalProperties": false, - "properties": { - "email": { - "format": "email", - "type": "string" - } - }, - "required": [ - "email" - ], - "type": "object" - }, - "ErrorResponse": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "fields": { - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - } - }, - "required": [ - "error" - ], - "type": "object" - }, - "FeedbackRequest": { - "additionalProperties": false, - "properties": { - "comment": { - "type": "string" - }, - "rating": { - "maximum": 5, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "rating" - ], - "type": "object" - }, - "GeoJsonObject": { - "additionalProperties": true, - "description": "GeoJSON polygon or multipolygon boundary.", - "type": "object" - }, - "HealthResponse": { - "additionalProperties": false, - "properties": { - "service": { - "example": "visione-backend", - "type": "string" - }, - "status": { - "example": "ok", - "type": "string" - } - }, - "required": [ - "status", - "service" - ], - "type": "object" - }, - "Media": { - "additionalProperties": true, - "properties": { - "content_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "media_kind": { - "enum": [ - "IMAGE", - "VIDEO" - ], - "type": "string" - }, - "original_filename": { - "type": "string" - }, - "report_id": { - "format": "int64", - "type": "integer" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - }, - "url": { - "type": "string" - } - }, - "type": "object" - }, - "MediaUploadRequest": { - "additionalProperties": false, - "properties": { - "files": { - "items": { - "format": "binary", - "type": "string" - }, - "maxItems": 5, - "type": "array" - } - }, - "required": [ - "files" - ], - "type": "object" - }, - "MediaUploadResponse": { - "additionalProperties": false, - "properties": { - "media": { - "items": { - "$ref": "#/components/schemas/Media" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok", - "media" - ], - "type": "object" - }, - "Message": { - "additionalProperties": true, - "properties": { - "body": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "first_name": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "last_name": { - "type": "string" - }, - "report_id": { - "format": "int64", - "type": "integer" - }, - "user_id": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, - "MessageListResponse": { - "additionalProperties": false, - "properties": { - "messages": { - "items": { - "$ref": "#/components/schemas/Message" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok", - "messages" - ], - "type": "object" - }, - "MessageRequest": { - "additionalProperties": false, - "properties": { - "body": { - "type": "string" - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "OkResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok" - ], - "type": "object" - }, - "OpenApiDocument": { - "additionalProperties": true, - "description": "Generated OpenAPI 3 document.", - "type": "object" - }, - "OtpRequestResponse": { - "additionalProperties": false, - "properties": { - "delivery": { - "example": "mailhog", - "type": "string" - }, - "expires_in_minutes": { - "type": "integer" - }, - "ok": { - "type": "boolean" - } - }, - "required": [ - "ok", - "delivery", - "expires_in_minutes" - ], - "type": "object" - }, - "OverviewStatsResponse": { - "additionalProperties": false, - "properties": { - "avg_resolution_hours": { - "items": { - "additionalProperties": true, - "properties": { - "value": { - "type": "number" - } - }, - "type": "object" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - }, - "reports_by_area": { - "items": { - "additionalProperties": true, - "properties": { - "area": { - "type": "string" - }, - "total": { - "type": "integer" - } - }, - "type": "object" - }, - "type": "array" - }, - "reports_by_status": { - "items": { - "additionalProperties": true, - "properties": { - "status": { - "$ref": "#/components/schemas/ReportStatus" - }, - "total": { - "type": "integer" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "required": [ - "ok", - "reports_by_status", - "reports_by_area", - "avg_resolution_hours" - ], - "type": "object" - }, - "PasswordLoginRequest": { - "additionalProperties": false, - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "password": { - "format": "password", - "type": "string" - } - }, - "required": [ - "email", - "password" - ], - "type": "object" - }, - "ReadyResponse": { - "additionalProperties": false, - "properties": { - "dependencies": { - "additionalProperties": false, - "properties": { - "mysql": { - "type": "boolean" - } - }, - "required": [ - "mysql" - ], - "type": "object" - }, - "errors": { - "additionalProperties": false, - "properties": { - "mysql": { - "type": "string" - } - }, - "type": "object" - }, - "status": { - "enum": [ - "ready", - "not_ready" - ], - "type": "string" - } - }, - "required": [ - "status", - "dependencies" - ], - "type": "object" - }, - "RegisterCitizenRequest": { - "additionalProperties": false, - "properties": { - "birth_date": { - "format": "date", - "type": "string" - }, - "birth_place": { - "type": "string" - }, - "email": { - "format": "email", - "type": "string" - }, - "first_name": { - "type": "string" - }, - "fiscal_code": { - "maxLength": 16, - "minLength": 16, - "type": "string" - }, - "last_name": { - "type": "string" - }, - "password": { - "format": "password", - "minLength": 10, - "type": "string" - }, - "phone": { - "type": "string" - } - }, - "required": [ - "first_name", - "last_name", - "email", - "password", - "fiscal_code" - ], - "type": "object" - }, - "RegisterCitizenResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - }, - "user_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "ok", - "user_id" - ], - "type": "object" - }, - "Report": { - "additionalProperties": true, - "properties": { - "address": { - "type": "string" - }, - "area_id": { - "format": "int64", - "type": "integer" - }, - "area_name": { - "type": "string" - }, - "assigned_operator_id": { - "format": "int64", - "type": "integer" - }, - "category_id": { - "format": "int64", - "type": "integer" - }, - "category_name": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "latitude": { - "type": "number" - }, - "longitude": { - "type": "number" - }, - "status": { - "$ref": "#/components/schemas/ReportStatus" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "id", - "category_id", - "latitude", - "longitude", - "address", - "description", - "status" - ], - "type": "object" - }, - "ReportDetailResponse": { - "additionalProperties": false, - "properties": { - "history": { - "items": { - "$ref": "#/components/schemas/StatusHistoryItem" - }, - "type": "array" - }, - "media": { - "items": { - "$ref": "#/components/schemas/Media" - }, - "type": "array" - }, - "ok": { - "type": "boolean" - }, - "report": { - "$ref": "#/components/schemas/Report" - } - }, - "required": [ - "ok", - "report", - "history", - "media" - ], - "type": "object" - }, - "ReportListResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - }, - "reports": { - "items": { - "$ref": "#/components/schemas/Report" - }, - "type": "array" - } - }, - "required": [ - "ok", - "reports" - ], - "type": "object" - }, - "ReportStatus": { - "enum": [ - "OPEN", - "UNDER_REVIEW", - "ASSIGNED", - "IN_PROGRESS", - "RESOLVED", - "REJECTED", - "CLOSED" - ], - "type": "string" - }, - "StatusHistoryItem": { - "additionalProperties": true, - "properties": { - "changed_by_user_id": { - "format": "int64", - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "new_status": { - "$ref": "#/components/schemas/ReportStatus" - }, - "note": { - "type": "string" - }, - "old_status": { - "$ref": "#/components/schemas/ReportStatus" - }, - "report_id": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, - "Team": { - "additionalProperties": true, - "properties": { - "active": { - "type": "boolean" - }, - "area_id": { - "format": "int64", - "type": "integer" - }, - "area_name": { - "type": "string" - }, - "category_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - }, - "team_category_id": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, - "TeamListResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - }, - "teams": { - "items": { - "$ref": "#/components/schemas/Team" - }, - "type": "array" - } - }, - "required": [ - "ok", - "teams" - ], - "type": "object" - }, - "TokenResponse": { - "additionalProperties": false, - "properties": { - "expires_in_hours": { - "type": "integer" - }, - "ok": { - "type": "boolean" - }, - "token": { - "type": "string" - }, - "token_type": { - "example": "Bearer", - "type": "string" - } - }, - "required": [ - "ok", - "token", - "token_type", - "expires_in_hours" - ], - "type": "object" - }, - "UpdateStatusRequest": { - "additionalProperties": false, - "properties": { - "note": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/ReportStatus" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "UpdateUserBanRequest": { - "additionalProperties": false, - "properties": { - "banned": { - "type": "boolean" - } - }, - "required": [ - "banned" - ], - "type": "object" - }, - "UpdateUserRolesRequest": { - "additionalProperties": false, - "properties": { - "roles": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "roles" - ], - "type": "object" - }, - "UserListResponse": { - "additionalProperties": false, - "properties": { - "ok": { - "type": "boolean" - }, - "users": { - "items": { - "$ref": "#/components/schemas/AdminUser" - }, - "type": "array" - } - }, - "required": [ - "ok", - "users" - ], - "type": "object" - }, - "VerifyOtpRequest": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "email": { - "format": "email", - "type": "string" - } - }, - "required": [ - "email", - "code" - ], - "type": "object" - } - }, - "securitySchemes": { - "bearerAuth": { - "scheme": "bearer", - "type": "http" - } + "servers": [ + { + "url": "http://localhost:5555" } - } + ] } diff --git a/migrations/006_completion_features.sql b/migrations/006_completion_features.sql new file mode 100644 index 0000000..70a43f5 --- /dev/null +++ b/migrations/006_completion_features.sql @@ -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); diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index d4063ae..c3446fa 100644 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -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, diff --git a/scripts/test_generate_openapi.py b/scripts/test_generate_openapi.py index 74a0717..ab68f7b 100644 --- a/scripts/test_generate_openapi.py +++ b/scripts/test_generate_openapi.py @@ -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"): { diff --git a/services/AdminService.cc b/services/AdminService.cc index d6dab35..747fb25 100644 --- a/services/AdminService.cc +++ b/services/AdminService.cc @@ -2,9 +2,12 @@ #include "common/Validation.h" #include "repositories/Sql.h" +#include "services/UserService.h" #include +#include + namespace visione::services { namespace @@ -23,6 +26,38 @@ Json::Value success() body["ok"] = true; return body; } + +int boundedPage(const std::optional &value) +{ + if (!value) + { + return 1; + } + try + { + return std::max(1, std::stoi(*value)); + } + catch (...) + { + return 1; + } +} + +int boundedPerPage(const std::optional &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 &status, + const std::optional &page, + const std::optional &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(); + const auto current = db->execSqlSync( + "SELECT status, user_id FROM reports WHERE id = ?", + reportId); + if (!current.empty() && current[0]["status"].as() != "REJECTED") + { + const auto oldStatus = current[0]["status"].as(); + 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(), + "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)) diff --git a/services/AdminService.h b/services/AdminService.h index d336170..58c111a 100644 --- a/services/AdminService.h +++ b/services/AdminService.h @@ -4,6 +4,9 @@ #include +#include +#include + 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 &status, + const std::optional &page, + const std::optional &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); diff --git a/services/ReportService.cc b/services/ReportService.cc index 200c0fd..45d71c0 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -3,12 +3,14 @@ #include "common/GeoJson.h" #include "common/Validation.h" #include "repositories/Sql.h" +#include "services/UserService.h" #include #include #include #include +#include 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 &value) +{ + if (!value) + { + return 1; + } + try + { + return std::max(1, std::stoi(*value)); + } + catch (...) + { + return 1; + } +} + +int boundedPerPage(const std::optional &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 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(); +} + +std::optional 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(); +} + +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 &page, + const std::optional &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 &page, + const std::optional &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 &page, + const std::optional &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()); + 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()); + return response; +} + +Json::Value ReportService::openDataReports(const std::optional &page, + const std::optional &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) { diff --git a/services/ReportService.h b/services/ReportService.h index 4481ade..c738756 100644 --- a/services/ReportService.h +++ b/services/ReportService.h @@ -16,7 +16,17 @@ class ReportService const std::optional &status, const std::optional &categoryId, const std::optional &areaId); + Json::Value assignedReports(const auth::UserContext &user, + const std::optional &page, + const std::optional &perPage); + Json::Value openReports(const auth::UserContext &user, + const std::optional &page, + const std::optional &perPage); + Json::Value otherOperatorReports(const auth::UserContext &user, + const std::optional &page, + const std::optional &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 &page, + const std::optional &perPage); Json::Value canAddMedia(const auth::UserContext &user, long long reportId); Json::Value addMedia(const auth::UserContext &user, long long reportId, diff --git a/services/UserService.cc b/services/UserService.cc new file mode 100644 index 0000000..ff26470 --- /dev/null +++ b/services/UserService.cc @@ -0,0 +1,197 @@ +#include "services/UserService.h" + +#include "repositories/Sql.h" + +#include + +#include + +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 &value) +{ + if (!value) + { + return 1; + } + try + { + return std::max(1, std::stoi(*value)); + } + catch (...) + { + return 1; + } +} + +int boundedPerPage(const std::optional &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 &page, + const std::optional &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 &page, + const std::optional &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(); + 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 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(); + 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 diff --git a/services/UserService.h b/services/UserService.h new file mode 100644 index 0000000..2886f3e --- /dev/null +++ b/services/UserService.h @@ -0,0 +1,33 @@ +#pragma once + +#include "common/Auth.h" + +#include + +#include +#include + +namespace visione::services +{ +class UserService +{ + public: + Json::Value listNotifications(const auth::UserContext &user, + const std::optional &page, + const std::optional &perPage); + Json::Value markNotificationRead(const auth::UserContext &user, + long long notificationId); + Json::Value rewards(const auth::UserContext &user, + const std::optional &page, + const std::optional &perPage); + + static void createNotification(long long userId, + const std::string &type, + const std::string &title, + const std::string &body, + std::optional reportId); + static void awardReportOwner(long long reportId, + const std::string &reason, + int points); +}; +} // namespace visione::services