From 170fadbfe081315b90461fab32b2f31f57f499bd Mon Sep 17 00:00:00 2001 From: gabdevele Date: Mon, 15 Jun 2026 11:47:44 +0200 Subject: [PATCH] feat: enhance Admin and report services with CRUD operations and intervention links --- controllers/AdminController.cc | 73 ++- controllers/AdminController.h | 35 +- controllers/PublicController.cc | 20 + controllers/PublicController.h | 10 + controllers/ReportController.cc | 79 ++- controllers/ReportController.h | 32 ++ docs/API_SPEC.md | 16 +- docs/openapi/visione.openapi.yaml | 537 +++++++++++++++--- ..._reference_data_and_intervention_links.sql | 37 ++ services/AdminService.cc | 239 ++++++-- services/AdminService.h | 12 +- services/ReportService.cc | 325 ++++++++++- services/ReportService.h | 7 + 13 files changed, 1258 insertions(+), 164 deletions(-) create mode 100644 migrations/007_reference_data_and_intervention_links.sql diff --git a/controllers/AdminController.cc b/controllers/AdminController.cc index b5fa34d..eca51a6 100644 --- a/controllers/AdminController.cc +++ b/controllers/AdminController.cc @@ -69,18 +69,14 @@ void sendAdmin(Json::Value body, { status = drogon::k409Conflict; } + else if (error == "invalid_reference" || error.starts_with("invalid_")) + { + status = drogon::k422UnprocessableEntity; + } callback(visione::http::json(std::move(body), status)); } } // namespace -void AdminController::listCategories( - const drogon::HttpRequestPtr &, - std::function &&callback) -{ - visione::services::AdminService service; - callback(visione::http::json(service.listCategories())); -} - void AdminController::createCategory( const drogon::HttpRequestPtr &req, std::function &&callback) @@ -95,14 +91,6 @@ void AdminController::createCategory( std::move(callback)); } -void AdminController::listAreas( - const drogon::HttpRequestPtr &, - std::function &&callback) -{ - visione::services::AdminService service; - callback(visione::http::json(service.listAreas())); -} - void AdminController::createArea( const drogon::HttpRequestPtr &req, std::function &&callback) @@ -117,14 +105,6 @@ void AdminController::createArea( std::move(callback)); } -void AdminController::listTeams( - const drogon::HttpRequestPtr &, - std::function &&callback) -{ - visione::services::AdminService service; - callback(visione::http::json(service.listTeams())); -} - void AdminController::createTeam( const drogon::HttpRequestPtr &req, std::function &&callback) @@ -139,6 +119,51 @@ void AdminController::createTeam( std::move(callback)); } +void AdminController::updateCategory( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long categoryId) +{ + auto user = requireUser(req, callback); + if (!user || blockPasswordChangeRequired(*user, callback)) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.updateCategory(*user, categoryId, visione::http::requireJson(req)), + std::move(callback)); +} + +void AdminController::updateArea( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long areaId) +{ + auto user = requireUser(req, callback); + if (!user || blockPasswordChangeRequired(*user, callback)) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.updateArea(*user, areaId, visione::http::requireJson(req)), + std::move(callback)); +} + +void AdminController::updateTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long teamId) +{ + auto user = requireUser(req, callback); + if (!user || blockPasswordChangeRequired(*user, callback)) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.updateTeam(*user, teamId, visione::http::requireJson(req)), + std::move(callback)); +} + void AdminController::overviewStats( const drogon::HttpRequestPtr &req, std::function &&callback) diff --git a/controllers/AdminController.h b/controllers/AdminController.h index 396cbd5..98ccdda 100644 --- a/controllers/AdminController.h +++ b/controllers/AdminController.h @@ -6,16 +6,20 @@ class AdminController : public drogon::HttpController { public: METHOD_LIST_BEGIN - ADD_METHOD_TO(AdminController::listCategories, - "/api/v1/admin/categories", - drogon::Get); ADD_METHOD_TO(AdminController::createCategory, "/api/v1/admin/categories", drogon::Post); - ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get); ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post); - ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get); ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post); + ADD_METHOD_TO(AdminController::updateCategory, + "/api/v1/admin/categories/{1}", + drogon::Patch); + ADD_METHOD_TO(AdminController::updateArea, + "/api/v1/admin/areas/{1}", + drogon::Patch); + ADD_METHOD_TO(AdminController::updateTeam, + "/api/v1/admin/teams/{1}", + drogon::Patch); ADD_METHOD_TO(AdminController::overviewStats, "/api/v1/admin/stats/overview", drogon::Get); @@ -34,24 +38,27 @@ class AdminController : public drogon::HttpController drogon::Patch); METHOD_LIST_END - void listCategories( - const drogon::HttpRequestPtr &req, - std::function &&callback); void createCategory( const drogon::HttpRequestPtr &req, std::function &&callback); - void listAreas( - const drogon::HttpRequestPtr &req, - std::function &&callback); void createArea( const drogon::HttpRequestPtr &req, std::function &&callback); - void listTeams( - const drogon::HttpRequestPtr &req, - std::function &&callback); void createTeam( const drogon::HttpRequestPtr &req, std::function &&callback); + void updateCategory( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long categoryId); + void updateArea( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long areaId); + void updateTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long teamId); void overviewStats( const drogon::HttpRequestPtr &req, std::function &&callback); diff --git a/controllers/PublicController.cc b/controllers/PublicController.cc index 65b0478..714b31a 100644 --- a/controllers/PublicController.cc +++ b/controllers/PublicController.cc @@ -21,6 +21,14 @@ std::optional queryValue(const drogon::HttpRequestPtr &req, } } // namespace +void PublicController::listAreas( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + visione::services::ReportService service; + callback(visione::http::json(service.listAreas())); +} + void PublicController::openDataReports( const drogon::HttpRequestPtr &req, std::function &&callback) @@ -29,3 +37,15 @@ void PublicController::openDataReports( callback(visione::http::json(service.openDataReports(queryValue(req, "page"), queryValue(req, "per_page")))); } + +void PublicController::interventionSummary( + const drogon::HttpRequestPtr &, + std::function &&callback, + const std::string &token) +{ + visione::services::ReportService service; + auto body = service.publicInterventionSummary(token); + const auto status = body.get("ok", false).asBool() ? drogon::k200OK + : drogon::k404NotFound; + callback(visione::http::json(std::move(body), status)); +} diff --git a/controllers/PublicController.h b/controllers/PublicController.h index 1f27b86..295509f 100644 --- a/controllers/PublicController.h +++ b/controllers/PublicController.h @@ -6,12 +6,22 @@ class PublicController : public drogon::HttpController { public: METHOD_LIST_BEGIN + ADD_METHOD_TO(PublicController::listAreas, "/api/v1/areas", drogon::Get); ADD_METHOD_TO(PublicController::openDataReports, "/api/v1/open-data/reports", drogon::Get); + ADD_METHOD_TO(PublicController::interventionSummary, + "/api/v1/interventions/{1}", + drogon::Get); METHOD_LIST_END + void listAreas(const drogon::HttpRequestPtr &req, + std::function &&callback); void openDataReports( const drogon::HttpRequestPtr &req, std::function &&callback); + void interventionSummary( + const drogon::HttpRequestPtr &req, + std::function &&callback, + const std::string &token); }; diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 6d36e65..30cfad3 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -68,10 +68,17 @@ void sendService(Json::Value body, status = drogon::k404NotFound; } else if (error == "invalid_status_transition" || - error == "media_limit_reached") + error == "media_limit_reached" || + error == "report_not_open" || + error == "report_owned_by_other_operator") { status = drogon::k409Conflict; } + else if (error == "invalid_reference" || error.starts_with("invalid_") || + error == "missing_rating") + { + status = drogon::k422UnprocessableEntity; + } callback(visione::http::json(std::move(body), status)); } @@ -141,6 +148,32 @@ void ReportController::list( std::move(callback)); } +void ReportController::listCategories( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user || blockPasswordChangeRequired(*user, callback)) + { + return; + } + visione::services::ReportService service; + sendService(service.listCategories(*user), std::move(callback)); +} + +void ReportController::listTeams( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user || blockPasswordChangeRequired(*user, callback)) + { + return; + } + visione::services::ReportService service; + sendService(service.listOperatorTeams(*user), std::move(callback)); +} + void ReportController::assignedReports( const drogon::HttpRequestPtr &req, std::function &&callback) @@ -217,6 +250,48 @@ void ReportController::interventionSummary( sendService(service.interventionSummary(*user, reportId), std::move(callback)); } +void ReportController::teamSuggestions( + 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.teamSuggestions(*user, reportId), std::move(callback)); +} + +void ReportController::createInterventionLink( + 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.createInterventionLink(*user, reportId), std::move(callback)); +} + +void ReportController::revokeInterventionLink( + 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.revokeInterventionLink(*user, reportId), std::move(callback)); +} + void ReportController::updateStatus( const drogon::HttpRequestPtr &req, std::function &&callback, @@ -368,7 +443,7 @@ void ReportController::uploadMedia( auto allowed = service.canAddMedia(*user, reportId); if (!allowed.get("ok", false).asBool()) { - callback(visione::http::json(std::move(allowed), drogon::k403Forbidden)); + sendService(std::move(allowed), std::move(callback)); return; } diff --git a/controllers/ReportController.h b/controllers/ReportController.h index e324999..5c8b9bd 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -8,6 +8,12 @@ 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::listCategories, + "/api/v1/report-categories", + drogon::Get); + ADD_METHOD_TO(ReportController::listTeams, + "/api/v1/operator/teams", + drogon::Get); ADD_METHOD_TO(ReportController::assignedReports, "/api/v1/operator/reports/assigned", drogon::Get); @@ -21,6 +27,15 @@ class ReportController : public drogon::HttpController ADD_METHOD_TO(ReportController::interventionSummary, "/api/v1/reports/{1}/intervention-summary", drogon::Get); + ADD_METHOD_TO(ReportController::teamSuggestions, + "/api/v1/reports/{1}/team-suggestions", + drogon::Get); + ADD_METHOD_TO(ReportController::createInterventionLink, + "/api/v1/reports/{1}/intervention-link", + drogon::Post); + ADD_METHOD_TO(ReportController::revokeInterventionLink, + "/api/v1/reports/{1}/intervention-link", + drogon::Delete); ADD_METHOD_TO(ReportController::updateStatus, "/api/v1/reports/{1}/status", drogon::Patch); @@ -54,6 +69,11 @@ class ReportController : public drogon::HttpController std::function &&callback); void list(const drogon::HttpRequestPtr &req, std::function &&callback); + void listCategories( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void listTeams(const drogon::HttpRequestPtr &req, + std::function &&callback); void assignedReports( const drogon::HttpRequestPtr &req, std::function &&callback); @@ -70,6 +90,18 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void teamSuggestions( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void createInterventionLink( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void revokeInterventionLink( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); void updateStatus( const drogon::HttpRequestPtr &req, std::function &&callback, diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index b830f3b..3a2491a 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -26,12 +26,17 @@ Authorization: Bearer ## Reports - `POST /reports`: create a report. - `GET /reports`: list reports with optional `status`, `category_id`, `area_id`. +- `GET /report-categories`: list active report categories for authenticated users. +- `GET /operator/teams`: list active intervention teams for operators/admins. - `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. +- `GET /reports/{id}/team-suggestions`: suggest compatible teams by area/category. +- `POST /reports/{id}/intervention-link`: create or rotate public team link. +- `DELETE /reports/{id}/intervention-link`: revoke public team link. - `PATCH /reports/{id}/status`: update status. - `POST /reports/{id}/take`: assigned-queue operator takes an open report. - `POST /reports/{id}/messages`: write a report message. @@ -51,9 +56,12 @@ Authorization: Bearer - `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` +- `POST /admin/categories` +- `PATCH /admin/categories/{id}` +- `POST /admin/areas` +- `PATCH /admin/areas/{id}` +- `POST /admin/teams` +- `PATCH /admin/teams/{id}` - `GET /admin/stats/overview` - `GET /admin/moderation`: list moderation flags with optional `status`. - `PATCH /admin/moderation/{id}`: resolve or dismiss a moderation flag, optionally @@ -63,4 +71,6 @@ Authorization: Bearer - `PATCH /admin/users/{id}/ban` ## Public Data +- `GET /areas`: list active municipal areas. +- `GET /interventions/{token}`: public intervention summary for external teams. - `GET /open-data/reports`: anonymized resolved/closed report export. diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index 7ba860a..4c9537f 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -136,6 +136,26 @@ ], "type": "object" }, + "AssignmentLinkResponse": { + "additionalProperties": false, + "properties": { + "expires_at": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "ok", + "url", + "expires_at" + ], + "type": "object" + }, "Category": { "additionalProperties": true, "properties": { @@ -1037,7 +1057,22 @@ }, "type": "array" }, - "reports_by_team": { + "assignments_by_team": { + "items": { + "additionalProperties": true, + "properties": { + "team": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "resolved_reports_by_team": { "items": { "additionalProperties": true, "properties": { @@ -1059,7 +1094,8 @@ "reports_by_area", "avg_resolution_hours", "reports_by_category", - "reports_by_team", + "assignments_by_team", + "resolved_reports_by_team", "monthly_reports", "avg_resolution_hours_by_category", "avg_resolution_hours_by_area" @@ -1468,6 +1504,26 @@ ], "type": "object" }, + "TeamSuggestionListResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + }, + "suggestions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "ok", + "suggestions" + ], + "type": "object" + }, "TokenResponse": { "additionalProperties": false, "properties": { @@ -1609,35 +1665,6 @@ "openapi": "3.0.3", "paths": { "/api/v1/admin/areas": { - "get": { - "description": "Lists configured municipal areas.", - "operationId": "AdminController.listAreas", - "parameters": [ ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AreaListResponse" - } - } - }, - "description": "Area list." - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "security": [ - { - "bearerAuth": [ ] - } - ], - "summary": "List areas", - "tags": [ - "Admin" - ] - }, "post": { "description": "Creates a municipal area with GeoJSON boundary.", "operationId": "AdminController.createArea", @@ -1684,24 +1711,50 @@ ] } }, - "/api/v1/admin/categories": { - "get": { - "description": "Lists active report categories.", - "operationId": "AdminController.listCategories", - "parameters": [ ], + "/api/v1/admin/areas/{id}": { + "patch": { + "description": "Updates an existing municipal area.", + "operationId": "AdminController.updateArea", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAreaRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryListResponse" + "$ref": "#/components/schemas/OkResponse" } } }, - "description": "Report category list." + "description": "Updated area result." }, "401": { "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" } }, "security": [ @@ -1709,11 +1762,13 @@ "bearerAuth": [ ] } ], - "summary": "List categories", + "summary": "Update area", "tags": [ "Admin" ] - }, + } + }, + "/api/v1/admin/categories": { "post": { "description": "Creates a report category.", "operationId": "AdminController.createCategory", @@ -1760,6 +1815,63 @@ ] } }, + "/api/v1/admin/categories/{id}": { + "patch": { + "description": "Updates an existing report category.", + "operationId": "AdminController.updateCategory", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Updated category result." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "Update category", + "tags": [ + "Admin" + ] + } + }, "/api/v1/admin/moderation": { "get": { "description": "Lists moderation flags for admin/operator review.", @@ -1916,35 +2028,6 @@ } }, "/api/v1/admin/teams": { - "get": { - "description": "Lists intervention teams.", - "operationId": "AdminController.listTeams", - "parameters": [ ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TeamListResponse" - } - } - }, - "description": "Team list." - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "security": [ - { - "bearerAuth": [ ] - } - ], - "summary": "List teams", - "tags": [ - "Admin" - ] - }, "post": { "description": "Creates an intervention team.", "operationId": "AdminController.createTeam", @@ -1991,6 +2074,86 @@ ] } }, + "/api/v1/admin/teams/{id}": { + "patch": { + "description": "Updates an existing intervention team.", + "operationId": "AdminController.updateTeam", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Updated team result." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "Update team", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/areas": { + "get": { + "description": "Lists active municipal areas.", + "operationId": "PublicController.listAreas", + "parameters": [ ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AreaListResponse" + } + } + }, + "description": "Area list." + } + }, + "summary": "List areas", + "tags": [ + "Public" + ] + } + }, "/api/v1/admin/users": { "get": { "description": "Lists platform users with role information.", @@ -2697,6 +2860,41 @@ ] } }, + "/api/v1/interventions/{id}": { + "get": { + "description": "Returns public intervention summary for external intervention teams.", + "operationId": "PublicController.interventionSummary", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterventionSummaryResponse" + } + } + }, + "description": "Public intervention summary." + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "summary": "Public intervention summary", + "tags": [ + "Public" + ] + } + }, "/api/v1/operator/reports/assigned": { "get": { "description": "Lists reports assigned to the authenticated operator.", @@ -2850,6 +3048,40 @@ ] } }, + "/api/v1/operator/teams": { + "get": { + "description": "Lists active intervention teams for operators and admins.", + "operationId": "ReportController.listTeams", + "parameters": [ ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamListResponse" + } + } + }, + "description": "Team list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "List teams", + "tags": [ + "Reports" + ] + } + }, "/api/v1/ready": { "get": { "description": "Checks whether MySQL is reachable.", @@ -2986,6 +3218,37 @@ ] } }, + "/api/v1/report-categories": { + "get": { + "description": "Lists active report categories for authenticated users.", + "operationId": "ReportController.listCategories", + "parameters": [ ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryListResponse" + } + } + }, + "description": "Report category list." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "List categories", + "tags": [ + "Reports" + ] + } + }, "/api/v1/reports/{id}": { "get": { "description": "Returns report detail, status history and media.", @@ -3030,6 +3293,95 @@ ] } }, + "/api/v1/reports/{id}/intervention-link": { + "delete": { + "description": "Revokes current public intervention link for assigned team.", + "operationId": "ReportController.revokeInterventionLink", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + }, + "description": "Link revocation result." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "Revoke intervention link", + "tags": [ + "Reports" + ] + }, + "post": { + "description": "Creates or rotates public intervention link for external intervention team.", + "operationId": "ReportController.createInterventionLink", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignmentLinkResponse" + } + } + }, + "description": "Generated public link." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "Create intervention link", + "tags": [ + "Reports" + ] + } + }, "/api/v1/reports/{id}/assignments": { "post": { "description": "Assigns an intervention team to a report.", @@ -3245,6 +3597,53 @@ ] } }, + "/api/v1/reports/{id}/team-suggestions": { + "get": { + "description": "Suggests compatible teams for report category and area.", + "operationId": "ReportController.teamSuggestions", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamSuggestionListResponse" + } + } + }, + "description": "Suggested teams." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "summary": "Team suggestions", + "tags": [ + "Reports" + ] + } + }, "/api/v1/reports/{id}/media": { "post": { "description": "Uploads report images or videos as multipart files.", diff --git a/migrations/007_reference_data_and_intervention_links.sql b/migrations/007_reference_data_and_intervention_links.sql new file mode 100644 index 0000000..691992e --- /dev/null +++ b/migrations/007_reference_data_and_intervention_links.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS report_category_team_categories ( + report_category_id BIGINT UNSIGNED NOT NULL, + team_category_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (report_category_id, team_category_id), + CONSTRAINT report_category_team_categories_report_fk + FOREIGN KEY (report_category_id) REFERENCES report_categories(id), + CONSTRAINT report_category_team_categories_team_fk + FOREIGN KEY (team_category_id) REFERENCES team_categories(id) +); + +CREATE TABLE IF NOT EXISTS intervention_access_links ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + assignment_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(255) NOT NULL, + created_by_user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + revoked_at DATETIME NULL, + last_accessed_at DATETIME NULL, + UNIQUE KEY intervention_access_links_token_hash_unique (token_hash), + KEY intervention_access_links_report_assignment_idx (report_id, assignment_id), + CONSTRAINT intervention_access_links_report_fk + FOREIGN KEY (report_id) REFERENCES reports(id), + CONSTRAINT intervention_access_links_assignment_fk + FOREIGN KEY (assignment_id) REFERENCES assignments(id), + CONSTRAINT intervention_access_links_user_fk + FOREIGN KEY (created_by_user_id) REFERENCES users(id) +); + +INSERT IGNORE INTO report_category_team_categories (report_category_id, team_category_id) +SELECT rc.id, tc.id +FROM report_categories rc +JOIN team_categories tc ON + (rc.name = 'Buche stradali' AND tc.name = 'Manutenzione strade') OR + (rc.name = 'Illuminazione guasta' AND tc.name = 'Illuminazione pubblica') OR + (rc.name = 'Rifiuti abbandonati' AND tc.name = 'Igiene urbana'); diff --git a/services/AdminService.cc b/services/AdminService.cc index 24cc6af..ec3c6da 100644 --- a/services/AdminService.cc +++ b/services/AdminService.cc @@ -82,24 +82,24 @@ const JsonRowSchema &adminUserSchema() {"must_change_password", JsonFieldType::Bool}}; return schema; } -} // namespace -Json::Value AdminService::listCategories() +bool isActiveField(const Json::Value &body) { - auto response = success(); - response["categories"] = repositories::resultToJson( - drogon::app().getDbClient()->execSqlSync( - "SELECT * FROM report_categories ORDER BY name")); - return response; + return body.isMember("active") && body["active"].isBool(); } +} // namespace Json::Value AdminService::createCategory(const auth::UserContext &user, const Json::Value &body) { - if (!auth::isAdmin(user) || !validation::hasString(body, "name")) + if (!auth::isAdmin(user)) { return failure("forbidden"); } + if (!validation::hasString(body, "name")) + { + return failure("invalid_category"); + } drogon::app().getDbClient()->execSqlSync( "INSERT INTO report_categories (name, description) VALUES (?, ?)", @@ -108,19 +108,14 @@ Json::Value AdminService::createCategory(const auth::UserContext &user, return success(); } -Json::Value AdminService::listAreas() -{ - auto response = success(); - response["areas"] = repositories::resultToJson( - drogon::app().getDbClient()->execSqlSync("SELECT * FROM areas ORDER BY name")); - return response; -} - Json::Value AdminService::createArea(const auth::UserContext &user, const Json::Value &body) { - if (!auth::isAdmin(user) || !validation::hasString(body, "name") || - !body["boundary_geojson"].isObject()) + if (!auth::isAdmin(user)) + { + return failure("forbidden"); + } + if (!validation::hasString(body, "name") || !body["boundary_geojson"].isObject()) { return failure("invalid_area"); } @@ -135,29 +130,38 @@ Json::Value AdminService::createArea(const auth::UserContext &user, return success(); } -Json::Value AdminService::listTeams() -{ - auto response = success(); - response["teams"] = repositories::resultToJson( - drogon::app().getDbClient()->execSqlSync( - "SELECT t.*, tc.name AS category_name, a.name AS area_name " - "FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id " - "LEFT JOIN areas a ON a.id = t.area_id ORDER BY t.name")); - return response; -} - Json::Value AdminService::createTeam(const auth::UserContext &user, const Json::Value &body) { - if (!auth::isAdmin(user) || !validation::hasString(body, "name") || + if (!auth::isAdmin(user)) + { + return failure("forbidden"); + } + if (!validation::hasString(body, "name") || !validation::hasNumber(body, "team_category_id")) { return failure("invalid_team"); } + auto db = drogon::app().getDbClient(); + const auto teamCategoryRows = db->execSqlSync( + "SELECT id FROM team_categories WHERE id = ?", + body["team_category_id"].asInt64()); + if (teamCategoryRows.empty()) + { + return failure("invalid_reference"); + } + if (body["area_id"].isNumeric()) { - drogon::app().getDbClient()->execSqlSync( + const auto areaRows = db->execSqlSync( + "SELECT id FROM areas WHERE id = ?", + body["area_id"].asInt64()); + if (areaRows.empty()) + { + return failure("invalid_reference"); + } + db->execSqlSync( "INSERT INTO teams (team_category_id, area_id, name, description) " "VALUES (?, ?, ?, ?)", body["team_category_id"].asInt64(), @@ -167,7 +171,7 @@ Json::Value AdminService::createTeam(const auth::UserContext &user, } else { - drogon::app().getDbClient()->execSqlSync( + db->execSqlSync( "INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)", body["team_category_id"].asInt64(), body["name"].asString(), @@ -198,10 +202,21 @@ Json::Value AdminService::overviewStats(const auth::UserContext &user) "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 " + response["assignments_by_team"] = repositories::resultToJson(db->execSqlSync( + "SELECT t.name AS team, COUNT(DISTINCT ass.id) AS total " "FROM assignments ass JOIN teams t ON t.id = ass.team_id " "GROUP BY t.name ORDER BY total DESC")); + response["resolved_reports_by_team"] = repositories::resultToJson(db->execSqlSync( + "SELECT t.name AS team, COUNT(DISTINCT ass.report_id) AS total " + "FROM assignments ass " + "JOIN teams t ON t.id = ass.team_id " + "JOIN reports r ON r.id = ass.report_id " + "WHERE r.status IN ('RESOLVED', 'CLOSED') " + "AND NOT EXISTS (" + " SELECT 1 FROM assignments ass2 " + " WHERE ass2.report_id = ass.report_id AND ass2.assigned_at > ass.assigned_at" + ") " + "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') " @@ -229,7 +244,7 @@ Json::Value AdminService::listModeration( const std::optional &page, const std::optional &perPage) { - if (!auth::canManageReports(user)) + if (!auth::isAdmin(user)) { return failure("forbidden"); } @@ -262,7 +277,7 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user, long long flagId, const Json::Value &body) { - if (!auth::canManageReports(user)) + if (!auth::isAdmin(user)) { return failure("forbidden"); } @@ -324,6 +339,158 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user, return success(); } +Json::Value AdminService::updateCategory(const auth::UserContext &user, + long long categoryId, + const Json::Value &body) +{ + if (!auth::isAdmin(user)) + { + return failure("forbidden"); + } + auto db = drogon::app().getDbClient(); + const auto rows = db->execSqlSync( + "SELECT id FROM report_categories WHERE id = ?", + categoryId); + if (rows.empty()) + { + return failure("category_not_found"); + } + if (validation::hasString(body, "name")) + { + db->execSqlSync("UPDATE report_categories SET name = ? WHERE id = ?", + body["name"].asString(), + categoryId); + } + if (body.isMember("description") && body["description"].isString()) + { + db->execSqlSync("UPDATE report_categories SET description = ? WHERE id = ?", + body["description"].asString(), + categoryId); + } + if (isActiveField(body)) + { + db->execSqlSync("UPDATE report_categories SET active = ? WHERE id = ?", + body["active"].asBool(), + categoryId); + } + return success(); +} + +Json::Value AdminService::updateArea(const auth::UserContext &user, + long long areaId, + const Json::Value &body) +{ + if (!auth::isAdmin(user)) + { + return failure("forbidden"); + } + auto db = drogon::app().getDbClient(); + const auto rows = db->execSqlSync("SELECT id FROM areas WHERE id = ?", areaId); + if (rows.empty()) + { + return failure("area_not_found"); + } + if (validation::hasString(body, "name")) + { + db->execSqlSync("UPDATE areas SET name = ? WHERE id = ?", + body["name"].asString(), + areaId); + } + if (body.isMember("description") && body["description"].isString()) + { + db->execSqlSync("UPDATE areas SET description = ? WHERE id = ?", + body["description"].asString(), + areaId); + } + if (isActiveField(body)) + { + db->execSqlSync("UPDATE areas SET active = ? WHERE id = ?", + body["active"].asBool(), + areaId); + } + if (body["boundary_geojson"].isObject()) + { + Json::StreamWriterBuilder writer; + db->execSqlSync( + "UPDATE areas SET boundary_geojson = CAST(? AS JSON) WHERE id = ?", + Json::writeString(writer, body["boundary_geojson"]), + areaId); + } + return success(); +} + +Json::Value AdminService::updateTeam(const auth::UserContext &user, + long long teamId, + const Json::Value &body) +{ + if (!auth::isAdmin(user)) + { + return failure("forbidden"); + } + auto db = drogon::app().getDbClient(); + const auto rows = db->execSqlSync("SELECT id FROM teams WHERE id = ?", teamId); + if (rows.empty()) + { + return failure("team_not_found"); + } + if (validation::hasString(body, "name")) + { + db->execSqlSync("UPDATE teams SET name = ? WHERE id = ?", + body["name"].asString(), + teamId); + } + if (body.isMember("description") && body["description"].isString()) + { + db->execSqlSync("UPDATE teams SET description = ? WHERE id = ?", + body["description"].asString(), + teamId); + } + if (validation::hasNumber(body, "team_category_id")) + { + const auto categoryRows = db->execSqlSync( + "SELECT id FROM team_categories WHERE id = ?", + body["team_category_id"].asInt64()); + if (categoryRows.empty()) + { + return failure("invalid_reference"); + } + db->execSqlSync("UPDATE teams SET team_category_id = ? WHERE id = ?", + body["team_category_id"].asInt64(), + teamId); + } + if (body.isMember("area_id")) + { + if (body["area_id"].isNull()) + { + db->execSqlSync("UPDATE teams SET area_id = NULL WHERE id = ?", teamId); + } + else if (body["area_id"].isNumeric()) + { + const auto areaRows = + db->execSqlSync("SELECT id FROM areas WHERE id = ?", + body["area_id"].asInt64()); + if (areaRows.empty()) + { + return failure("invalid_reference"); + } + db->execSqlSync("UPDATE teams SET area_id = ? WHERE id = ?", + body["area_id"].asInt64(), + teamId); + } + else + { + return failure("invalid_reference"); + } + } + if (isActiveField(body)) + { + db->execSqlSync("UPDATE teams SET active = ? WHERE id = ?", + body["active"].asBool(), + teamId); + } + 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 58c111a..26f995a 100644 --- a/services/AdminService.h +++ b/services/AdminService.h @@ -12,11 +12,8 @@ namespace visione::services class AdminService { public: - Json::Value listCategories(); Json::Value createCategory(const auth::UserContext &user, const Json::Value &body); - Json::Value listAreas(); Json::Value createArea(const auth::UserContext &user, const Json::Value &body); - Json::Value listTeams(); Json::Value createTeam(const auth::UserContext &user, const Json::Value &body); Json::Value overviewStats(const auth::UserContext &user); Json::Value listUsers(const auth::UserContext &user); @@ -27,6 +24,15 @@ class AdminService Json::Value updateModeration(const auth::UserContext &user, long long flagId, const Json::Value &body); + Json::Value updateCategory(const auth::UserContext &user, + long long categoryId, + const Json::Value &body); + Json::Value updateArea(const auth::UserContext &user, + long long areaId, + const Json::Value &body); + Json::Value updateTeam(const auth::UserContext &user, + long long teamId, + 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 490481b..2f93580 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -1,5 +1,6 @@ #include "services/ReportService.h" +#include "common/Crypto.h" #include "common/GeoJson.h" #include "common/Validation.h" #include "repositories/Sql.h" @@ -11,6 +12,7 @@ #include #include #include +#include namespace visione::services { @@ -34,6 +36,14 @@ Json::Value success() return body; } +Json::Value successWithLink(const std::string &url, const std::string &expiresAt) +{ + auto body = success(); + body["url"] = url; + body["expires_at"] = expiresAt; + return body; +} + bool isAllowedStatus(const std::string &status) { static const std::set statuses = { @@ -56,6 +66,15 @@ bool canTransition(const std::string &from, const std::string &to) return it != transitions.end() && it->second.contains(to); } +std::string maskedLastName(const std::string &lastName) +{ + if (lastName.empty()) + { + return {}; + } + return std::string(1, static_cast(std::toupper(lastName.front()))) + "."; +} + std::optional findArea(double latitude, double longitude) { const auto result = drogon::app().getDbClient()->execSqlSync( @@ -109,6 +128,27 @@ bool canModifyReport(const auth::UserContext &user, long long reportId) return !result.empty(); } +bool canOwnOrModifyReport(const auth::UserContext &user, long long reportId) +{ + if (auth::isAdmin(user)) + { + return true; + } + if (!auth::canOperateReports(user)) + { + return false; + } + const auto result = drogon::app().getDbClient()->execSqlSync( + "SELECT assigned_operator_id FROM reports WHERE id = ?", + reportId); + if (result.empty()) + { + return false; + } + return result[0]["assigned_operator_id"].isNull() || + result[0]["assigned_operator_id"].as() == user.id; +} + bool canFlagReportContent(const auth::UserContext &user, long long reportId) { if (!auth::canOperateReports(user)) @@ -260,6 +300,27 @@ std::optional assignedOperator(long long reportId) return rows[0]["assigned_operator_id"].as(); } +std::optional activeAssignmentId(long long reportId) +{ + const auto rows = drogon::app().getDbClient()->execSqlSync( + "SELECT id FROM assignments WHERE report_id = ? AND ended_at IS NULL " + "ORDER BY assigned_at DESC LIMIT 1", + reportId); + if (rows.empty()) + { + return std::nullopt; + } + return rows[0]["id"].as(); +} + +void revokeActiveLinksForReport(long long reportId) +{ + drogon::app().getDbClient()->execSqlSync( + "UPDATE intervention_access_links SET revoked_at = NOW() " + "WHERE report_id = ? AND revoked_at IS NULL AND expires_at > NOW()", + reportId); +} + void notifyReportOwner(long long reportId, const std::string &type, const std::string &title, @@ -271,6 +332,50 @@ void notifyReportOwner(long long reportId, UserService::createNotification(*owner, type, title, body, reportId); } } + +Json::Value buildInterventionSummaryRow(const drogon::orm::Row &row, + const drogon::orm::Result &mediaRows, + const drogon::orm::Result &messageRows, + bool publicView) +{ + auto response = success(); + Json::Value summary = repositories::rowToJson(row, reportSchema()); + summary["category_name"] = row["category_name"].as(); + if (!row["area_name"].isNull()) + { + summary["area_name"] = row["area_name"].as(); + } + if (!row["assigned_team_name"].isNull()) + { + summary["assigned_team_name"] = row["assigned_team_name"].as(); + } + if (publicView) + { + summary["citizen_name"] = row["first_name"].as() + " " + + maskedLastName(row["last_name"].as()); + if (!row["phone"].isNull()) + { + summary["phone"] = row["phone"].as(); + } + response["summary"] = std::move(summary); + response["media"] = repositories::resultToJson(mediaRows, reportMediaSchema()); + response["latest_messages"] = + repositories::resultToJson(messageRows, messageSchema()); + return response; + } + summary["first_name"] = row["first_name"].as(); + summary["last_name"] = row["last_name"].as(); + if (!row["phone"].isNull()) + { + summary["phone"] = row["phone"].as(); + } + summary["email"] = row["email"].as(); + response["summary"] = std::move(summary); + response["media"] = repositories::resultToJson(mediaRows, reportMediaSchema()); + response["latest_messages"] = + repositories::resultToJson(messageRows, messageSchema()); + return response; +} } // namespace Json::Value ReportService::createReport(const auth::UserContext &user, @@ -493,6 +598,44 @@ Json::Value ReportService::otherOperatorReports( return pagedReportResponse(result, pageValue, perPageValue); } +Json::Value ReportService::listCategories(const auth::UserContext &user) +{ + if (user.id == 0) + { + return failure("forbidden"); + } + auto response = success(); + response["categories"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT * FROM report_categories WHERE active = TRUE ORDER BY name")); + return response; +} + +Json::Value ReportService::listAreas() +{ + auto response = success(); + response["areas"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT * FROM areas WHERE active = TRUE ORDER BY name")); + return response; +} + +Json::Value ReportService::listOperatorTeams(const auth::UserContext &user) +{ + if (!auth::canManageReports(user)) + { + return failure("forbidden"); + } + auto response = success(); + response["teams"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT t.*, tc.name AS category_name, a.name AS area_name " + "FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id " + "LEFT JOIN areas a ON a.id = t.area_id " + "WHERE t.active = TRUE ORDER BY t.name")); + return response; +} + Json::Value ReportService::reportDetail(const auth::UserContext &user, long long reportId) { @@ -549,19 +692,64 @@ Json::Value ReportService::interventionSummary(const auth::UserContext &user, { return failure("report_not_found"); } - auto response = success(); - response["summary"] = repositories::rowToJson(report[0], reportSchema()); - response["media"] = repositories::resultToJson(db->execSqlSync( + return buildInterventionSummaryRow(report[0], + db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", reportId), - reportMediaSchema()); - response["latest_messages"] = repositories::resultToJson(db->execSqlSync( + 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), - messageSchema()); - return response; + false); +} + +Json::Value ReportService::publicInterventionSummary(const std::string &token) +{ + auto db = drogon::app().getDbClient(); + const auto rows = db->execSqlSync( + "SELECT l.report_id " + "FROM intervention_access_links l " + "WHERE l.token_hash = ? AND l.revoked_at IS NULL AND l.expires_at > NOW() " + "LIMIT 1", + crypto::hashSecret(token)); + if (rows.empty()) + { + return failure("intervention_link_not_found"); + } + const auto reportId = rows[0]["report_id"].as(); + 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, " + "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("intervention_link_not_found"); + } + db->execSqlSync( + "UPDATE intervention_access_links SET last_accessed_at = NOW() " + "WHERE token_hash = ?", + crypto::hashSecret(token)); + return buildInterventionSummaryRow(report[0], + db->execSqlSync( + "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", + reportId), + 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 = ? AND u.id <> ? " + "ORDER BY m.created_at DESC LIMIT 5", + reportId, + report[0]["user_id"].as()), + true); } Json::Value ReportService::updateStatus(const auth::UserContext &user, @@ -725,31 +913,57 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user, long long reportId, const Json::Value &body) { - if (!canModifyReport(user, reportId) || !validation::hasNumber(body, "team_id")) + if (!canOwnOrModifyReport(user, reportId)) { return failure("forbidden"); } + if (!validation::hasNumber(body, "team_id")) + { + return failure("invalid_team"); + } auto db = drogon::app().getDbClient(); const auto current = db->execSqlSync( - "SELECT status FROM reports WHERE id = ?", + "SELECT status, assigned_operator_id FROM reports WHERE id = ?", reportId); if (current.empty()) { return failure("report_not_found"); } + if (!auth::isAdmin(user) && !current[0]["assigned_operator_id"].isNull() && + current[0]["assigned_operator_id"].as() != user.id) + { + return failure("report_owned_by_other_operator"); + } const auto oldStatus = current[0]["status"].as(); - if (!canTransition(oldStatus, "ASSIGNED")) + if (oldStatus != "OPEN" && !canTransition(oldStatus, "ASSIGNED")) { return failure("invalid_status_transition"); } const auto team = db->execSqlSync( - "SELECT id FROM teams WHERE id = ? AND active = TRUE", + "SELECT id, active FROM teams WHERE id = ?", body["team_id"].asInt64()); if (team.empty()) { return failure("team_not_found"); } + if (!team[0]["active"].as()) + { + return failure("invalid_reference"); + } + + if (oldStatus == "OPEN") + { + db->execSqlSync( + "UPDATE reports SET assigned_operator_id = ?, status = 'ASSIGNED' WHERE id = ?", + user.id, + reportId); + } + else + { + db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?", + reportId); + } db->execSqlSync( "UPDATE assignments SET ended_at = NOW() " @@ -761,8 +975,6 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user, reportId, body["team_id"].asInt64(), user.id); - db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?", - reportId); db->execSqlSync( "INSERT INTO report_status_history " "(report_id, old_status, new_status, changed_by_user_id, note) " @@ -770,6 +982,7 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user, reportId, oldStatus, user.id); + revokeActiveLinksForReport(reportId); notifyReportOwner(reportId, "team_assigned", "Team assigned", @@ -895,6 +1108,92 @@ Json::Value ReportService::flagMessage(const auth::UserContext &user, return response; } +Json::Value ReportService::teamSuggestions(const auth::UserContext &user, + long long reportId) +{ + if (!auth::canManageReports(user)) + { + return failure("forbidden"); + } + auto db = drogon::app().getDbClient(); + const auto reportRows = db->execSqlSync( + "SELECT id, category_id, area_id FROM reports WHERE id = ?", + reportId); + if (reportRows.empty()) + { + return failure("report_not_found"); + } + const auto result = db->execSqlSync( + "SELECT t.*, tc.name AS category_name, a.name AS area_name, " + "CASE " + " WHEN t.area_id = r.area_id THEN 'area_and_category' " + " WHEN t.area_id IS NULL THEN 'category_only' " + " ELSE 'fallback_global' " + "END AS match_reason, " + "CASE WHEN t.area_id = r.area_id THEN 0 " + " WHEN t.area_id IS NULL THEN 1 " + " ELSE 2 END AS rank_value " + "FROM reports r " + "JOIN report_category_team_categories map ON map.report_category_id = r.category_id " + "JOIN teams t ON t.team_category_id = map.team_category_id AND t.active = TRUE " + "JOIN team_categories tc ON tc.id = t.team_category_id " + "LEFT JOIN areas a ON a.id = t.area_id " + "WHERE r.id = ? AND (t.area_id = r.area_id OR t.area_id IS NULL) " + "ORDER BY rank_value, t.name", + reportId); + + auto response = success(); + response["suggestions"] = Json::arrayValue; + for (size_t i = 0; i < result.size(); ++i) + { + auto item = repositories::rowToJson(result[i]); + item["is_recommended"] = (i == 0); + response["suggestions"].append(std::move(item)); + } + return response; +} + +Json::Value ReportService::createInterventionLink(const auth::UserContext &user, + long long reportId) +{ + if (!canModifyReport(user, reportId)) + { + return failure("forbidden"); + } + const auto assignmentId = activeAssignmentId(reportId); + if (!assignmentId) + { + return failure("invalid_status_transition"); + } + auto token = crypto::randomToken(); + auto db = drogon::app().getDbClient(); + revokeActiveLinksForReport(reportId); + db->execSqlSync( + "INSERT INTO intervention_access_links " + "(report_id, assignment_id, token_hash, created_by_user_id, expires_at) " + "VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))", + reportId, + *assignmentId, + crypto::hashSecret(token), + user.id); + const auto expiresRows = db->execSqlSync( + "SELECT expires_at FROM intervention_access_links WHERE token_hash = ?", + crypto::hashSecret(token)); + return successWithLink("/api/v1/interventions/" + token, + expiresRows[0]["expires_at"].as()); +} + +Json::Value ReportService::revokeInterventionLink(const auth::UserContext &user, + long long reportId) +{ + if (!canModifyReport(user, reportId)) + { + return failure("forbidden"); + } + revokeActiveLinksForReport(reportId); + return success(); +} + Json::Value ReportService::openDataReports(const std::optional &page, const std::optional &perPage) { diff --git a/services/ReportService.h b/services/ReportService.h index c738756..7b290fd 100644 --- a/services/ReportService.h +++ b/services/ReportService.h @@ -19,14 +19,18 @@ class ReportService Json::Value assignedReports(const auth::UserContext &user, const std::optional &page, const std::optional &perPage); + Json::Value listAreas(); 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 listCategories(const auth::UserContext &user); + Json::Value listOperatorTeams(const auth::UserContext &user); Json::Value reportDetail(const auth::UserContext &user, long long reportId); Json::Value interventionSummary(const auth::UserContext &user, long long reportId); + Json::Value publicInterventionSummary(const std::string &token); Json::Value updateStatus(const auth::UserContext &user, long long reportId, const Json::Value &body); @@ -48,6 +52,9 @@ class ReportService long long reportId, long long messageId, const Json::Value &body); + Json::Value teamSuggestions(const auth::UserContext &user, long long reportId); + Json::Value createInterventionLink(const auth::UserContext &user, long long reportId); + Json::Value revokeInterventionLink(const auth::UserContext &user, long long reportId); Json::Value openDataReports(const std::optional &page, const std::optional &perPage); Json::Value canAddMedia(const auth::UserContext &user, long long reportId);