Add report media uploads
This commit is contained in:
@@ -28,7 +28,9 @@ cmake --build build
|
|||||||
|
|
||||||
The API listens on `http://localhost:5555`.
|
The API listens on `http://localhost:5555`.
|
||||||
MailHog listens on `http://localhost:8025`.
|
MailHog listens on `http://localhost:8025`.
|
||||||
Local CORS allows `http://localhost:5173` by default for the Vue frontend.
|
Local CORS allows `http://localhost:5173` by default for the future Vue frontend.
|
||||||
|
Multipart uploads currently accept report images and videos up to the app body
|
||||||
|
limit configured in `config.json`.
|
||||||
|
|
||||||
Local seeded admin:
|
Local seeded admin:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
"handle_sig_term": true,
|
"handle_sig_term": true,
|
||||||
"use_gzip": true,
|
"use_gzip": true,
|
||||||
"use_brotli": false,
|
"use_brotli": false,
|
||||||
"client_max_body_size": "1M",
|
"client_max_body_size": "50M",
|
||||||
"client_max_memory_body_size": "256K"
|
"client_max_memory_body_size": "256K"
|
||||||
},
|
},
|
||||||
"plugins": [],
|
"plugins": [],
|
||||||
|
|||||||
@@ -63,25 +63,30 @@ void sendService(Json::Value body,
|
|||||||
callback(visione::http::json(std::move(body), status));
|
callback(visione::http::json(std::move(body), status));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isAllowedImage(const drogon::HttpFile &file)
|
struct MediaType
|
||||||
{
|
{
|
||||||
const auto type = file.getContentType();
|
std::string kind;
|
||||||
return type == drogon::CT_IMAGE_JPG || type == drogon::CT_IMAGE_PNG ||
|
std::string contentType;
|
||||||
type == drogon::CT_IMAGE_WEBP;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
std::string imageContentType(const drogon::HttpFile &file)
|
std::optional<MediaType> allowedMediaType(const drogon::HttpFile &file)
|
||||||
{
|
{
|
||||||
switch (file.getContentType())
|
switch (file.getContentType())
|
||||||
{
|
{
|
||||||
case drogon::CT_IMAGE_JPG:
|
case drogon::CT_IMAGE_JPG:
|
||||||
return "image/jpeg";
|
return MediaType{"IMAGE", "image/jpeg"};
|
||||||
case drogon::CT_IMAGE_PNG:
|
case drogon::CT_IMAGE_PNG:
|
||||||
return "image/png";
|
return MediaType{"IMAGE", "image/png"};
|
||||||
case drogon::CT_IMAGE_WEBP:
|
case drogon::CT_IMAGE_WEBP:
|
||||||
return "image/webp";
|
return MediaType{"IMAGE", "image/webp"};
|
||||||
|
case drogon::CT_VIDEO_MP4:
|
||||||
|
return MediaType{"VIDEO", "video/mp4"};
|
||||||
|
case drogon::CT_VIDEO_WEBM:
|
||||||
|
return MediaType{"VIDEO", "video/webm"};
|
||||||
|
case drogon::CT_VIDEO_QUICKTIME:
|
||||||
|
return MediaType{"VIDEO", "video/quicktime"};
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream";
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -226,7 +231,7 @@ void ReportController::addFeedback(
|
|||||||
sendService(service.addFeedback(*user, reportId, body), std::move(callback));
|
sendService(service.addFeedback(*user, reportId, body), std::move(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReportController::uploadPhotos(
|
void ReportController::uploadMedia(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
long long reportId)
|
long long reportId)
|
||||||
@@ -254,7 +259,7 @@ void ReportController::uploadPhotos(
|
|||||||
std::filesystem::path(reportsPath) / std::to_string(reportId);
|
std::filesystem::path(reportsPath) / std::to_string(reportId);
|
||||||
|
|
||||||
visione::services::ReportService service;
|
visione::services::ReportService service;
|
||||||
auto allowed = service.canAddPhoto(*user, reportId);
|
auto allowed = service.canAddMedia(*user, reportId);
|
||||||
if (!allowed.get("ok", false).asBool())
|
if (!allowed.get("ok", false).asBool())
|
||||||
{
|
{
|
||||||
callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
|
callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
|
||||||
@@ -262,16 +267,16 @@ void ReportController::uploadPhotos(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parser.getFiles().size() >
|
if (parser.getFiles().size() >
|
||||||
static_cast<size_t>(allowed.get("remaining_photo_slots", 0).asInt()))
|
static_cast<size_t>(allowed.get("remaining_media_slots", 0).asInt()))
|
||||||
{
|
{
|
||||||
callback(visione::http::error("too_many_photos", drogon::k400BadRequest));
|
callback(visione::http::error("too_many_media", drogon::k400BadRequest));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const auto &file : parser.getFiles())
|
for (const auto &file : parser.getFiles())
|
||||||
{
|
{
|
||||||
if (!isAllowedImage(file))
|
if (!allowedMediaType(file))
|
||||||
{
|
{
|
||||||
callback(visione::http::error("unsupported_photo_type",
|
callback(visione::http::error("unsupported_media_type",
|
||||||
drogon::k400BadRequest));
|
drogon::k400BadRequest));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,10 +286,12 @@ void ReportController::uploadPhotos(
|
|||||||
|
|
||||||
Json::Value response;
|
Json::Value response;
|
||||||
response["ok"] = true;
|
response["ok"] = true;
|
||||||
|
response["media"] = Json::arrayValue;
|
||||||
response["photos"] = Json::arrayValue;
|
response["photos"] = Json::arrayValue;
|
||||||
|
|
||||||
for (const auto &file : parser.getFiles())
|
for (const auto &file : parser.getFiles())
|
||||||
{
|
{
|
||||||
|
const auto mediaType = allowedMediaType(file).value();
|
||||||
auto extension = std::string(file.getFileExtension());
|
auto extension = std::string(file.getFileExtension());
|
||||||
if (!extension.empty() && extension.front() != '.')
|
if (!extension.empty() && extension.front() != '.')
|
||||||
{
|
{
|
||||||
@@ -294,24 +301,30 @@ void ReportController::uploadPhotos(
|
|||||||
const auto fullPath = reportDir / fileName;
|
const auto fullPath = reportDir / fileName;
|
||||||
if (file.saveAs(fullPath.string()) != 0)
|
if (file.saveAs(fullPath.string()) != 0)
|
||||||
{
|
{
|
||||||
callback(visione::http::error("photo_save_failed",
|
callback(visione::http::error("media_save_failed",
|
||||||
drogon::k500InternalServerError));
|
drogon::k500InternalServerError));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto url = publicBasePath + "/reports/" + std::to_string(reportId) +
|
const auto url = publicBasePath + "/reports/" + std::to_string(reportId) +
|
||||||
"/" + fileName;
|
"/" + fileName;
|
||||||
auto saved = service.addPhoto(*user,
|
auto saved = service.addMedia(*user,
|
||||||
reportId,
|
reportId,
|
||||||
url,
|
url,
|
||||||
file.getFileName(),
|
file.getFileName(),
|
||||||
imageContentType(file),
|
mediaType.contentType,
|
||||||
|
mediaType.kind,
|
||||||
file.fileLength());
|
file.fileLength());
|
||||||
if (!saved.get("ok", false).asBool())
|
if (!saved.get("ok", false).asBool())
|
||||||
{
|
{
|
||||||
callback(visione::http::json(std::move(saved), drogon::k403Forbidden));
|
callback(visione::http::json(std::move(saved), drogon::k403Forbidden));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Json::Value item;
|
||||||
|
item["url"] = url;
|
||||||
|
item["kind"] = mediaType.kind;
|
||||||
|
item["content_type"] = mediaType.contentType;
|
||||||
|
response["media"].append(item);
|
||||||
response["photos"].append(url);
|
response["photos"].append(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
ADD_METHOD_TO(ReportController::addFeedback,
|
ADD_METHOD_TO(ReportController::addFeedback,
|
||||||
"/api/v1/reports/{1}/feedback",
|
"/api/v1/reports/{1}/feedback",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
ADD_METHOD_TO(ReportController::uploadPhotos,
|
ADD_METHOD_TO(ReportController::uploadMedia,
|
||||||
|
"/api/v1/reports/{1}/media",
|
||||||
|
drogon::Post);
|
||||||
|
ADD_METHOD_TO(ReportController::uploadMedia,
|
||||||
"/api/v1/reports/{1}/photos",
|
"/api/v1/reports/{1}/photos",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
@@ -63,7 +66,7 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
long long reportId);
|
long long reportId);
|
||||||
void uploadPhotos(
|
void uploadMedia(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
long long reportId);
|
long long reportId);
|
||||||
|
|||||||
+3
-1
@@ -30,7 +30,9 @@ Authorization: Bearer <token>
|
|||||||
- `GET /reports/{id}/messages`: list report messages.
|
- `GET /reports/{id}/messages`: list report messages.
|
||||||
- `POST /reports/{id}/assignments`: assign a team.
|
- `POST /reports/{id}/assignments`: assign a team.
|
||||||
- `POST /reports/{id}/feedback`: add citizen feedback.
|
- `POST /reports/{id}/feedback`: add citizen feedback.
|
||||||
- `POST /reports/{id}/photos`: upload up to five JPEG/PNG/WebP multipart files.
|
- `POST /reports/{id}/media`: upload up to five multipart media files. Supported
|
||||||
|
types are JPEG, PNG, WebP, MP4, WebM and QuickTime. The legacy
|
||||||
|
`/reports/{id}/photos` path is kept as a compatibility alias.
|
||||||
|
|
||||||
## Admin Reference Data
|
## Admin Reference Data
|
||||||
- `GET|POST /admin/categories`
|
- `GET|POST /admin/categories`
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ Important tables:
|
|||||||
- `reports`: citizen reports with coordinates, address, status, area and assigned operator.
|
- `reports`: citizen reports with coordinates, address, status, area and assigned operator.
|
||||||
- `report_status_history`: immutable status transition log.
|
- `report_status_history`: immutable status transition log.
|
||||||
- `assignments`: report-to-team assignment history.
|
- `assignments`: report-to-team assignment history.
|
||||||
- `report_photos`: uploaded photo URL, original filename, content type and size.
|
- `report_photos`: uploaded report media URL, original filename, content type,
|
||||||
|
media kind and size. The table name is historical; rows can be images or videos.
|
||||||
- `messages`: citizen/operator report conversation.
|
- `messages`: citizen/operator report conversation.
|
||||||
- `feedback`: citizen rating and comment after resolution.
|
- `feedback`: citizen rating and comment after resolution.
|
||||||
- `reward_events`: indirect gamification points ledger.
|
- `reward_events`: indirect gamification points ledger.
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,8 @@
|
|||||||
# Visione Backend LLM Context
|
# Visione Backend LLM Context
|
||||||
|
|
||||||
Visione is a C++20/Drogon backend for a municipal urban issue reporting system.
|
Visione is a C++20/Drogon backend for a municipal urban issue reporting system.
|
||||||
Citizens create reports with category, position, address, description and photos.
|
Citizens create reports with category, position, address, description and media
|
||||||
|
attachments.
|
||||||
Operators manage reports, assign teams, update status and chat with citizens.
|
Operators manage reports, assign teams, update status and chat with citizens.
|
||||||
Admins manage roles, users, areas, teams and reference data.
|
Admins manage roles, users, areas, teams and reference data.
|
||||||
|
|
||||||
@@ -16,5 +17,5 @@ Important conventions:
|
|||||||
- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon`
|
- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon`
|
||||||
and `https://drogonframework.github.io/drogon-docs/`.
|
and `https://drogonframework.github.io/drogon-docs/`.
|
||||||
- `AGENTS.md` is the durable agent instruction entrypoint.
|
- `AGENTS.md` is the durable agent instruction entrypoint.
|
||||||
- `docs/MILESTONE_01.md` describes what has been implemented and what comes next.
|
- `docs/MILESTONE_01.md` and later milestone files describe what has been
|
||||||
|
implemented and what comes next.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- OTP email delivery through MailHog.
|
- OTP email delivery through MailHog.
|
||||||
- Focused regression tests for technical endpoints and core validation helpers.
|
- Focused regression tests for technical endpoints and core validation helpers.
|
||||||
- Local admin seed: `[email protected]` / `ChangeMe123!`, with forced password change flag.
|
- Local admin seed: `[email protected]` / `ChangeMe123!`, with forced password change flag.
|
||||||
- Multipart report photo upload endpoint storing files under `uploads/reports/`.
|
- Multipart report media upload endpoint storing files under `uploads/reports/`.
|
||||||
- PBKDF2-HMAC-SHA256 password hashes for credentials. Legacy SHA-256 hashes are intentionally rejected.
|
- PBKDF2-HMAC-SHA256 password hashes for credentials. Legacy SHA-256 hashes are intentionally rejected.
|
||||||
|
|
||||||
## To do next
|
## To do next
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- Report ownership workflow with assigned operator and take endpoint.
|
- Report ownership workflow with assigned operator and take endpoint.
|
||||||
- Valid report status transitions.
|
- Valid report status transitions.
|
||||||
- Citizen report filters scoped to own reports.
|
- Citizen report filters scoped to own reports.
|
||||||
- Report photo upload validation and metadata storage.
|
- Report media upload validation and metadata storage.
|
||||||
|
|
||||||
## To do next
|
## To do next
|
||||||
- Add broader API tests once the frontend contracts stabilize.
|
- Add broader API tests once the frontend contracts stabilize.
|
||||||
@@ -16,4 +16,3 @@
|
|||||||
- Add moderation flow for abusive reports/messages.
|
- Add moderation flow for abusive reports/messages.
|
||||||
- Add open-data exports and statistics refinements.
|
- Add open-data exports and statistics refinements.
|
||||||
- Design CIE/SPID integration.
|
- Design CIE/SPID integration.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE report_photos
|
||||||
|
ADD COLUMN media_kind ENUM('IMAGE', 'VIDEO') NOT NULL DEFAULT 'IMAGE'
|
||||||
|
AFTER content_type;
|
||||||
+19
-13
@@ -181,16 +181,19 @@ Json::Value ReportService::createReport(const auth::UserContext &user,
|
|||||||
reportId,
|
reportId,
|
||||||
user.id);
|
user.id);
|
||||||
|
|
||||||
if (body["photos"].isArray())
|
const auto &mediaItems =
|
||||||
|
body["media"].isArray() ? body["media"] : body["photos"];
|
||||||
|
if (mediaItems.isArray())
|
||||||
{
|
{
|
||||||
for (const auto &photo : body["photos"])
|
for (const auto &item : mediaItems)
|
||||||
{
|
{
|
||||||
if (photo.isString() && !photo.asString().empty())
|
if (item.isString() && !item.asString().empty())
|
||||||
{
|
{
|
||||||
db->execSqlSync(
|
db->execSqlSync(
|
||||||
"INSERT INTO report_photos (report_id, url) VALUES (?, ?)",
|
"INSERT INTO report_photos (report_id, url, media_kind) "
|
||||||
|
"VALUES (?, ?, 'IMAGE')",
|
||||||
reportId,
|
reportId,
|
||||||
photo.asString());
|
item.asString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,9 +284,10 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user,
|
|||||||
response["history"] = repositories::resultToJson(db->execSqlSync(
|
response["history"] = repositories::resultToJson(db->execSqlSync(
|
||||||
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
|
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
|
||||||
reportId));
|
reportId));
|
||||||
response["photos"] = repositories::resultToJson(db->execSqlSync(
|
response["media"] = repositories::resultToJson(db->execSqlSync(
|
||||||
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
||||||
reportId));
|
reportId));
|
||||||
|
response["photos"] = response["media"];
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +486,7 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
|
|||||||
return success();
|
return success();
|
||||||
}
|
}
|
||||||
|
|
||||||
Json::Value ReportService::canAddPhoto(const auth::UserContext &user,
|
Json::Value ReportService::canAddMedia(const auth::UserContext &user,
|
||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
if (!canAccessReport(user, reportId))
|
if (!canAccessReport(user, reportId))
|
||||||
@@ -495,22 +499,23 @@ Json::Value ReportService::canAddPhoto(const auth::UserContext &user,
|
|||||||
reportId);
|
reportId);
|
||||||
if (!count.empty() && count[0]["total"].as<int>() >= 5)
|
if (!count.empty() && count[0]["total"].as<int>() >= 5)
|
||||||
{
|
{
|
||||||
return failure("photo_limit_reached");
|
return failure("media_limit_reached");
|
||||||
}
|
}
|
||||||
auto response = success();
|
auto response = success();
|
||||||
response["remaining_photo_slots"] =
|
response["remaining_media_slots"] =
|
||||||
5 - (count.empty() ? 0 : count[0]["total"].as<int>());
|
5 - (count.empty() ? 0 : count[0]["total"].as<int>());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
Json::Value ReportService::addPhoto(const auth::UserContext &user,
|
Json::Value ReportService::addMedia(const auth::UserContext &user,
|
||||||
long long reportId,
|
long long reportId,
|
||||||
const std::string &url,
|
const std::string &url,
|
||||||
const std::string &originalFilename,
|
const std::string &originalFilename,
|
||||||
const std::string &contentType,
|
const std::string &contentType,
|
||||||
|
const std::string &mediaKind,
|
||||||
size_t sizeBytes)
|
size_t sizeBytes)
|
||||||
{
|
{
|
||||||
auto allowed = canAddPhoto(user, reportId);
|
auto allowed = canAddMedia(user, reportId);
|
||||||
if (!allowed.get("ok", false).asBool() || url.empty())
|
if (!allowed.get("ok", false).asBool() || url.empty())
|
||||||
{
|
{
|
||||||
return allowed;
|
return allowed;
|
||||||
@@ -518,12 +523,13 @@ Json::Value ReportService::addPhoto(const auth::UserContext &user,
|
|||||||
|
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
drogon::app().getDbClient()->execSqlSync(
|
||||||
"INSERT INTO report_photos "
|
"INSERT INTO report_photos "
|
||||||
"(report_id, url, original_filename, content_type, size_bytes) "
|
"(report_id, url, original_filename, content_type, media_kind, size_bytes) "
|
||||||
"VALUES (?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
reportId,
|
reportId,
|
||||||
url,
|
url,
|
||||||
originalFilename,
|
originalFilename,
|
||||||
contentType,
|
contentType,
|
||||||
|
mediaKind,
|
||||||
static_cast<unsigned long long>(sizeBytes));
|
static_cast<unsigned long long>(sizeBytes));
|
||||||
return success();
|
return success();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ class ReportService
|
|||||||
Json::Value addFeedback(const auth::UserContext &user,
|
Json::Value addFeedback(const auth::UserContext &user,
|
||||||
long long reportId,
|
long long reportId,
|
||||||
const Json::Value &body);
|
const Json::Value &body);
|
||||||
Json::Value canAddPhoto(const auth::UserContext &user, long long reportId);
|
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
|
||||||
Json::Value addPhoto(const auth::UserContext &user,
|
Json::Value addMedia(const auth::UserContext &user,
|
||||||
long long reportId,
|
long long reportId,
|
||||||
const std::string &url,
|
const std::string &url,
|
||||||
const std::string &originalFilename,
|
const std::string &originalFilename,
|
||||||
const std::string &contentType,
|
const std::string &contentType,
|
||||||
|
const std::string &mediaKind,
|
||||||
size_t sizeBytes);
|
size_t sizeBytes);
|
||||||
};
|
};
|
||||||
} // namespace visione::services
|
} // namespace visione::services
|
||||||
|
|||||||
Reference in New Issue
Block a user