feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user