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

@@ -132,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler {
source: ReviewSource::Remote {
actor_url: actor_url_str,
},
watch_medium: None,
});
self.review_store

View File

@@ -29,6 +29,7 @@ fn review_to_ap_object_includes_two_hashtags() {
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
watch_medium: None,
});
let obj = review_to_ap_object(
&review,
@@ -67,6 +68,7 @@ fn review_to_ap_object_has_public_addressing() {
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
watch_medium: None,
});
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object(

View File

@@ -61,6 +61,7 @@ fn make_entry_full(
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
None,
)
.unwrap();
DiaryEntry::new(movie, review)

View File

@@ -106,6 +106,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium: None,
}))
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;

View File

@@ -60,7 +60,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -93,7 +94,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1
@@ -178,7 +180,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1{remote_clause}{search_clause}
@@ -339,6 +342,7 @@ impl DiaryRepository for PostgresDiaryRepository {
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
r.watch_medium,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
@@ -432,7 +436,8 @@ impl DiaryRepository for PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1

View File

@@ -90,6 +90,7 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl ReviewRow {
@@ -105,6 +106,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -114,6 +116,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium,
}))
}
}
@@ -134,6 +137,7 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl DiaryRow {
@@ -156,6 +160,7 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
@@ -178,6 +183,7 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub user_email: String,
}
@@ -198,6 +204,7 @@ impl FeedRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))

View File

@@ -41,8 +41,8 @@ impl ReviewRepository for PostgresReviewRepository {
};
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)",
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
)
.bind(&id)
.bind(&movie_id)
@@ -52,6 +52,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
@@ -71,7 +72,8 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
remote_actor_url,
watch_medium
FROM reviews WHERE id = $1",
)
.bind(&id)
@@ -82,6 +84,28 @@ impl ReviewRepository for PostgresReviewRepository {
.transpose()
}
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
sqlx::query(
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&watch_medium)
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = $1")
@@ -98,7 +122,8 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
remote_actor_url,
watch_medium
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
)
.bind(&uid)

View File

@@ -106,6 +106,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium: None,
}))
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;

View File

@@ -57,7 +57,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -87,7 +87,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ?
@@ -161,7 +161,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?{remote_clause}{search_clause}
@@ -308,7 +308,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let select_sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
r.watched_at, r.created_at, r.remote_actor_url,
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
@@ -395,7 +395,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -418,7 +418,7 @@ impl DiaryRepository for SqliteDiaryRepository {
Box::pin(async_stream::stream! {
let mut rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -475,7 +475,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
r.watched_at, r.created_at, r.remote_actor_url,
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email

View File

@@ -94,6 +94,7 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl ReviewRow {
@@ -109,6 +110,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -118,6 +120,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium,
}))
}
}
@@ -139,6 +142,7 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl DiaryRow {
@@ -162,6 +166,7 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
@@ -215,6 +220,7 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub user_email: String,
}
@@ -235,6 +241,7 @@ impl FeedRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))

View File

@@ -41,8 +41,8 @@ impl ReviewRepository for SqliteReviewRepository {
};
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&movie_id)
@@ -52,6 +52,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
@@ -68,7 +69,7 @@ impl ReviewRepository for SqliteReviewRepository {
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
FROM reviews WHERE id = ?",
)
.bind(&id)
@@ -79,6 +80,28 @@ impl ReviewRepository for SqliteReviewRepository {
.transpose()
}
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
sqlx::query(
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? WHERE id = ?",
)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&watch_medium)
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = ?")
@@ -92,7 +115,7 @@ impl ReviewRepository for SqliteReviewRepository {
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let uid = user_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
)
.bind(&uid)

View File

@@ -42,6 +42,9 @@
<span class="star {% if filled %}filled{% else %}empty{% endif %}"></span>
{% endfor %}
</div>
{% if let Some(wm) = entry.review().watch_medium() %}
<span class="watch-medium">{{ wm }}</span>
{% endif %}
{% if let Some(comment) = entry.review().comment() %}
<div class="comment">{{ comment.value() }}</div>
{% endif %}

View File

@@ -35,6 +35,19 @@
Comment<br>
<textarea name="comment"></textarea>
</label>
<label>
Watched via <span class="optional">(optional)</span><br>
<select name="watch_medium">
<option value=""></option>
<option value="cinema">Cinema</option>
<option value="streaming">Streaming</option>
<option value="tv">TV</option>
<option value="physical_media">Physical Media</option>
<option value="download">Download</option>
<option value="media_server">Media Server</option>
<option value="other">Other</option>
</select>
</label>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Log Review</button>
</form>

View File

@@ -17,6 +17,8 @@ pub struct LogReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -77,6 +79,18 @@ pub struct ExportQueryParams {
pub format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct EditReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub watched_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<Option<String>>,
}
fn default_export_format() -> String {
"csv".to_string()
}

View File

@@ -98,6 +98,8 @@ pub struct ReviewDto {
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -122,6 +124,8 @@ pub struct SocialReviewDto {
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub watch_medium: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,4 +1,5 @@
use chrono::NaiveDateTime;
use domain::value_objects::WatchMedium;
use uuid::Uuid;
pub struct MovieInput {
@@ -15,6 +16,7 @@ pub struct LogReviewCommand {
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
pub watch_medium: Option<WatchMedium>,
}
pub struct DeleteReviewCommand {
@@ -22,6 +24,15 @@ pub struct DeleteReviewCommand {
pub requesting_user_id: Uuid,
}
pub struct EditReviewCommand {
pub review_id: Uuid,
pub requesting_user_id: Uuid,
pub rating: Option<u8>,
pub comment: Option<Option<String>>,
pub watched_at: Option<NaiveDateTime>,
pub watch_medium: Option<Option<WatchMedium>>,
}
#[derive(Clone)]
pub struct SyncPosterCommand {
pub movie_id: Uuid,

View File

@@ -14,6 +14,11 @@ pub struct DeleteReviewDeps {
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct EditReviewDeps {
pub review: Arc<dyn ReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetMovieSocialPageDeps {
pub movie: Arc<dyn MovieRepository>,
pub diary: Arc<dyn DiaryRepository>,

View File

@@ -0,0 +1,60 @@
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps};
use domain::{
errors::DomainError,
events::DomainEvent,
models::ReviewEdit,
value_objects::{Comment, Rating, ReviewId, UserId},
};
pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<(), DomainError> {
let review_id = ReviewId::from_uuid(cmd.review_id);
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
let review = deps
.review
.get_review_by_id(&review_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
if review.is_remote() {
return Err(DomainError::Forbidden(
"cannot edit a federated review".into(),
));
}
if review.user_id() != &requesting_user_id {
return Err(DomainError::Forbidden("not your review".into()));
}
let updated = review.apply_edit(ReviewEdit {
rating: cmd.rating.map(Rating::new).transpose()?,
comment: cmd
.comment
.map(|c| c.map(Comment::new).transpose())
.transpose()?,
watched_at: cmd.watched_at,
watch_medium: cmd.watch_medium,
});
deps.review.update_review(&updated).await?;
if let Err(e) = deps
.event_publisher
.publish(&DomainEvent::ReviewUpdated {
review_id: updated.id().clone(),
movie_id: updated.movie_id().clone(),
user_id: updated.user_id().clone(),
rating: updated.rating().clone(),
watched_at: *updated.watched_at(),
})
.await
{
tracing::warn!("failed to publish ReviewUpdated: {e}");
}
Ok(())
}
#[cfg(test)]
#[path = "tests/edit_review.rs"]
mod tests;

View File

@@ -1,6 +1,7 @@
pub mod commands;
pub mod delete_review;
pub mod deps;
pub mod edit_review;
pub mod export_diary;
pub mod get_activity_feed;
pub mod get_diary;

View File

@@ -68,7 +68,14 @@ impl ReviewLogger for DefaultReviewLogger {
self.movie_repo.upsert_movie(&movie).await?;
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review = Review::new(
movie.id().clone(),
user_id,
rating,
comment,
cmd.watched_at,
cmd.watch_medium,
)?;
let review_event = self.review_repo.save_review(&review).await?;
let was_on_watchlist = self

View File

@@ -32,6 +32,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
Rating::new(4).unwrap(),
None,
Utc::now().naive_utc(),
None,
)
.unwrap()
}

View File

@@ -0,0 +1,165 @@
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use domain::{
models::Review,
ports::ReviewRepository,
testing::{InMemoryReviewRepository, NoopEventPublisher},
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
};
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
fn make_review(user_id: UserId) -> Review {
Review::new(
MovieId::generate(),
user_id,
Rating::new(3).unwrap(),
Some(Comment::new("original comment".into()).unwrap()),
Utc::now().naive_utc(),
Some(WatchMedium::Streaming),
)
.unwrap()
}
async fn setup() -> (
Arc<InMemoryReviewRepository>,
Arc<NoopEventPublisher>,
ReviewId,
UserId,
) {
let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new();
let user_id = UserId::generate();
let review = make_review(user_id.clone());
let review_id = review.id().clone();
reviews.save_review(&review).await.unwrap();
(reviews, events, review_id, user_id)
}
fn deps(
reviews: &Arc<InMemoryReviewRepository>,
events: &Arc<NoopEventPublisher>,
) -> EditReviewDeps {
EditReviewDeps {
review: Arc::clone(reviews) as _,
event_publisher: Arc::clone(events) as _,
}
}
#[tokio::test]
async fn edit_own_review_updates_rating() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
rating: Some(5),
comment: None,
watched_at: None,
watch_medium: None,
},
)
.await
.unwrap();
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
assert_eq!(updated.rating().value(), 5);
assert_eq!(updated.comment().unwrap().value(), "original comment");
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming));
}
#[tokio::test]
async fn edit_nonexistent_review_returns_not_found() {
let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new();
let err = edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: Uuid::new_v4(),
requesting_user_id: Uuid::new_v4(),
rating: Some(5),
comment: None,
watched_at: None,
watch_medium: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, domain::errors::DomainError::NotFound(_)));
}
#[tokio::test]
async fn edit_other_users_review_returns_forbidden() {
let (reviews, events, review_id, _user_id) = setup().await;
let err = edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: Uuid::new_v4(),
rating: Some(5),
comment: None,
watched_at: None,
watch_medium: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
}
#[tokio::test]
async fn edit_publishes_review_updated_event() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
rating: Some(1),
comment: None,
watched_at: None,
watch_medium: None,
},
)
.await
.unwrap();
let published = events.published();
assert_eq!(published.len(), 1);
assert!(matches!(
published[0],
domain::events::DomainEvent::ReviewUpdated { .. }
));
}
#[tokio::test]
async fn edit_sets_watch_medium() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
rating: None,
comment: None,
watched_at: None,
watch_medium: Some(Some(WatchMedium::Cinema)),
},
)
.await
.unwrap();
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema));
}

View File

@@ -65,6 +65,7 @@ async fn test_log_review_creates_movie_and_review() {
rating: 4,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
log_review::execute(&logger, cmd).await.unwrap();
@@ -97,6 +98,7 @@ async fn test_log_review_reuses_existing_movie() {
rating: 5,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
log_review::execute(&logger, cmd).await.unwrap();
@@ -118,6 +120,7 @@ async fn test_log_review_with_invalid_rating_fails() {
rating: 6,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
let result = log_review::execute(&logger, cmd).await;
assert!(result.is_err(), "rating > 5 should fail");

View File

@@ -55,6 +55,7 @@ async fn logs_review_with_manual_movie() {
rating: 4,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
logger.log_review(cmd).await.unwrap();
@@ -103,6 +104,7 @@ async fn removes_from_watchlist_on_review() {
rating: 5,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
logger.log_review(cmd).await.unwrap();
@@ -140,6 +142,7 @@ async fn logs_review_with_existing_movie_by_id() {
rating: 3,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
logger.log_review(cmd).await.unwrap();
@@ -168,6 +171,7 @@ async fn existing_movie_not_found_returns_error() {
rating: 4,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
assert!(logger.log_review(cmd).await.is_err());
@@ -193,6 +197,7 @@ async fn invalid_rating_returns_error() {
rating: 6,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
let result = logger.log_review(cmd).await;
@@ -222,6 +227,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
rating: 4,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
logger.log_review(cmd).await.unwrap();
@@ -289,6 +295,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
rating: 5,
comment: None,
watched_at: Utc::now().naive_utc(),
watch_medium: None,
};
logger.log_review(cmd).await.unwrap();

View File

@@ -118,5 +118,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
rating,
comment: row.comment.clone(),
watched_at,
watch_medium: None,
})
}

View File

@@ -56,6 +56,7 @@ pub async fn execute(
rating: c.rating,
comment: c.comment,
watched_at: *event.watched_at(),
watch_medium: None,
};
review_logger.log_review(review_cmd).await?;

View File

@@ -2,7 +2,7 @@ use chrono::{NaiveDateTime, Utc};
use crate::{
errors::DomainError,
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
};
use super::movie::Movie;
@@ -25,6 +25,16 @@ pub struct PersistedReview {
pub watched_at: NaiveDateTime,
pub created_at: NaiveDateTime,
pub source: ReviewSource,
pub watch_medium: Option<WatchMedium>,
}
/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set.
#[derive(Clone, Debug, Default)]
pub struct ReviewEdit {
pub rating: Option<Rating>,
pub comment: Option<Option<Comment>>,
pub watched_at: Option<NaiveDateTime>,
pub watch_medium: Option<Option<WatchMedium>>,
}
#[derive(Clone, Debug)]
@@ -37,6 +47,7 @@ pub struct Review {
watched_at: NaiveDateTime,
created_at: NaiveDateTime,
source: ReviewSource,
watch_medium: Option<WatchMedium>,
}
impl Review {
@@ -46,6 +57,7 @@ impl Review {
rating: Rating,
comment: Option<Comment>,
watched_at: NaiveDateTime,
watch_medium: Option<WatchMedium>,
) -> Result<Self, DomainError> {
Ok(Self {
id: ReviewId::generate(),
@@ -56,6 +68,7 @@ impl Review {
watched_at,
created_at: Utc::now().naive_utc(),
source: ReviewSource::Local,
watch_medium,
})
}
@@ -69,6 +82,27 @@ impl Review {
watched_at: row.watched_at,
created_at: row.created_at,
source: row.source,
watch_medium: row.watch_medium,
}
}
pub fn apply_edit(&self, edit: ReviewEdit) -> Self {
Self {
id: self.id.clone(),
movie_id: self.movie_id.clone(),
user_id: self.user_id.clone(),
rating: edit.rating.unwrap_or_else(|| self.rating.clone()),
comment: match edit.comment {
Some(c) => c,
None => self.comment.clone(),
},
watched_at: edit.watched_at.unwrap_or(self.watched_at),
created_at: self.created_at,
source: self.source.clone(),
watch_medium: match edit.watch_medium {
Some(wm) => wm,
None => self.watch_medium,
},
}
}
@@ -96,6 +130,9 @@ impl Review {
pub fn source(&self) -> &ReviewSource {
&self.source
}
pub fn watch_medium(&self) -> Option<&WatchMedium> {
self.watch_medium.as_ref()
}
/// Returns [star1_filled, star2_filled, ..., star5_filled]
pub fn stars(&self) -> [bool; 5] {
let r = self.rating.value();
@@ -157,3 +194,7 @@ impl ReviewHistory {
self.viewings.sort_by_key(|r| *r.watched_at());
}
}
#[cfg(test)]
#[path = "tests/review.rs"]
mod tests;

View File

@@ -78,6 +78,7 @@ fn make_review() -> Review {
Rating::new(4).unwrap(),
Some(Comment::new("great".into()).unwrap()),
chrono::Utc::now().naive_utc(),
None,
)
.unwrap()
}
@@ -112,6 +113,7 @@ fn review_from_persistence() {
source: ReviewSource::Remote {
actor_url: "https://example.com/actor".into(),
},
watch_medium: None,
});
assert_eq!(*r.id(), id);
assert!(r.is_remote());

View File

@@ -0,0 +1,81 @@
use chrono::NaiveDateTime;
use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium};
use super::*;
fn make_review(watch_medium: Option<WatchMedium>) -> Review {
Review::new(
MovieId::generate(),
UserId::generate(),
Rating::new(4).unwrap(),
Some(Comment::new("great film".into()).unwrap()),
NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
watch_medium,
)
.unwrap()
}
#[test]
fn new_review_stores_watch_medium() {
let review = make_review(Some(WatchMedium::Cinema));
assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema));
}
#[test]
fn new_review_without_medium() {
let review = make_review(None);
assert_eq!(review.watch_medium(), None);
}
#[test]
fn apply_edit_updates_rating_only() {
let original = make_review(Some(WatchMedium::Streaming));
let edited = original.apply_edit(ReviewEdit {
rating: Some(Rating::new(2).unwrap()),
..Default::default()
});
assert_eq!(edited.rating().value(), 2);
assert_eq!(edited.comment().unwrap().value(), "great film");
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Streaming));
}
#[test]
fn apply_edit_clears_comment() {
let original = make_review(None);
let edited = original.apply_edit(ReviewEdit {
comment: Some(None),
..Default::default()
});
assert!(edited.comment().is_none());
assert_eq!(edited.rating().value(), 4);
}
#[test]
fn apply_edit_sets_watch_medium() {
let original = make_review(None);
let edited = original.apply_edit(ReviewEdit {
watch_medium: Some(Some(WatchMedium::Cinema)),
..Default::default()
});
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Cinema));
}
#[test]
fn apply_edit_clears_watch_medium() {
let original = make_review(Some(WatchMedium::Cinema));
let edited = original.apply_edit(ReviewEdit {
watch_medium: Some(None),
..Default::default()
});
assert_eq!(edited.watch_medium(), None);
}
#[test]
fn apply_edit_no_changes_preserves_all() {
let original = make_review(Some(WatchMedium::TV));
let edited = original.apply_edit(ReviewEdit::default());
assert_eq!(edited.rating().value(), 4);
assert_eq!(edited.comment().unwrap().value(), "great film");
assert_eq!(edited.watch_medium(), Some(&WatchMedium::TV));
}

View File

@@ -45,6 +45,7 @@ pub trait DiaryRepository: Send + Sync {
pub trait ReviewRepository: Send + Sync {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn update_review(&self, review: &Review) -> Result<(), DomainError>;
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
}

View File

@@ -28,6 +28,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
Rating::new(rating).unwrap(),
None,
watched_at,
None,
)
.unwrap()
}

View File

@@ -189,6 +189,14 @@ impl ReviewRepository for InMemoryReviewRepository {
Ok(self.store.lock().unwrap().get(&review_id.value()).cloned())
}
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.insert(review.id().value(), review.clone());
Ok(())
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
self.store.lock().unwrap().remove(&review_id.value());
Ok(())

View File

@@ -158,3 +158,44 @@ fn password_value_preserves_content() {
let raw = "supersecret!".to_string();
assert_eq!(Password::new(raw.clone()).unwrap().value(), raw);
}
#[test]
fn watch_medium_parses_valid_strings() {
let cases = [
("cinema", WatchMedium::Cinema),
("streaming", WatchMedium::Streaming),
("tv", WatchMedium::TV),
("physical_media", WatchMedium::PhysicalMedia),
("download", WatchMedium::Download),
("media_server", WatchMedium::MediaServer),
("other", WatchMedium::Other),
];
for (input, expected) in cases {
let parsed: WatchMedium = input.parse().unwrap();
assert_eq!(parsed, expected);
}
}
#[test]
fn watch_medium_rejects_invalid() {
assert!("nonsense".parse::<WatchMedium>().is_err());
assert!("".parse::<WatchMedium>().is_err());
}
#[test]
fn watch_medium_display_round_trips() {
let variants = [
WatchMedium::Cinema,
WatchMedium::Streaming,
WatchMedium::TV,
WatchMedium::PhysicalMedia,
WatchMedium::Download,
WatchMedium::MediaServer,
WatchMedium::Other,
];
for v in variants {
let s = v.to_string();
let parsed: WatchMedium = s.parse().unwrap();
assert_eq!(parsed, v);
}
}

View File

@@ -1,5 +1,71 @@
use std::fmt;
use std::str::FromStr;
use crate::errors::DomainError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WatchMedium {
Cinema,
Streaming,
TV,
PhysicalMedia,
Download,
MediaServer,
Other,
}
impl fmt::Display for WatchMedium {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cinema => write!(f, "cinema"),
Self::Streaming => write!(f, "streaming"),
Self::TV => write!(f, "tv"),
Self::PhysicalMedia => write!(f, "physical_media"),
Self::Download => write!(f, "download"),
Self::MediaServer => write!(f, "media_server"),
Self::Other => write!(f, "other"),
}
}
}
impl FromStr for WatchMedium {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cinema" => Ok(Self::Cinema),
"streaming" => Ok(Self::Streaming),
"tv" => Ok(Self::TV),
"physical_media" => Ok(Self::PhysicalMedia),
"download" => Ok(Self::Download),
"media_server" => Ok(Self::MediaServer),
"other" => Ok(Self::Other),
_ => Err(DomainError::ValidationError(format!(
"unknown watch medium: {s}"
))),
}
}
}
pub fn parse_watched_at(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
.or_else(|_| {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
})
.map_err(|_| {
DomainError::ValidationError(format!(
"invalid date '{s}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]"
))
})
}
pub fn format_watched_at(dt: &chrono::NaiveDateTime) -> String {
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rating(u8);

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]

View File

@@ -364,6 +364,7 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
Some(comment)
},
watched_at,
watch_medium: None,
}),
});
}
@@ -856,6 +857,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
rating,
comment,
watched_at,
watch_medium: None,
};
app.loading = true;
return vec![Command::CreateReview(req)];

View File

@@ -54,6 +54,7 @@ fn diary_entry() -> DiaryEntryDto {
rating: 5,
comment: None,
watched_at: "1999-03-31T00:00:00".into(),
watch_medium: None,
},
}
}
@@ -381,6 +382,7 @@ fn bulk_import_all_with_valid_rows_returns_import_next_command() {
rating: 5,
comment: None,
watched_at: "1999-03-31T00:00:00".into(),
watch_medium: None,
}),
}];
}
@@ -403,6 +405,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
rating: 5,
comment: None,
watched_at: "2024-01-01T00:00:00".into(),
watch_medium: None,
},
LogReviewRequest {
external_metadata_id: None,
@@ -412,6 +415,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
rating: 4,
comment: None,
watched_at: "2024-01-02T00:00:00".into(),
watch_medium: None,
},
];
m.bulk_import.results = vec![None, None];
@@ -440,6 +444,7 @@ fn bulk_item_done_last_item_transitions_to_done() {
rating: 5,
comment: None,
watched_at: "2024-01-01T00:00:00".into(),
watch_medium: None,
}];
m.bulk_import.results = vec![None];
}

View File

@@ -23,6 +23,7 @@ fn log_review_request_skips_none_fields() {
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
watch_medium: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("external_metadata_id"));
@@ -42,6 +43,7 @@ fn log_review_request_includes_director_when_set() {
rating: 5,
comment: None,
watched_at: "2024-01-15T20:00:00".into(),
watch_medium: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));