Add report media uploads
This commit is contained in:
@@ -28,7 +28,9 @@ cmake --build build
|
||||
|
||||
The API listens on `http://localhost:5555`.
|
||||
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:
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
"handle_sig_term": true,
|
||||
"use_gzip": true,
|
||||
"use_brotli": false,
|
||||
"client_max_body_size": "1M",
|
||||
"client_max_body_size": "50M",
|
||||
"client_max_memory_body_size": "256K"
|
||||
},
|
||||
"plugins": [],
|
||||
|
||||
@@ -63,25 +63,30 @@ void sendService(Json::Value body,
|
||||
callback(visione::http::json(std::move(body), status));
|
||||
}
|
||||
|
||||
bool isAllowedImage(const drogon::HttpFile &file)
|
||||
struct MediaType
|
||||
{
|
||||
const auto type = file.getContentType();
|
||||
return type == drogon::CT_IMAGE_JPG || type == drogon::CT_IMAGE_PNG ||
|
||||
type == drogon::CT_IMAGE_WEBP;
|
||||
}
|
||||
std::string kind;
|
||||
std::string contentType;
|
||||
};
|
||||
|
||||
std::string imageContentType(const drogon::HttpFile &file)
|
||||
std::optional<MediaType> allowedMediaType(const drogon::HttpFile &file)
|
||||
{
|
||||
switch (file.getContentType())
|
||||
{
|
||||
case drogon::CT_IMAGE_JPG:
|
||||
return "image/jpeg";
|
||||
return MediaType{"IMAGE", "image/jpeg"};
|
||||
case drogon::CT_IMAGE_PNG:
|
||||
return "image/png";
|
||||
return MediaType{"IMAGE", "image/png"};
|
||||
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:
|
||||
return "application/octet-stream";
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
@@ -226,7 +231,7 @@ void ReportController::addFeedback(
|
||||
sendService(service.addFeedback(*user, reportId, body), std::move(callback));
|
||||
}
|
||||
|
||||
void ReportController::uploadPhotos(
|
||||
void ReportController::uploadMedia(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
long long reportId)
|
||||
@@ -254,7 +259,7 @@ void ReportController::uploadPhotos(
|
||||
std::filesystem::path(reportsPath) / std::to_string(reportId);
|
||||
|
||||
visione::services::ReportService service;
|
||||
auto allowed = service.canAddPhoto(*user, reportId);
|
||||
auto allowed = service.canAddMedia(*user, reportId);
|
||||
if (!allowed.get("ok", false).asBool())
|
||||
{
|
||||
callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
|
||||
@@ -262,16 +267,16 @@ void ReportController::uploadPhotos(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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));
|
||||
return;
|
||||
}
|
||||
@@ -281,10 +286,12 @@ void ReportController::uploadPhotos(
|
||||
|
||||
Json::Value response;
|
||||
response["ok"] = true;
|
||||
response["media"] = Json::arrayValue;
|
||||
response["photos"] = Json::arrayValue;
|
||||
|
||||
for (const auto &file : parser.getFiles())
|
||||
{
|
||||
const auto mediaType = allowedMediaType(file).value();
|
||||
auto extension = std::string(file.getFileExtension());
|
||||
if (!extension.empty() && extension.front() != '.')
|
||||
{
|
||||
@@ -294,24 +301,30 @@ void ReportController::uploadPhotos(
|
||||
const auto fullPath = reportDir / fileName;
|
||||
if (file.saveAs(fullPath.string()) != 0)
|
||||
{
|
||||
callback(visione::http::error("photo_save_failed",
|
||||
callback(visione::http::error("media_save_failed",
|
||||
drogon::k500InternalServerError));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto url = publicBasePath + "/reports/" + std::to_string(reportId) +
|
||||
"/" + fileName;
|
||||
auto saved = service.addPhoto(*user,
|
||||
auto saved = service.addMedia(*user,
|
||||
reportId,
|
||||
url,
|
||||
file.getFileName(),
|
||||
imageContentType(file),
|
||||
mediaType.contentType,
|
||||
mediaType.kind,
|
||||
file.fileLength());
|
||||
if (!saved.get("ok", false).asBool())
|
||||
{
|
||||
callback(visione::http::json(std::move(saved), drogon::k403Forbidden));
|
||||
return;
|
||||
}
|
||||
Json::Value item;
|
||||
item["url"] = url;
|
||||
item["kind"] = mediaType.kind;
|
||||
item["content_type"] = mediaType.contentType;
|
||||
response["media"].append(item);
|
||||
response["photos"].append(url);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
ADD_METHOD_TO(ReportController::addFeedback,
|
||||
"/api/v1/reports/{1}/feedback",
|
||||
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",
|
||||
drogon::Post);
|
||||
METHOD_LIST_END
|
||||
@@ -63,7 +66,7 @@ class ReportController : public drogon::HttpController<ReportController>
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
void uploadPhotos(
|
||||
void uploadMedia(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
long long reportId);
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ Authorization: Bearer <token>
|
||||
- `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 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
|
||||
- `GET|POST /admin/categories`
|
||||
|
||||
@@ -14,7 +14,8 @@ Important tables:
|
||||
- `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 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.
|
||||
- `feedback`: citizen rating and comment after resolution.
|
||||
- `reward_events`: indirect gamification points ledger.
|
||||
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
# Visione Backend LLM Context
|
||||
|
||||
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.
|
||||
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`
|
||||
and `https://drogonframework.github.io/drogon-docs/`.
|
||||
- `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.
|
||||
- Focused regression tests for technical endpoints and core validation helpers.
|
||||
- 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.
|
||||
|
||||
## To do next
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 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.
|
||||
- Report media upload validation and metadata storage.
|
||||
|
||||
## To do next
|
||||
- Add broader API tests once the frontend contracts stabilize.
|
||||
@@ -16,4 +16,3 @@
|
||||
- Add moderation flow for abusive reports/messages.
|
||||
- Add open-data exports and statistics refinements.
|
||||
- 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,
|
||||
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(
|
||||
"INSERT INTO report_photos (report_id, url) VALUES (?, ?)",
|
||||
"INSERT INTO report_photos (report_id, url, media_kind) "
|
||||
"VALUES (?, ?, 'IMAGE')",
|
||||
reportId,
|
||||
photo.asString());
|
||||
item.asString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,9 +284,10 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user,
|
||||
response["history"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
|
||||
reportId));
|
||||
response["photos"] = repositories::resultToJson(db->execSqlSync(
|
||||
response["media"] = repositories::resultToJson(db->execSqlSync(
|
||||
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
||||
reportId));
|
||||
response["photos"] = response["media"];
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -482,7 +486,7 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
|
||||
return success();
|
||||
}
|
||||
|
||||
Json::Value ReportService::canAddPhoto(const auth::UserContext &user,
|
||||
Json::Value ReportService::canAddMedia(const auth::UserContext &user,
|
||||
long long reportId)
|
||||
{
|
||||
if (!canAccessReport(user, reportId))
|
||||
@@ -495,22 +499,23 @@ Json::Value ReportService::canAddPhoto(const auth::UserContext &user,
|
||||
reportId);
|
||||
if (!count.empty() && count[0]["total"].as<int>() >= 5)
|
||||
{
|
||||
return failure("photo_limit_reached");
|
||||
return failure("media_limit_reached");
|
||||
}
|
||||
auto response = success();
|
||||
response["remaining_photo_slots"] =
|
||||
response["remaining_media_slots"] =
|
||||
5 - (count.empty() ? 0 : count[0]["total"].as<int>());
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value ReportService::addPhoto(const auth::UserContext &user,
|
||||
Json::Value ReportService::addMedia(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const std::string &url,
|
||||
const std::string &originalFilename,
|
||||
const std::string &contentType,
|
||||
const std::string &mediaKind,
|
||||
size_t sizeBytes)
|
||||
{
|
||||
auto allowed = canAddPhoto(user, reportId);
|
||||
auto allowed = canAddMedia(user, reportId);
|
||||
if (!allowed.get("ok", false).asBool() || url.empty())
|
||||
{
|
||||
return allowed;
|
||||
@@ -518,12 +523,13 @@ Json::Value ReportService::addPhoto(const auth::UserContext &user,
|
||||
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO report_photos "
|
||||
"(report_id, url, original_filename, content_type, size_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
"(report_id, url, original_filename, content_type, media_kind, size_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
reportId,
|
||||
url,
|
||||
originalFilename,
|
||||
contentType,
|
||||
mediaKind,
|
||||
static_cast<unsigned long long>(sizeBytes));
|
||||
return success();
|
||||
}
|
||||
|
||||
@@ -31,12 +31,13 @@ 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,
|
||||
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
|
||||
Json::Value addMedia(const auth::UserContext &user,
|
||||
long long reportId,
|
||||
const std::string &url,
|
||||
const std::string &originalFilename,
|
||||
const std::string &contentType,
|
||||
const std::string &mediaKind,
|
||||
size_t sizeBytes);
|
||||
};
|
||||
} // namespace visione::services
|
||||
|
||||
Reference in New Issue
Block a user