feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s

- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other)
- PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium)
- edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast
- SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge
- shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at
- deploy.sh parameterized (--features, --tag), CORS allows PATCH
- CONTEXT.md glossary, ADR-0001 general review editing
This commit is contained in:
2026-07-10 00:01:14 +02:00
parent 9794babe06
commit 29cc68b07c
72 changed files with 1298 additions and 124 deletions

View File

@@ -37,6 +37,8 @@ pub struct LogReviewForm {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub comment: Option<String>,
pub watched_at: String,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub watch_medium: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
@@ -170,6 +172,7 @@ pub struct LogReviewData {
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
pub watch_medium: Option<domain::value_objects::WatchMedium>,
}
#[derive(Debug)]
@@ -182,18 +185,23 @@ impl TryFrom<LogReviewForm> for LogReviewData {
type Error = ParseReviewError;
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M"))
.or_else(|_| {
chrono::NaiveDate::parse_from_str(&form.watched_at, "%Y-%m-%d")
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
})
let watched_at =
domain::value_objects::parse_watched_at(&form.watched_at).map_err(|_| {
ParseReviewError {
field: "watched_at",
message: format!(
"invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
form.watched_at
),
}
})?;
let watch_medium = form
.watch_medium
.map(|s| s.parse())
.transpose()
.map_err(|_| ParseReviewError {
field: "watched_at",
message: format!(
"invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
form.watched_at
),
field: "watch_medium",
message: "invalid watch medium".into(),
})?;
Ok(Self {
external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()),
@@ -203,6 +211,7 @@ impl TryFrom<LogReviewForm> for LogReviewData {
rating: form.rating,
comment: form.comment,
watched_at,
watch_medium,
})
}
}
@@ -211,12 +220,8 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
type Error = DomainError;
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S")
.map_err(|_| {
DomainError::ValidationError(
"invalid watched_at; expected YYYY-MM-DDTHH:MM:SS".into(),
)
})?;
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?;
let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Self {
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
manual_title: req.manual_title,
@@ -225,6 +230,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium,
})
}
}
@@ -243,6 +249,7 @@ impl LogReviewData {
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
watch_medium: self.watch_medium,
}
}
}

View File

@@ -9,10 +9,11 @@ use futures::StreamExt;
use uuid::Uuid;
use application::diary::{
commands::DeleteReviewCommand,
commands::{DeleteReviewCommand, EditReviewCommand},
delete_review,
deps::{DeleteReviewDeps, GetActivityFeedDeps},
export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, log_review,
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary,
log_review,
queries::{ExportQuery, GetActivityFeedQuery},
};
use domain::models::ExportFormat;
@@ -27,7 +28,7 @@ use crate::{
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
ExportQueryParams, LogReviewRequest,
EditReviewRequest, ExportQueryParams, LogReviewRequest,
};
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
@@ -121,6 +122,55 @@ pub async fn delete_review(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch, path = "/api/v1/reviews/{id}",
request_body = EditReviewRequest,
params(("id" = Uuid, Path, description = "Review ID")),
responses(
(status = 200, description = "Review updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Review not found"),
),
security(("bearer_auth" = []))
)]
pub async fn patch_review(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
Json(req): Json<EditReviewRequest>,
) -> Result<StatusCode, ApiError> {
let watched_at = req
.watched_at
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
.transpose()?;
let watch_medium = req
.watch_medium
.map(|opt| {
opt.map(|s| s.parse::<domain::value_objects::WatchMedium>())
.transpose()
.map_err(ApiError)
})
.transpose()?;
let cmd = EditReviewCommand {
review_id,
requesting_user_id: user_id.value(),
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium,
};
let deps = EditReviewDeps {
review: state.app_ctx.repos.review.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
edit_review::execute(&deps, cmd).await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
get, path = "/api/v1/diary/export",
params(ExportQueryParams),

View File

@@ -183,8 +183,9 @@ pub async fn get_movie_detail(
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
is_federated: e.review().is_remote(),
watch_medium: e.review().watch_medium().map(|wm| wm.to_string()),
})
.collect(),
total_count: result.reviews.total_count,

View File

@@ -36,7 +36,8 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
id: review.id().value(),
rating: review.rating().value(),
comment: review.comment().map(|c| c.value().to_string()),
watched_at: review.watched_at().to_string(),
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
}
}

View File

@@ -262,6 +262,7 @@ fn cors_layer() -> CorsLayer {
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
@@ -318,7 +319,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/reviews", routing::post(handlers::diary::post_review))
.route(
"/reviews/{id}",
routing::delete(handlers::diary::delete_review),
routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review),
)
.route(
"/movies/{id}/sync-poster",

View File

@@ -92,6 +92,9 @@ impl ReviewRepository for Panic {
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
panic!()
}
async fn update_review(&self, _: &Review) -> Result<(), DomainError> {
panic!()
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!()
}

View File

@@ -9,6 +9,7 @@ fn make_form(watched_at: &str) -> LogReviewForm {
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
csrf_token: String::new(),
}
}
@@ -22,6 +23,7 @@ fn make_request(watched_at: &str) -> LogReviewRequest {
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
}
}
@@ -49,8 +51,9 @@ fn api_accepts_datetime_with_seconds() {
}
#[test]
fn api_rejects_datetime_without_seconds() {
assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err());
fn api_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]