refactor: group use cases into DDD bounded contexts

Flat use_cases/ (44 files) + monolithic commands.rs/queries.rs
split into diary/, movies/, watchlist/, import/, auth/, users/,
integrations/, search/, person/, federation/ — each with own
commands.rs, queries.rs, and use case modules.

Inline tests extracted to sibling tests/ dirs.
This commit is contained in:
2026-06-02 19:49:09 +02:00
parent aadad3cfb0
commit dcc9244d4e
92 changed files with 1617 additions and 1500 deletions

View File

@@ -0,0 +1,12 @@
use chrono::Duration;
use domain::errors::DomainError;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30);
ctx.repos
.watch_event
.delete_non_pending_older_than(cutoff)
.await
}

View File

@@ -0,0 +1,34 @@
use uuid::Uuid;
pub struct IngestWatchEventCommand {
pub token: String,
pub raw_payload: Vec<u8>,
pub source: domain::models::WatchEventSource,
}
pub struct WatchEventConfirmation {
pub watch_event_id: Uuid,
pub rating: u8,
pub comment: Option<String>,
}
pub struct ConfirmWatchEventsCommand {
pub user_id: Uuid,
pub confirmations: Vec<WatchEventConfirmation>,
}
pub struct DismissWatchEventsCommand {
pub user_id: Uuid,
pub event_ids: Vec<Uuid>,
}
pub struct GenerateWebhookTokenCommand {
pub user_id: Uuid,
pub provider: domain::models::WatchEventSource,
pub label: Option<String>,
}
pub struct RevokeWebhookTokenCommand {
pub user_id: Uuid,
pub token_id: Uuid,
}

View File

@@ -0,0 +1,68 @@
use domain::{
errors::DomainError,
models::WatchEventStatus,
value_objects::{UserId, WatchEventId},
};
use crate::{
context::AppContext,
diary::commands::{LogReviewCommand, MovieInput},
diary::log_review,
integrations::commands::ConfirmWatchEventsCommand,
};
pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let mut confirmed = 0u32;
for c in cmd.confirmations {
let event_id = WatchEventId::from_uuid(c.watch_event_id);
let event = ctx
.repos
.watch_event
.get_by_id(&event_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
if event.user_id() != &user_id {
return Err(DomainError::Unauthorized("not your watch event".into()));
}
let input = if let Some(movie_id) = event.movie_id() {
MovieInput {
movie_id: Some(movie_id.value()),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
MovieInput {
movie_id: None,
external_metadata_id: event.external_metadata_id().map(String::from),
manual_title: Some(event.title().to_string()),
manual_release_year: event.year(),
manual_director: None,
}
};
let review_cmd = LogReviewCommand {
user_id: cmd.user_id,
input,
rating: c.rating,
comment: c.comment,
watched_at: *event.watched_at(),
};
log_review::execute(ctx, review_cmd).await?;
ctx.repos
.watch_event
.update_status(&event_id, WatchEventStatus::Confirmed)
.await?;
confirmed += 1;
}
Ok(confirmed)
}

View File

@@ -0,0 +1,35 @@
use domain::{
errors::DomainError,
models::WatchEventStatus,
value_objects::{UserId, WatchEventId},
};
use crate::{context::AppContext, integrations::commands::DismissWatchEventsCommand};
pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let mut dismissed = 0u32;
for id in cmd.event_ids {
let event_id = WatchEventId::from_uuid(id);
let event = ctx
.repos
.watch_event
.get_by_id(&event_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {id}")))?;
if event.user_id() != &user_id {
return Err(DomainError::Unauthorized("not your watch event".into()));
}
ctx.repos
.watch_event
.update_status(&event_id, WatchEventStatus::Dismissed)
.await?;
dismissed += 1;
}
Ok(dismissed)
}

View File

@@ -0,0 +1,38 @@
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
use sha2::{Digest, Sha256};
use crate::{context::AppContext, integrations::commands::GenerateWebhookTokenCommand};
pub struct GeneratedWebhookToken {
pub token_plaintext: String,
pub token: WebhookToken,
}
pub async fn execute(
ctx: &AppContext,
cmd: GenerateWebhookTokenCommand,
) -> Result<GeneratedWebhookToken, DomainError> {
let plaintext = generate_random_token();
let hash = hash_token(&plaintext);
let user_id = UserId::from_uuid(cmd.user_id);
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
ctx.repos.webhook_token.save(&token).await?;
Ok(GeneratedWebhookToken {
token_plaintext: plaintext,
token,
})
}
fn generate_random_token() -> String {
let bytes: [u8; 32] = rand::random();
hex::encode(bytes)
}
pub fn hash_token(plaintext: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(plaintext.as_bytes());
hex::encode(hasher.finalize())
}

View File

@@ -0,0 +1,11 @@
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId};
use crate::{context::AppContext, integrations::queries::GetWatchQueueQuery};
pub async fn execute(
ctx: &AppContext,
query: GetWatchQueueQuery,
) -> Result<Vec<WatchEvent>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
ctx.repos.watch_event.list_pending(&user_id).await
}

View File

@@ -0,0 +1,11 @@
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
use crate::{context::AppContext, integrations::queries::GetWebhookTokensQuery};
pub async fn execute(
ctx: &AppContext,
query: GetWebhookTokensQuery,
) -> Result<Vec<WebhookToken>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
ctx.repos.webhook_token.list_by_user(&user_id).await
}

View File

@@ -0,0 +1,71 @@
use chrono::Duration;
use domain::{
errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser,
};
use crate::{context::AppContext, integrations::commands::IngestWatchEventCommand};
pub async fn execute(
ctx: &AppContext,
cmd: IngestWatchEventCommand,
parser: &dyn MediaServerParser,
) -> Result<(), DomainError> {
let token_hash = super::generate_token::hash_token(&cmd.token);
let webhook_token = ctx
.repos
.webhook_token
.find_by_token_hash(&token_hash)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?;
let _ = ctx
.repos
.webhook_token
.touch_last_used(webhook_token.id())
.await;
let parsed = match parser.parse_playback_event(&cmd.raw_payload)? {
Some(event) => event,
None => return Ok(()),
};
let external_metadata_id = parsed.tmdb_id.or(parsed.imdb_id);
let user_id = webhook_token.user_id().clone();
if let Some(ref ext_id) = external_metadata_id {
let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1);
if ctx
.repos
.watch_event
.find_duplicate(&user_id, ext_id, one_hour_ago)
.await?
{
return Ok(());
}
}
let watched_at = chrono::Utc::now().naive_utc();
let event = WatchEvent::new(
user_id,
parsed.title,
parsed.year,
external_metadata_id,
cmd.source,
watched_at,
None,
);
ctx.repos.watch_event.save(&event).await?;
let _ = ctx
.services
.event_publisher
.publish(&DomainEvent::WatchEventIngested {
user_id: event.user_id().clone(),
title: event.title().to_string(),
source: event.source().to_string(),
})
.await;
Ok(())
}

View File

@@ -0,0 +1,10 @@
pub mod cleanup;
pub mod commands;
pub mod confirm;
pub mod dismiss;
pub mod generate_token;
pub mod get_queue;
pub mod get_tokens;
pub mod ingest;
pub mod queries;
pub mod revoke_token;

View File

@@ -0,0 +1,9 @@
use uuid::Uuid;
pub struct GetWatchQueueQuery {
pub user_id: Uuid,
}
pub struct GetWebhookTokensQuery {
pub user_id: Uuid,
}

View File

@@ -0,0 +1,12 @@
use domain::{
errors::DomainError,
value_objects::{UserId, WebhookTokenId},
};
use crate::{context::AppContext, integrations::commands::RevokeWebhookTokenCommand};
pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
ctx.repos.webhook_token.delete(&token_id, &user_id).await
}