- Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars)
- Rename poster-storage crate to image-storage; serve at /images/{*key}
- Add bio and avatar_path to User model (migration 0009_user_profile)
- update_profile use case with avatar upload, mime validation, old avatar cleanup
- GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page
- Enrich AP Person actor with summary, icon, url, discoverable fields
- Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar)
- Shared inbox delivery via collect_inboxes deduplication
- Broadcast Update(Person) to followers on UserUpdated event
- Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor
- Announce/boost tracking in ap_announces table (migration 0011_ap_announces)
493 lines
11 KiB
Rust
493 lines
11 KiB
Rust
use chrono::{NaiveDateTime, Utc};
|
|
|
|
use crate::{
|
|
errors::DomainError,
|
|
models::collections::PageParams,
|
|
value_objects::{
|
|
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, Rating,
|
|
ReleaseYear, ReviewId, UserId, Username,
|
|
},
|
|
};
|
|
pub mod collections;
|
|
pub mod import;
|
|
pub mod import_session;
|
|
pub mod import_profile;
|
|
|
|
pub use import::{
|
|
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError,
|
|
ImportRow, ParsedFile, RowResult, Transform,
|
|
};
|
|
pub use import_session::ImportSession;
|
|
pub use import_profile::ImportProfile;
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub enum SortDirection {
|
|
#[default]
|
|
Descending,
|
|
Ascending,
|
|
ByRatingDesc,
|
|
ByRatingAsc,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct DiaryFilter {
|
|
pub sort_by: SortDirection,
|
|
pub page: PageParams,
|
|
pub movie_id: Option<MovieId>,
|
|
pub user_id: Option<UserId>,
|
|
pub search: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Movie {
|
|
id: MovieId,
|
|
external_metadata_id: Option<ExternalMetadataId>,
|
|
title: MovieTitle,
|
|
release_year: ReleaseYear,
|
|
director: Option<String>,
|
|
poster_path: Option<PosterPath>,
|
|
}
|
|
|
|
impl Movie {
|
|
pub fn new(
|
|
external_metadata_id: Option<ExternalMetadataId>,
|
|
title: MovieTitle,
|
|
release_year: ReleaseYear,
|
|
director: Option<String>,
|
|
poster_path: Option<PosterPath>,
|
|
) -> Self {
|
|
Self {
|
|
id: MovieId::generate(),
|
|
external_metadata_id,
|
|
title,
|
|
release_year,
|
|
director,
|
|
poster_path,
|
|
}
|
|
}
|
|
|
|
pub fn from_persistence(
|
|
id: MovieId,
|
|
external_metadata_id: Option<ExternalMetadataId>,
|
|
title: MovieTitle,
|
|
release_year: ReleaseYear,
|
|
director: Option<String>,
|
|
poster_path: Option<PosterPath>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
external_metadata_id,
|
|
title,
|
|
release_year,
|
|
director,
|
|
poster_path,
|
|
}
|
|
}
|
|
|
|
pub fn update_poster(&mut self, poster_path: PosterPath) {
|
|
self.poster_path = Some(poster_path);
|
|
}
|
|
|
|
pub fn id(&self) -> &MovieId {
|
|
&self.id
|
|
}
|
|
pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> {
|
|
self.external_metadata_id.as_ref()
|
|
}
|
|
pub fn title(&self) -> &MovieTitle {
|
|
&self.title
|
|
}
|
|
pub fn release_year(&self) -> &ReleaseYear {
|
|
&self.release_year
|
|
}
|
|
pub fn director(&self) -> Option<&str> {
|
|
self.director.as_deref()
|
|
}
|
|
pub fn poster_path(&self) -> Option<&PosterPath> {
|
|
self.poster_path.as_ref()
|
|
}
|
|
}
|
|
|
|
impl Movie {
|
|
pub fn is_manual_match(
|
|
&self,
|
|
title: &MovieTitle,
|
|
year: &ReleaseYear,
|
|
director: Option<&str>,
|
|
) -> bool {
|
|
if self.title != *title || self.release_year != *year {
|
|
return false;
|
|
}
|
|
|
|
match (self.director(), director) {
|
|
(Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir),
|
|
_ => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum ReviewSource {
|
|
Local,
|
|
Remote { actor_url: String },
|
|
}
|
|
|
|
impl Default for ReviewSource {
|
|
fn default() -> Self {
|
|
ReviewSource::Local
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Review {
|
|
id: ReviewId,
|
|
movie_id: MovieId,
|
|
user_id: UserId,
|
|
rating: Rating,
|
|
comment: Option<Comment>,
|
|
watched_at: chrono::NaiveDateTime,
|
|
created_at: chrono::NaiveDateTime,
|
|
source: ReviewSource,
|
|
}
|
|
|
|
impl Review {
|
|
pub fn new(
|
|
movie_id: MovieId,
|
|
user_id: UserId,
|
|
rating: Rating,
|
|
comment: Option<Comment>,
|
|
watched_at: NaiveDateTime,
|
|
) -> Result<Self, DomainError> {
|
|
Ok(Self {
|
|
id: ReviewId::generate(),
|
|
movie_id,
|
|
user_id,
|
|
rating,
|
|
comment,
|
|
watched_at,
|
|
created_at: Utc::now().naive_utc(),
|
|
source: ReviewSource::Local,
|
|
})
|
|
}
|
|
|
|
pub fn from_persistence(
|
|
id: ReviewId,
|
|
movie_id: MovieId,
|
|
user_id: UserId,
|
|
rating: Rating,
|
|
comment: Option<Comment>,
|
|
watched_at: NaiveDateTime,
|
|
created_at: NaiveDateTime,
|
|
source: ReviewSource,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
movie_id,
|
|
user_id,
|
|
rating,
|
|
comment,
|
|
watched_at,
|
|
created_at,
|
|
source,
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> &ReviewId {
|
|
&self.id
|
|
}
|
|
pub fn movie_id(&self) -> &MovieId {
|
|
&self.movie_id
|
|
}
|
|
pub fn user_id(&self) -> &UserId {
|
|
&self.user_id
|
|
}
|
|
pub fn rating(&self) -> &Rating {
|
|
&self.rating
|
|
}
|
|
pub fn comment(&self) -> Option<&Comment> {
|
|
self.comment.as_ref()
|
|
}
|
|
pub fn watched_at(&self) -> &NaiveDateTime {
|
|
&self.watched_at
|
|
}
|
|
pub fn created_at(&self) -> &NaiveDateTime {
|
|
&self.created_at
|
|
}
|
|
pub fn source(&self) -> &ReviewSource {
|
|
&self.source
|
|
}
|
|
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
|
pub fn stars(&self) -> [bool; 5] {
|
|
let r = self.rating.value();
|
|
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
|
|
}
|
|
|
|
pub fn is_remote(&self) -> bool {
|
|
matches!(self.source, ReviewSource::Remote { .. })
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DiaryEntry {
|
|
movie: Movie,
|
|
review: Review,
|
|
}
|
|
|
|
impl DiaryEntry {
|
|
pub fn new(movie: Movie, review: Review) -> Self {
|
|
Self { movie, review }
|
|
}
|
|
|
|
pub fn movie(&self) -> &Movie {
|
|
&self.movie
|
|
}
|
|
pub fn review(&self) -> &Review {
|
|
&self.review
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ReviewHistory {
|
|
movie: Movie,
|
|
viewings: Vec<Review>,
|
|
}
|
|
|
|
impl ReviewHistory {
|
|
pub fn new(movie: Movie, viewings: Vec<Review>) -> Self {
|
|
Self { movie, viewings }
|
|
}
|
|
|
|
pub fn movie(&self) -> &Movie {
|
|
&self.movie
|
|
}
|
|
pub fn viewings(&self) -> &[Review] {
|
|
&self.viewings
|
|
}
|
|
pub fn sort_by_date(&mut self) {
|
|
self.viewings.sort_by_key(|r| *r.watched_at());
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct MovieStats {
|
|
pub total_count: u64,
|
|
pub avg_rating: Option<f64>,
|
|
pub federated_count: u64,
|
|
pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub enum UserRole {
|
|
#[default]
|
|
Standard,
|
|
Admin,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct User {
|
|
id: UserId,
|
|
email: Email,
|
|
username: Username,
|
|
password_hash: PasswordHash,
|
|
role: UserRole,
|
|
bio: Option<String>,
|
|
avatar_path: Option<String>,
|
|
}
|
|
|
|
impl User {
|
|
pub fn new(
|
|
email: Email,
|
|
username: Username,
|
|
password_hash: PasswordHash,
|
|
role: UserRole,
|
|
) -> Self {
|
|
Self {
|
|
id: UserId::generate(),
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role,
|
|
bio: None,
|
|
avatar_path: None,
|
|
}
|
|
}
|
|
|
|
pub fn from_persistence(
|
|
id: UserId,
|
|
email: Email,
|
|
username: Username,
|
|
password_hash: PasswordHash,
|
|
role: UserRole,
|
|
bio: Option<String>,
|
|
avatar_path: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role,
|
|
bio,
|
|
avatar_path,
|
|
}
|
|
}
|
|
|
|
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
|
self.password_hash = new_hash;
|
|
}
|
|
|
|
pub fn update_profile(&mut self, bio: Option<String>, avatar_path: Option<String>) {
|
|
self.bio = bio;
|
|
self.avatar_path = avatar_path;
|
|
}
|
|
|
|
pub fn email(&self) -> &Email {
|
|
&self.email
|
|
}
|
|
pub fn username(&self) -> &Username {
|
|
&self.username
|
|
}
|
|
pub fn id(&self) -> &UserId {
|
|
&self.id
|
|
}
|
|
pub fn password_hash(&self) -> &PasswordHash {
|
|
&self.password_hash
|
|
}
|
|
pub fn role(&self) -> &UserRole {
|
|
&self.role
|
|
}
|
|
pub fn bio(&self) -> Option<&str> {
|
|
self.bio.as_deref()
|
|
}
|
|
|
|
pub fn avatar_path(&self) -> Option<&str> {
|
|
self.avatar_path.as_deref()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct FeedEntry {
|
|
entry: DiaryEntry,
|
|
user_email: String,
|
|
}
|
|
|
|
impl FeedEntry {
|
|
pub fn new(entry: DiaryEntry, user_email: String) -> Self {
|
|
Self { entry, user_email }
|
|
}
|
|
pub fn movie(&self) -> &Movie {
|
|
self.entry.movie()
|
|
}
|
|
pub fn review(&self) -> &Review {
|
|
self.entry.review()
|
|
}
|
|
pub fn user_email(&self) -> &str {
|
|
&self.user_email
|
|
}
|
|
pub fn user_display_name(&self) -> &str {
|
|
self.user_email
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or(&self.user_email)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct UserSummary {
|
|
pub user_id: UserId,
|
|
email: Email,
|
|
pub total_movies: i64,
|
|
pub avg_rating: Option<f64>,
|
|
}
|
|
|
|
impl UserSummary {
|
|
pub fn new(user_id: UserId, email: Email, total_movies: i64, avg_rating: Option<f64>) -> Self {
|
|
Self {
|
|
user_id,
|
|
email,
|
|
total_movies,
|
|
avg_rating,
|
|
}
|
|
}
|
|
pub fn email(&self) -> &str {
|
|
self.email.value()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct UserStats {
|
|
pub total_movies: i64,
|
|
pub avg_rating: Option<f64>,
|
|
pub favorite_director: Option<String>,
|
|
pub most_active_month: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct MonthActivity {
|
|
pub year_month: String,
|
|
pub month_label: String,
|
|
pub count: i64,
|
|
pub entries: Vec<DiaryEntry>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct MonthlyRating {
|
|
pub year_month: String,
|
|
pub month_label: String,
|
|
pub avg_rating: f64,
|
|
pub count: i64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DirectorStat {
|
|
pub director: String,
|
|
pub count: i64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct UserTrends {
|
|
pub monthly_ratings: Vec<MonthlyRating>,
|
|
pub top_directors: Vec<DirectorStat>,
|
|
pub max_director_count: i64,
|
|
}
|
|
|
|
pub enum ExportFormat {
|
|
Csv,
|
|
Json,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
|
|
|
fn make_user() -> User {
|
|
User::from_persistence(
|
|
UserId::generate(),
|
|
Email::new("a@b.com".to_string()).unwrap(),
|
|
Username::new("alice".to_string()).unwrap(),
|
|
PasswordHash::new("hash".to_string()).unwrap(),
|
|
UserRole::Standard,
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn update_profile_sets_fields() {
|
|
let mut user = make_user();
|
|
user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string()));
|
|
assert_eq!(user.bio(), Some("My bio"));
|
|
assert_eq!(user.avatar_path(), Some("avatars/abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn update_profile_clears_with_none() {
|
|
let mut user = make_user();
|
|
user.update_profile(Some("bio".to_string()), Some("path".to_string()));
|
|
user.update_profile(None, None);
|
|
assert_eq!(user.bio(), None);
|
|
assert_eq!(user.avatar_path(), None);
|
|
}
|
|
}
|