From bff22ea83a82ba37ea42b8e417902755bea876c7 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Tue, 2 Jun 2026 20:04:59 +0200 Subject: [PATCH] Add report workflow --- controllers/ReportController.cc | 69 +++++++++- controllers/ReportController.h | 7 + docs/API_SPEC.md | 3 +- docs/DATABASE_SCHEMA.md | 5 +- docs/MILESTONE_02.md | 19 +++ docs/PROJECT_SPEC.md | 10 ++ migrations/004_report_workflow.sql | 13 ++ services/ReportService.cc | 214 +++++++++++++++++++++++------ services/ReportService.h | 7 +- 9 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 docs/MILESTONE_02.md create mode 100644 migrations/004_report_workflow.sql diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 8fcf3f5..7288acb 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -62,6 +62,28 @@ void sendService(Json::Value body, : drogon::k400BadRequest; callback(visione::http::json(std::move(body), status)); } + +bool isAllowedImage(const drogon::HttpFile &file) +{ + const auto type = file.getContentType(); + return type == drogon::CT_IMAGE_JPG || type == drogon::CT_IMAGE_PNG || + type == drogon::CT_IMAGE_WEBP; +} + +std::string imageContentType(const drogon::HttpFile &file) +{ + switch (file.getContentType()) + { + case drogon::CT_IMAGE_JPG: + return "image/jpeg"; + case drogon::CT_IMAGE_PNG: + return "image/png"; + case drogon::CT_IMAGE_WEBP: + return "image/webp"; + default: + return "application/octet-stream"; + } +} } // namespace void ReportController::create( @@ -131,6 +153,20 @@ void ReportController::updateStatus( sendService(service.updateStatus(*user, reportId, body), std::move(callback)); } +void ReportController::takeReport( + 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.takeReport(*user, reportId), std::move(callback)); +} + void ReportController::addMessage( const drogon::HttpRequestPtr &req, std::function &&callback, @@ -216,9 +252,33 @@ void ReportController::uploadPhotos( const auto reportDir = std::filesystem::path(reportsPath) / std::to_string(reportId); - std::filesystem::create_directories(reportDir); visione::services::ReportService service; + auto allowed = service.canAddPhoto(*user, reportId); + if (!allowed.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(allowed), drogon::k403Forbidden)); + return; + } + + if (parser.getFiles().size() > + static_cast(allowed.get("remaining_photo_slots", 0).asInt())) + { + callback(visione::http::error("too_many_photos", drogon::k400BadRequest)); + return; + } + for (const auto &file : parser.getFiles()) + { + if (!isAllowedImage(file)) + { + callback(visione::http::error("unsupported_photo_type", + drogon::k400BadRequest)); + return; + } + } + + std::filesystem::create_directories(reportDir); + Json::Value response; response["ok"] = true; response["photos"] = Json::arrayValue; @@ -241,7 +301,12 @@ void ReportController::uploadPhotos( const auto url = publicBasePath + "/reports/" + std::to_string(reportId) + "/" + fileName; - auto saved = service.addPhoto(*user, reportId, url); + auto saved = service.addPhoto(*user, + reportId, + url, + file.getFileName(), + imageContentType(file), + file.fileLength()); if (!saved.get("ok", false).asBool()) { callback(visione::http::json(std::move(saved), drogon::k403Forbidden)); diff --git a/controllers/ReportController.h b/controllers/ReportController.h index 9d54ab1..24ee23c 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -12,6 +12,9 @@ class ReportController : public drogon::HttpController ADD_METHOD_TO(ReportController::updateStatus, "/api/v1/reports/{1}/status", drogon::Patch); + ADD_METHOD_TO(ReportController::takeReport, + "/api/v1/reports/{1}/take", + drogon::Post); ADD_METHOD_TO(ReportController::addMessage, "/api/v1/reports/{1}/messages", drogon::Post); @@ -40,6 +43,10 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void takeReport( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); void addMessage( const drogon::HttpRequestPtr &req, std::function &&callback, diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 30e70de..b9c33ee 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -25,11 +25,12 @@ Authorization: Bearer - `GET /reports`: list reports with optional `status`, `category_id`, `area_id`. - `GET /reports/{id}`: report detail. - `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}/assignments`: assign a team. - `POST /reports/{id}/feedback`: add citizen feedback. -- `POST /reports/{id}/photos`: upload one or more multipart files for a report. +- `POST /reports/{id}/photos`: upload up to five JPEG/PNG/WebP multipart files. ## Admin Reference Data - `GET|POST /admin/categories` diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index fd7ca52..54d8b14 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -11,14 +11,13 @@ Important tables: - `report_categories`: report classification. - `areas`: named city areas with GeoJSON polygon boundaries. - `team_categories`, `teams`: municipal intervention teams. -- `reports`: citizen reports with coordinates, address, status and area. +- `reports`: citizen reports with coordinates, address, status, area and assigned operator. - `report_status_history`: immutable status transition log. - `assignments`: report-to-team assignment history. -- `report_photos`: uploaded photo metadata. +- `report_photos`: uploaded photo URL, original filename, content type and size. - `messages`: citizen/operator report conversation. - `feedback`: citizen rating and comment after resolution. - `reward_events`: indirect gamification points ledger. GeoJSON is intentionally used instead of MySQL Spatial in milestone 1 to keep the local setup simple and portable. - diff --git a/docs/MILESTONE_02.md b/docs/MILESTONE_02.md new file mode 100644 index 0000000..ef88cab --- /dev/null +++ b/docs/MILESTONE_02.md @@ -0,0 +1,19 @@ +# Milestone 02: Workflow Core and Hardening + +## Implemented in this milestone +- Documentation/config fixes from Milestone 01 review. +- Authorization context with roles, permissions and forced password-change state. +- Authenticated password change endpoint. +- Admin user listing, role assignment and ban/unban endpoints. +- Report ownership workflow with assigned operator and take endpoint. +- Valid report status transitions. +- Citizen report filters scoped to own reports. +- Report photo upload validation and metadata storage. + +## To do next +- Add broader API tests once the frontend contracts stabilize. +- Add notification delivery for status changes. +- Add moderation flow for abusive reports/messages. +- Add open-data exports and statistics refinements. +- Design CIE/SPID integration. + diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 344d560..47c3432 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -23,6 +23,16 @@ or administrative APIs. - `REJECTED`: report was invalid or abusive. - `CLOSED`: administrative closure after feedback or final review. +Valid milestone 2 transitions: + +- `OPEN -> UNDER_REVIEW` when an operator/admin takes the report. +- `UNDER_REVIEW -> ASSIGNED` when a team is assigned. +- `ASSIGNED -> IN_PROGRESS`. +- `IN_PROGRESS -> RESOLVED`. +- `RESOLVED -> CLOSED`. +- `OPEN/UNDER_REVIEW/ASSIGNED/IN_PROGRESS -> REJECTED` only for invalid reports. +- `REJECTED -> CLOSED`. + ## MVP Scope - Local auth with email OTP delivered through MailHog. - MySQL persistence with migrations and seed data. diff --git a/migrations/004_report_workflow.sql b/migrations/004_report_workflow.sql new file mode 100644 index 0000000..4073a84 --- /dev/null +++ b/migrations/004_report_workflow.sql @@ -0,0 +1,13 @@ +ALTER TABLE reports + ADD COLUMN assigned_operator_id BIGINT UNSIGNED NULL AFTER area_id, + ADD CONSTRAINT reports_assigned_operator_fk + FOREIGN KEY (assigned_operator_id) REFERENCES users(id); + +CREATE INDEX reports_user_status_created_idx ON reports(user_id, status, created_at); +CREATE INDEX assignments_report_ended_idx ON assignments(report_id, ended_at); + +ALTER TABLE report_photos + ADD COLUMN original_filename VARCHAR(255) NULL AFTER url, + ADD COLUMN content_type VARCHAR(100) NULL AFTER original_filename, + ADD COLUMN size_bytes BIGINT UNSIGNED NULL AFTER content_type; + diff --git a/services/ReportService.cc b/services/ReportService.cc index 264a4ea..9becad7 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -6,7 +6,9 @@ #include +#include #include +#include namespace visione::services { @@ -35,6 +37,20 @@ bool isAllowedStatus(const std::string &status) return statuses.contains(status); } +bool canTransition(const std::string &from, const std::string &to) +{ + static const std::map> transitions = { + {"OPEN", {"UNDER_REVIEW", "REJECTED"}}, + {"UNDER_REVIEW", {"ASSIGNED", "REJECTED"}}, + {"ASSIGNED", {"IN_PROGRESS", "REJECTED"}}, + {"IN_PROGRESS", {"RESOLVED", "REJECTED"}}, + {"RESOLVED", {"CLOSED"}}, + {"REJECTED", {"CLOSED"}}, + {"CLOSED", {}}}; + const auto it = transitions.find(from); + return it != transitions.end() && it->second.contains(to); +} + std::optional findArea(double latitude, double longitude) { const auto result = drogon::app().getDbClient()->execSqlSync( @@ -69,6 +85,24 @@ bool canAccessReport(const auth::UserContext &user, long long reportId) user.id); return !result.empty(); } + +bool canModifyReport(const auth::UserContext &user, long long reportId) +{ + if (auth::isAdmin(user)) + { + return true; + } + if (!auth::canManageReports(user)) + { + return false; + } + + const auto result = drogon::app().getDbClient()->execSqlSync( + "SELECT id FROM reports WHERE id = ? AND assigned_operator_id = ?", + reportId, + user.id); + return !result.empty(); +} } // namespace Json::Value ReportService::createReport(const auth::UserContext &user, @@ -88,6 +122,14 @@ Json::Value ReportService::createReport(const auth::UserContext &user, const auto areaId = findArea(latitude, longitude); auto db = drogon::app().getDbClient(); + const auto category = db->execSqlSync( + "SELECT id FROM report_categories WHERE id = ? AND active = TRUE", + body["category_id"].asInt64()); + if (category.empty()) + { + return failure("category_not_found"); + } + if (areaId) { db->execSqlSync( @@ -154,42 +196,27 @@ Json::Value ReportService::listReports(const auth::UserContext &user, const std::optional &areaId) { auto db = drogon::app().getDbClient(); + const auto statusParam = status.value_or(""); + const auto categoryParam = categoryId.value_or(""); + const auto areaParam = areaId.value_or(""); drogon::orm::Result result(nullptr); - if (status) - { - result = db->execSqlSync( - "SELECT r.*, c.name AS category_name, a.name AS area_name " - "FROM reports r JOIN report_categories c ON c.id = r.category_id " - "LEFT JOIN areas a ON a.id = r.area_id WHERE r.status = ? " - "ORDER BY r.created_at DESC LIMIT 100", - *status); - } - else if (categoryId) - { - result = db->execSqlSync( - "SELECT r.*, c.name AS category_name, a.name AS area_name " - "FROM reports r JOIN report_categories c ON c.id = r.category_id " - "LEFT JOIN areas a ON a.id = r.area_id WHERE r.category_id = ? " - "ORDER BY r.created_at DESC LIMIT 100", - *categoryId); - } - else if (areaId) - { - result = db->execSqlSync( - "SELECT r.*, c.name AS category_name, a.name AS area_name " - "FROM reports r JOIN report_categories c ON c.id = r.category_id " - "LEFT JOIN areas a ON a.id = r.area_id WHERE r.area_id = ? " - "ORDER BY r.created_at DESC LIMIT 100", - *areaId); - } - else if (auth::canManageReports(user)) + if (auth::canManageReports(user)) { result = db->execSqlSync( "SELECT r.*, c.name AS category_name, a.name AS area_name " "FROM reports r JOIN report_categories c ON c.id = r.category_id " "LEFT JOIN areas a ON a.id = r.area_id " - "ORDER BY r.created_at DESC LIMIT 100"); + "WHERE (? = '' OR r.status = ?) " + "AND (? = '' OR r.category_id = ?) " + "AND (? = '' OR r.area_id = ?) " + "ORDER BY r.created_at DESC LIMIT 100", + statusParam, + statusParam, + categoryParam, + categoryParam, + areaParam, + areaParam); } else { @@ -197,8 +224,18 @@ Json::Value ReportService::listReports(const auth::UserContext &user, "SELECT r.*, c.name AS category_name, a.name AS area_name " "FROM reports r JOIN report_categories c ON c.id = r.category_id " "LEFT JOIN areas a ON a.id = r.area_id " - "WHERE r.user_id = ? ORDER BY r.created_at DESC LIMIT 100", - user.id); + "WHERE r.user_id = ? " + "AND (? = '' OR r.status = ?) " + "AND (? = '' OR r.category_id = ?) " + "AND (? = '' OR r.area_id = ?) " + "ORDER BY r.created_at DESC LIMIT 100", + user.id, + statusParam, + statusParam, + categoryParam, + categoryParam, + areaParam, + areaParam); } auto response = success(); @@ -240,7 +277,7 @@ Json::Value ReportService::updateStatus(const auth::UserContext &user, long long reportId, const Json::Value &body) { - if (!auth::canManageReports(user)) + if (!canModifyReport(user, reportId)) { return failure("forbidden"); } @@ -251,29 +288,72 @@ Json::Value ReportService::updateStatus(const auth::UserContext &user, } auto db = drogon::app().getDbClient(); - const auto current = db->execSqlSync("SELECT status FROM reports WHERE id = ?", - reportId); + const auto current = db->execSqlSync( + "SELECT status FROM reports WHERE id = ?", + reportId); if (current.empty()) { return failure("report_not_found"); } + const auto oldStatus = current[0]["status"].as(); + const auto newStatus = body["status"].asString(); + if (!canTransition(oldStatus, newStatus)) + { + return failure("invalid_status_transition"); + } db->execSqlSync("UPDATE reports SET status = ? WHERE id = ?", - body["status"].asString(), + newStatus, reportId); db->execSqlSync( "INSERT INTO report_status_history " "(report_id, old_status, new_status, changed_by_user_id, note) " "VALUES (?, ?, ?, ?, ?)", reportId, - current[0]["status"].as(), - body["status"].asString(), + oldStatus, + newStatus, user.id, body.get("note", "").asString()); return success(); } +Json::Value ReportService::takeReport(const auth::UserContext &user, + long long reportId) +{ + if (!auth::canManageReports(user)) + { + return failure("forbidden"); + } + + auto db = drogon::app().getDbClient(); + const auto current = db->execSqlSync( + "SELECT status, assigned_operator_id FROM reports WHERE id = ?", + reportId); + if (current.empty()) + { + return failure("report_not_found"); + } + if (current[0]["status"].as() != "OPEN" || + !current[0]["assigned_operator_id"].isNull()) + { + return failure("report_not_open"); + } + + db->execSqlSync( + "UPDATE reports SET assigned_operator_id = ?, status = 'UNDER_REVIEW' " + "WHERE id = ?", + user.id, + reportId); + db->execSqlSync( + "INSERT INTO report_status_history " + "(report_id, old_status, new_status, changed_by_user_id, note) " + "VALUES (?, 'OPEN', 'UNDER_REVIEW', ?, 'Report taken by operator')", + reportId, + user.id); + return success(); +} + Json::Value ReportService::addMessage(const auth::UserContext &user, long long reportId, const Json::Value &body) @@ -313,12 +393,25 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user, long long reportId, const Json::Value &body) { - if (!auth::canManageReports(user) || !validation::hasNumber(body, "team_id")) + if (!canModifyReport(user, reportId) || !validation::hasNumber(body, "team_id")) { return failure("forbidden"); } auto db = drogon::app().getDbClient(); + const auto current = db->execSqlSync( + "SELECT status FROM reports WHERE id = ?", + reportId); + if (current.empty()) + { + return failure("report_not_found"); + } + const auto oldStatus = current[0]["status"].as(); + if (!canTransition(oldStatus, "ASSIGNED")) + { + return failure("invalid_status_transition"); + } + db->execSqlSync( "INSERT INTO assignments (report_id, team_id, assigned_by_user_id) " "VALUES (?, ?, ?)", @@ -330,8 +423,9 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user, db->execSqlSync( "INSERT INTO report_status_history " "(report_id, old_status, new_status, changed_by_user_id, note) " - "VALUES (?, NULL, 'ASSIGNED', ?, 'Team assigned')", + "VALUES (?, ?, 'ASSIGNED', ?, 'Team assigned')", reportId, + oldStatus, user.id); return success(); } @@ -373,19 +467,49 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user, return success(); } -Json::Value ReportService::addPhoto(const auth::UserContext &user, - long long reportId, - const std::string &url) +Json::Value ReportService::canAddPhoto(const auth::UserContext &user, + long long reportId) { - if (!canAccessReport(user, reportId) || url.empty()) + if (!canAccessReport(user, reportId)) { return failure("forbidden"); } + const auto count = drogon::app().getDbClient()->execSqlSync( + "SELECT COUNT(*) AS total FROM report_photos WHERE report_id = ?", + reportId); + if (!count.empty() && count[0]["total"].as() >= 5) + { + return failure("photo_limit_reached"); + } + auto response = success(); + response["remaining_photo_slots"] = + 5 - (count.empty() ? 0 : count[0]["total"].as()); + return response; +} + +Json::Value ReportService::addPhoto(const auth::UserContext &user, + long long reportId, + const std::string &url, + const std::string &originalFilename, + const std::string &contentType, + size_t sizeBytes) +{ + auto allowed = canAddPhoto(user, reportId); + if (!allowed.get("ok", false).asBool() || url.empty()) + { + return allowed; + } + drogon::app().getDbClient()->execSqlSync( - "INSERT INTO report_photos (report_id, url) VALUES (?, ?)", + "INSERT INTO report_photos " + "(report_id, url, original_filename, content_type, size_bytes) " + "VALUES (?, ?, ?, ?, ?)", reportId, - url); + url, + originalFilename, + contentType, + static_cast(sizeBytes)); return success(); } } // namespace visione::services diff --git a/services/ReportService.h b/services/ReportService.h index 582c87e..bcb5dd8 100644 --- a/services/ReportService.h +++ b/services/ReportService.h @@ -20,6 +20,7 @@ class ReportService Json::Value updateStatus(const auth::UserContext &user, long long reportId, const Json::Value &body); + Json::Value takeReport(const auth::UserContext &user, long long reportId); Json::Value addMessage(const auth::UserContext &user, long long reportId, const Json::Value &body); @@ -30,8 +31,12 @@ class ReportService Json::Value addFeedback(const auth::UserContext &user, long long reportId, const Json::Value &body); + Json::Value canAddPhoto(const auth::UserContext &user, long long reportId); Json::Value addPhoto(const auth::UserContext &user, long long reportId, - const std::string &url); + const std::string &url, + const std::string &originalFilename, + const std::string &contentType, + size_t sizeBytes); }; } // namespace visione::services