18
crates/application/Cargo.toml
Normal file
18
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "application"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
21
crates/application/src/activity/commands.rs
Normal file
21
crates/application/src/activity/commands.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use domain::activity::{ActivityId, ActivityName, CategoryName};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CreateActivityCommand {
|
||||
pub user_id: UserId,
|
||||
pub name: ActivityName,
|
||||
pub category: Option<CategoryName>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RenameActivityCommand {
|
||||
pub activity_id: ActivityId,
|
||||
pub new_name: ActivityName,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetCategoryCommand {
|
||||
pub activity_id: ActivityId,
|
||||
pub category: Option<CategoryName>,
|
||||
}
|
||||
2
crates/application/src/activity/mod.rs
Normal file
2
crates/application/src/activity/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort, EventPublisherPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn archive(
|
||||
activity_id: ActivityId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut activity = deps
|
||||
.query
|
||||
.find_by_id(&activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
|
||||
activity.archive()?;
|
||||
deps.command.save(&activity).await?;
|
||||
|
||||
let event = DomainEvent::ActivityArchived {
|
||||
activity_id: activity.id().clone(),
|
||||
user_id: activity.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn unarchive(
|
||||
activity_id: ActivityId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut activity = deps
|
||||
.query
|
||||
.find_by_id(&activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
|
||||
activity.unarchive()?;
|
||||
deps.command.save(&activity).await?;
|
||||
|
||||
let event = DomainEvent::ActivityUnarchived {
|
||||
activity_id: activity.id().clone(),
|
||||
user_id: activity.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
32
crates/application/src/activity/use_cases/create_activity.rs
Normal file
32
crates/application/src/activity/use_cases/create_activity.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::Activity;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{ActivityCommandPort, EventPublisherPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::CreateActivityCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub activities: Arc<dyn ActivityCommandPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: CreateActivityCommand,
|
||||
deps: &Deps,
|
||||
) -> Result<Activity, ApplicationError> {
|
||||
let activity = Activity::new(cmd.user_id, cmd.name, cmd.category);
|
||||
|
||||
deps.activities.save(&activity).await?;
|
||||
|
||||
let event = DomainEvent::ActivityCreated {
|
||||
activity_id: activity.id().clone(),
|
||||
user_id: activity.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(activity)
|
||||
}
|
||||
33
crates/application/src/activity/use_cases/delete_activity.rs
Normal file
33
crates/application/src/activity/use_cases/delete_activity.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
activity_id: ActivityId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let activity = deps
|
||||
.query
|
||||
.find_by_id(&activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
|
||||
deps.command.delete(&activity_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
29
crates/application/src/activity/use_cases/get_activity.rs
Normal file
29
crates/application/src/activity/use_cases/get_activity.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ActivityQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
activity_id: ActivityId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Activity, ApplicationError> {
|
||||
let activity = deps
|
||||
.query
|
||||
.find_by_id(&activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
Ok(activity)
|
||||
}
|
||||
21
crates/application/src/activity/use_cases/list_activities.rs
Normal file
21
crates/application/src/activity/use_cases/list_activities.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::Activity;
|
||||
use domain::ports::ActivityQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn all(user_id: UserId, deps: &Deps) -> Result<Vec<Activity>, ApplicationError> {
|
||||
Ok(deps.query.find_by_user(&user_id).await?)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn active_only(user_id: UserId, deps: &Deps) -> Result<Vec<Activity>, ApplicationError> {
|
||||
Ok(deps.query.find_active_by_user(&user_id).await?)
|
||||
}
|
||||
7
crates/application/src/activity/use_cases/mod.rs
Normal file
7
crates/application/src/activity/use_cases/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod archive_activity;
|
||||
pub mod create_activity;
|
||||
pub mod delete_activity;
|
||||
pub mod get_activity;
|
||||
pub mod list_activities;
|
||||
pub mod rename_activity;
|
||||
pub mod set_category;
|
||||
46
crates/application/src/activity/use_cases/rename_activity.rs
Normal file
46
crates/application/src/activity/use_cases/rename_activity.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort, EventPublisherPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::RenameActivityCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: RenameActivityCommand,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut activity = deps
|
||||
.query
|
||||
.find_by_id(&cmd.activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
|
||||
let old_name = activity.name().clone();
|
||||
activity.rename(cmd.new_name.clone());
|
||||
deps.command.save(&activity).await?;
|
||||
|
||||
let event = DomainEvent::ActivityRenamed {
|
||||
activity_id: activity.id().clone(),
|
||||
user_id: activity.user_id().clone(),
|
||||
old_name,
|
||||
new_name: cmd.new_name,
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
crates/application/src/activity/use_cases/set_category.rs
Normal file
35
crates/application/src/activity/use_cases/set_category.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::SetCategoryCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: SetCategoryCommand,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut activity = deps
|
||||
.query
|
||||
.find_by_id(&cmd.activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
|
||||
activity.set_category(cmd.category);
|
||||
deps.command.save(&activity).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1
crates/application/src/auth/mod.rs
Normal file
1
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
72
crates/application/src/auth/use_cases/login.rs
Normal file
72
crates/application/src/auth/use_cases/login.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::auth::{GeneratedToken, RefreshSession};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{
|
||||
AuthServicePort, PasswordHasherPort, RefreshSessionCommandPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::{Email, User, Username};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::commands::LoginCommand;
|
||||
|
||||
pub struct AuthResult {
|
||||
pub user: User,
|
||||
pub access_token: GeneratedToken,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
pub auth_service: Arc<dyn AuthServicePort>,
|
||||
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
|
||||
pub refresh_token_ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(identifier = %cmd.identifier))]
|
||||
pub async fn execute(cmd: LoginCommand, deps: &Deps) -> Result<AuthResult, ApplicationError> {
|
||||
let user = find_user_by_identifier(&cmd.identifier, &deps.user_query).await?;
|
||||
|
||||
let valid = deps
|
||||
.password_hasher
|
||||
.verify(&cmd.password, user.password_hash())?;
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(user_id = %user.id(), "failed login attempt");
|
||||
return Err(DomainError::Unauthorized("invalid credentials".into()).into());
|
||||
}
|
||||
|
||||
tracing::info!(user_id = %user.id(), "login successful");
|
||||
|
||||
let access_token = deps.auth_service.generate_token(user.id()).await?;
|
||||
|
||||
let session = RefreshSession::new(user.id().clone(), deps.refresh_token_ttl_seconds);
|
||||
let refresh_token = session.token().to_string();
|
||||
deps.refresh_session_command.create(&session).await?;
|
||||
|
||||
Ok(AuthResult {
|
||||
user,
|
||||
access_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_user_by_identifier(
|
||||
identifier: &str,
|
||||
query: &Arc<dyn UserQueryPort>,
|
||||
) -> Result<User, ApplicationError> {
|
||||
if let Ok(email) = Email::new(identifier)
|
||||
&& let Some(user) = query.find_by_email(&email).await?
|
||||
{
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
if let Ok(username) = Username::new(identifier)
|
||||
&& let Some(user) = query.find_by_username(&username).await?
|
||||
{
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
Err(DomainError::Unauthorized("invalid credentials".into()).into())
|
||||
}
|
||||
15
crates/application/src/auth/use_cases/logout.rs
Normal file
15
crates/application/src/auth/use_cases/logout.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::RefreshSessionCommandPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn execute(refresh_token: &str, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.refresh_session_command.revoke(refresh_token).await?;
|
||||
Ok(())
|
||||
}
|
||||
3
crates/application/src/auth/use_cases/mod.rs
Normal file
3
crates/application/src/auth/use_cases/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod refresh;
|
||||
51
crates/application/src/auth/use_cases/refresh.rs
Normal file
51
crates/application/src/auth/use_cases/refresh.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::auth::{GeneratedToken, RefreshSession};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{AuthServicePort, RefreshSessionCommandPort, RefreshSessionQueryPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct RefreshResult {
|
||||
pub access_token: GeneratedToken,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub auth_service: Arc<dyn AuthServicePort>,
|
||||
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
|
||||
pub refresh_session_query: Arc<dyn RefreshSessionQueryPort>,
|
||||
pub refresh_token_ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn execute(
|
||||
old_refresh_token: &str,
|
||||
deps: &Deps,
|
||||
) -> Result<RefreshResult, ApplicationError> {
|
||||
let session = deps
|
||||
.refresh_session_query
|
||||
.find_by_token(old_refresh_token)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?;
|
||||
|
||||
deps.refresh_session_command
|
||||
.revoke(old_refresh_token)
|
||||
.await?;
|
||||
|
||||
if session.is_expired() {
|
||||
return Err(DomainError::Unauthorized("refresh token expired".into()).into());
|
||||
}
|
||||
|
||||
let access_token = deps.auth_service.generate_token(session.user_id()).await?;
|
||||
|
||||
let new_session =
|
||||
RefreshSession::new(session.user_id().clone(), deps.refresh_token_ttl_seconds);
|
||||
let refresh_token = new_session.token().to_string();
|
||||
deps.refresh_session_command.create(&new_session).await?;
|
||||
|
||||
Ok(RefreshResult {
|
||||
access_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
11
crates/application/src/authorization.rs
Normal file
11
crates/application/src/authorization.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub fn verify_ownership(resource_owner: &UserId, caller: &UserId) -> Result<(), ApplicationError> {
|
||||
if resource_owner != caller {
|
||||
return Err(DomainError::Forbidden("access denied".into()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
28
crates/application/src/entry/commands.rs
Normal file
28
crates/application/src/entry/commands.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntryId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CreateEntryCommand {
|
||||
pub user_id: UserId,
|
||||
pub mood: Mood,
|
||||
pub logged_at: DateTime<FixedOffset>,
|
||||
pub activities: Vec<ActivityId>,
|
||||
pub content: Option<Content>,
|
||||
pub photos: Vec<PhotoId>,
|
||||
pub voice_memos: Vec<VoiceMemoId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateEntryCommand {
|
||||
pub entry_id: MoodEntryId,
|
||||
pub mood: Mood,
|
||||
pub logged_at: DateTime<FixedOffset>,
|
||||
pub activities: Vec<ActivityId>,
|
||||
pub content: Option<Content>,
|
||||
pub photos: Vec<PhotoId>,
|
||||
pub voice_memos: Vec<VoiceMemoId>,
|
||||
}
|
||||
3
crates/application/src/entry/mod.rs
Normal file
3
crates/application/src/entry/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod queries;
|
||||
pub mod use_cases;
|
||||
29
crates/application/src/entry/queries.rs
Normal file
29
crates/application/src/entry/queries.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, Mood};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListEntriesQuery {
|
||||
pub user_id: UserId,
|
||||
pub range: Option<DateRange>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FilterByMoodQuery {
|
||||
pub user_id: UserId,
|
||||
pub mood: Mood,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FilterByActivityQuery {
|
||||
pub user_id: UserId,
|
||||
pub activity_id: ActivityId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MoodStatsQuery {
|
||||
pub user_id: UserId,
|
||||
pub range: Option<DateRange>,
|
||||
}
|
||||
34
crates/application/src/entry/use_cases/create_entry.rs
Normal file
34
crates/application/src/entry/use_cases/create_entry.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EventPublisherPort, MoodEntryCommandPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::CreateEntryCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryCommandPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: CreateEntryCommand, deps: &Deps) -> Result<MoodEntry, ApplicationError> {
|
||||
let mut entry = MoodEntry::new(cmd.user_id, cmd.mood, cmd.logged_at);
|
||||
|
||||
entry.set_content(cmd.content);
|
||||
entry.set_activities(cmd.activities);
|
||||
entry.set_photos(cmd.photos);
|
||||
entry.set_voice_memos(cmd.voice_memos);
|
||||
|
||||
deps.entries.save(&entry).await?;
|
||||
|
||||
let event = DomainEvent::EntryCreated {
|
||||
entry_id: entry.id().clone(),
|
||||
user_id: entry.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::DateRange;
|
||||
use domain::ports::{CascadeDeletePort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
range: &DateRange,
|
||||
deps: &Deps,
|
||||
) -> Result<u64, ApplicationError> {
|
||||
let entries = deps
|
||||
.cascade
|
||||
.delete_entries_in_range(&user_id, range)
|
||||
.await?;
|
||||
|
||||
for entry in &entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = deps.media_storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%e, "failed to delete photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries.len() as u64)
|
||||
}
|
||||
55
crates/application/src/entry/use_cases/delete_entry.rs
Normal file
55
crates/application/src/entry/use_cases/delete_entry.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
EventPublisherPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
entry_id: MoodEntryId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let entry = deps
|
||||
.query
|
||||
.find_by_id(&entry_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("mood entry not found".into()))?;
|
||||
|
||||
verify_ownership(entry.user_id(), &caller_id)?;
|
||||
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = deps.media_storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%e, "failed to delete photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
|
||||
deps.command.delete(&entry_id).await?;
|
||||
|
||||
let event = DomainEvent::EntryDeleted {
|
||||
entry_id,
|
||||
user_id: entry.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
24
crates/application/src/entry/use_cases/filter_by_activity.rs
Normal file
24
crates/application/src/entry/use_cases/filter_by_activity.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::FilterByActivityQuery;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: FilterByActivityQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = deps
|
||||
.query
|
||||
.find_by_activity(&query.user_id, &query.activity_id)
|
||||
.await?;
|
||||
Ok(entries)
|
||||
}
|
||||
21
crates/application/src/entry/use_cases/filter_by_mood.rs
Normal file
21
crates/application/src/entry/use_cases/filter_by_mood.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::FilterByMoodQuery;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: FilterByMoodQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = deps.query.find_by_mood(&query.user_id, query.mood).await?;
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::DateRange;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::services::MoodAnalyzerService;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
activity_id: ActivityId,
|
||||
range: Option<DateRange>,
|
||||
deps: &Deps,
|
||||
) -> Result<Option<f64>, ApplicationError> {
|
||||
let entries = match range {
|
||||
Some(range) => deps.query.find_by_date_range(&user_id, &range).await?,
|
||||
None => deps.query.find_by_user(&user_id, None, None).await?,
|
||||
};
|
||||
|
||||
Ok(MoodAnalyzerService::activity_mood_correlation(
|
||||
&entries,
|
||||
&activity_id,
|
||||
))
|
||||
}
|
||||
68
crates/application/src/entry/use_cases/get_calendar.rs
Normal file
68
crates/application/src/entry/use_cases/get_calendar.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use domain::entry::{DateRange, Mood, MoodEntry};
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct CalendarDay {
|
||||
pub date: NaiveDate,
|
||||
pub entries: Vec<MoodEntry>,
|
||||
pub dominant_mood: Option<Mood>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
range: DateRange,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<CalendarDay>, ApplicationError> {
|
||||
let entries = deps.query.find_by_date_range(&user_id, &range).await?;
|
||||
|
||||
let mut by_date: BTreeMap<NaiveDate, Vec<MoodEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let date = entry.logged_at().date_naive();
|
||||
by_date.entry(date).or_default().push(entry);
|
||||
}
|
||||
|
||||
let days = by_date
|
||||
.into_iter()
|
||||
.map(|(date, entries)| {
|
||||
let dominant_mood = find_dominant_mood(&entries);
|
||||
CalendarDay {
|
||||
date,
|
||||
entries,
|
||||
dominant_mood,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
fn find_dominant_mood(entries: &[MoodEntry]) -> Option<Mood> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut counts = [0u32; 5];
|
||||
for entry in entries {
|
||||
counts[entry.mood().value() as usize - 1] += 1;
|
||||
}
|
||||
|
||||
let max_index = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, count)| *count)
|
||||
.map(|(i, _)| i)?;
|
||||
|
||||
Mood::try_from(max_index as u8 + 1).ok()
|
||||
}
|
||||
29
crates/application/src/entry/use_cases/get_entry.rs
Normal file
29
crates/application/src/entry/use_cases/get_entry.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::{MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
entry_id: MoodEntryId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<MoodEntry, ApplicationError> {
|
||||
let entry = deps
|
||||
.query
|
||||
.find_by_id(&entry_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("mood entry not found".into()))?;
|
||||
|
||||
verify_ownership(entry.user_id(), &caller_id)?;
|
||||
Ok(entry)
|
||||
}
|
||||
39
crates/application/src/entry/use_cases/get_mood_stats.rs
Normal file
39
crates/application/src/entry/use_cases/get_mood_stats.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::services::MoodAnalyzerService;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::MoodStatsQuery;
|
||||
|
||||
pub struct MoodStats {
|
||||
pub average: Option<f64>,
|
||||
pub frequency: Vec<(Mood, usize)>,
|
||||
pub current_streak: usize,
|
||||
pub total_entries: usize,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, ApplicationError> {
|
||||
let entries = match query.range {
|
||||
Some(range) => {
|
||||
deps.query
|
||||
.find_by_date_range(&query.user_id, &range)
|
||||
.await?
|
||||
}
|
||||
None => deps.query.find_by_user(&query.user_id, None, None).await?,
|
||||
};
|
||||
|
||||
Ok(MoodStats {
|
||||
average: MoodAnalyzerService::average_mood(&entries),
|
||||
frequency: MoodAnalyzerService::mood_frequency(&entries),
|
||||
current_streak: MoodAnalyzerService::current_streak(&entries),
|
||||
total_entries: entries.len(),
|
||||
})
|
||||
}
|
||||
33
crates/application/src/entry/use_cases/list_entries.rs
Normal file
33
crates/application/src/entry/use_cases/list_entries.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::ListEntriesQuery;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: ListEntriesQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = match query.range {
|
||||
Some(range) => {
|
||||
deps.query
|
||||
.find_by_date_range(&query.user_id, &range)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
deps.query
|
||||
.find_by_user(&query.user_id, query.limit, query.offset)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
12
crates/application/src/entry/use_cases/mod.rs
Normal file
12
crates/application/src/entry/use_cases/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod create_entry;
|
||||
pub mod delete_entries_by_date_range;
|
||||
pub mod delete_entry;
|
||||
pub mod filter_by_activity;
|
||||
pub mod filter_by_mood;
|
||||
pub mod get_activity_correlation;
|
||||
pub mod get_calendar;
|
||||
pub mod get_entry;
|
||||
pub mod get_mood_stats;
|
||||
pub mod list_entries;
|
||||
pub mod replace_activity;
|
||||
pub mod update_entry;
|
||||
37
crates/application/src/entry/use_cases/replace_activity.rs
Normal file
37
crates/application/src/entry/use_cases/replace_activity.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ActivityQueryPort, MoodEntryCommandPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
old_activity_id: ActivityId,
|
||||
new_activity_id: ActivityId,
|
||||
deps: &Deps,
|
||||
) -> Result<u64, ApplicationError> {
|
||||
let new_activity = deps
|
||||
.activity_query
|
||||
.find_by_id(&new_activity_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("target activity not found".into()))?;
|
||||
|
||||
verify_ownership(new_activity.user_id(), &user_id)?;
|
||||
|
||||
let updated = deps
|
||||
.entry_command
|
||||
.replace_activity(&user_id, &old_activity_id, &new_activity_id)
|
||||
.await?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
71
crates/application/src/entry/use_cases/update_entry.rs
Normal file
71
crates/application/src/entry/use_cases/update_entry.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
EventPublisherPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::UpdateEntryCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: UpdateEntryCommand,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<MoodEntry, ApplicationError> {
|
||||
let mut entry = deps
|
||||
.query
|
||||
.find_by_id(&cmd.entry_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("mood entry not found".into()))?;
|
||||
|
||||
verify_ownership(entry.user_id(), &caller_id)?;
|
||||
|
||||
let old_photos = entry.photos().to_vec();
|
||||
let old_memos = entry.voice_memos().to_vec();
|
||||
|
||||
entry.update_mood(cmd.mood);
|
||||
entry.update_logged_at(cmd.logged_at);
|
||||
entry.set_content(cmd.content);
|
||||
entry.set_activities(cmd.activities);
|
||||
entry.set_photos(cmd.photos);
|
||||
entry.set_voice_memos(cmd.voice_memos);
|
||||
|
||||
for photo_id in &old_photos {
|
||||
if !entry.photos().contains(photo_id)
|
||||
&& let Err(e) = deps.media_storage.delete_photo(photo_id).await
|
||||
{
|
||||
tracing::warn!(%e, "failed to delete removed photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in &old_memos {
|
||||
if !entry.voice_memos().contains(memo_id)
|
||||
&& let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await
|
||||
{
|
||||
tracing::warn!(%e, "failed to delete removed voice memo blob");
|
||||
}
|
||||
}
|
||||
|
||||
deps.command.save(&entry).await?;
|
||||
|
||||
let event = DomainEvent::EntryUpdated {
|
||||
entry_id: entry.id().clone(),
|
||||
user_id: entry.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
10
crates/application/src/errors.rs
Normal file
10
crates/application/src/errors.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApplicationError {
|
||||
#[error(transparent)]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
#[error("validation failed: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
1
crates/application/src/export/mod.rs
Normal file
1
crates/application/src/export/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
65
crates/application/src/export/use_cases/export_user_data.rs
Normal file
65
crates/application/src/export/use_cases/export_user_data.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, ExportPort, MediaBlob, MediaStoragePort, MoodEntryQueryPort,
|
||||
ReminderQueryPort, UserExport,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub exporter: Arc<dyn ExportPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<u8>, ApplicationError> {
|
||||
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
|
||||
let activities = deps.activity_query.find_by_user(&user_id).await?;
|
||||
let reminders = deps.reminder_query.find_by_user(&user_id).await?;
|
||||
|
||||
let mut photos = Vec::new();
|
||||
let mut voice_memos = Vec::new();
|
||||
|
||||
for entry in &entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Some(file) = deps.media_storage.get_photo(photo_id).await? {
|
||||
photos.push(MediaBlob {
|
||||
id: photo_id.value().to_string(),
|
||||
data: file.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
if let Some(file) = deps.media_storage.get_voice_memo(voice_memo_id).await? {
|
||||
voice_memos.push(MediaBlob {
|
||||
id: voice_memo_id.value().to_string(),
|
||||
data: file.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
entries = entries.len(),
|
||||
activities = activities.len(),
|
||||
photos = photos.len(),
|
||||
voice_memos = voice_memos.len(),
|
||||
"exporting user data"
|
||||
);
|
||||
|
||||
let export = UserExport {
|
||||
entries,
|
||||
activities,
|
||||
reminders,
|
||||
photos,
|
||||
voice_memos,
|
||||
};
|
||||
|
||||
let data = deps.exporter.export_user_data(&export).await?;
|
||||
Ok(data)
|
||||
}
|
||||
1
crates/application/src/export/use_cases/mod.rs
Normal file
1
crates/application/src/export/use_cases/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod export_user_data;
|
||||
7
crates/application/src/import/commands.rs
Normal file
7
crates/application/src/import/commands.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportCommand {
|
||||
pub user_id: UserId,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
2
crates/application/src/import/mod.rs
Normal file
2
crates/application/src/import/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
167
crates/application/src/import/use_cases/import_entries.rs
Normal file
167
crates/application/src/import/use_cases/import_entries.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::FixedOffset;
|
||||
|
||||
use config::PresetConfig;
|
||||
use domain::activity::{ActivityId, ActivityName, CategoryName};
|
||||
use domain::entry::{Content, Mood, MoodEntry};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, ImportSourcePort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort,
|
||||
};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::ImportCommand;
|
||||
|
||||
pub struct ImportResult {
|
||||
pub imported: u64,
|
||||
pub skipped: u64,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub source: Arc<dyn ImportSourcePort>,
|
||||
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub preset: PresetConfig,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, ApplicationError> {
|
||||
let rows = deps.source.read_entries(&cmd.data).await?;
|
||||
tracing::info!(row_count = rows.len(), "parsed import data");
|
||||
|
||||
let existing_entries = deps
|
||||
.entry_query
|
||||
.find_by_user(&cmd.user_id, None, None)
|
||||
.await?;
|
||||
let existing_keys: HashSet<(String, u8)> = existing_entries
|
||||
.iter()
|
||||
.map(|e| (e.logged_at().to_rfc3339(), e.mood().value()))
|
||||
.collect();
|
||||
|
||||
let existing_activities = deps.activity_query.find_by_user(&cmd.user_id).await?;
|
||||
let mut activity_cache: HashMap<String, ActivityId> = existing_activities
|
||||
.iter()
|
||||
.map(|a| (a.name().value().to_string(), a.id().clone()))
|
||||
.collect();
|
||||
|
||||
let category_map: HashMap<String, String> = deps
|
||||
.preset
|
||||
.activities
|
||||
.iter()
|
||||
.filter_map(|a| a.category.as_ref().map(|c| (a.name.clone(), c.clone())))
|
||||
.collect();
|
||||
|
||||
let mut batch = Vec::new();
|
||||
let mut skipped = 0u64;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (index, row) in rows.into_iter().enumerate() {
|
||||
match build_entry(
|
||||
&row,
|
||||
&cmd.user_id,
|
||||
&mut activity_cache,
|
||||
&category_map,
|
||||
&existing_keys,
|
||||
deps,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(entry)) => batch.push(entry),
|
||||
Ok(None) => skipped += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!(row = index + 1, error = %e, "skipped row during import");
|
||||
errors.push(format!("row {}: {e}", index + 1));
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let imported = batch.len() as u64;
|
||||
deps.entry_command.save_batch(&batch).await?;
|
||||
|
||||
tracing::info!(imported, skipped, "import completed");
|
||||
|
||||
Ok(ImportResult {
|
||||
imported,
|
||||
skipped,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_entry(
|
||||
row: &domain::ports::ImportedRow,
|
||||
user_id: &domain::user::UserId,
|
||||
activity_cache: &mut HashMap<String, ActivityId>,
|
||||
category_map: &HashMap<String, String>,
|
||||
existing_keys: &HashSet<(String, u8)>,
|
||||
deps: &Deps,
|
||||
) -> Result<Option<MoodEntry>, ApplicationError> {
|
||||
let mood = Mood::try_from(row.mood)?;
|
||||
let logged_at = parse_datetime(&row.date, &row.time)?;
|
||||
|
||||
let key = (logged_at.to_rfc3339(), mood.value());
|
||||
if existing_keys.contains(&key) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut activity_ids = Vec::new();
|
||||
for activity_name in &row.activities {
|
||||
let id =
|
||||
resolve_activity(activity_name, user_id, activity_cache, category_map, deps).await?;
|
||||
activity_ids.push(id);
|
||||
}
|
||||
|
||||
let content = row.note.as_ref().and_then(|n| Content::new(n).ok());
|
||||
|
||||
let mut entry = MoodEntry::new(user_id.clone(), mood, logged_at);
|
||||
entry.set_activities(activity_ids);
|
||||
entry.set_content(content);
|
||||
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
async fn resolve_activity(
|
||||
name: &str,
|
||||
user_id: &domain::user::UserId,
|
||||
cache: &mut HashMap<String, ActivityId>,
|
||||
category_map: &HashMap<String, String>,
|
||||
deps: &Deps,
|
||||
) -> Result<ActivityId, ApplicationError> {
|
||||
if let Some(id) = cache.get(name) {
|
||||
return Ok(id.clone());
|
||||
}
|
||||
|
||||
tracing::debug!(activity_name = name, "creating new activity from import");
|
||||
let activity_name = ActivityName::new(name)?;
|
||||
let category = category_map
|
||||
.get(name)
|
||||
.and_then(|c| CategoryName::new(c).ok());
|
||||
|
||||
let activity = domain::activity::Activity::new(user_id.clone(), activity_name, category);
|
||||
deps.activity_command.save(&activity).await?;
|
||||
|
||||
let id = activity.id().clone();
|
||||
cache.insert(name.to_string(), id.clone());
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn parse_datetime(
|
||||
date: &str,
|
||||
time: &str,
|
||||
) -> Result<chrono::DateTime<FixedOffset>, ApplicationError> {
|
||||
let datetime_str = format!("{date} {time}");
|
||||
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(&datetime_str, "%Y-%m-%d %I:%M %p")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(&datetime_str, "%Y-%m-%d %H:%M"))
|
||||
.map_err(|e| ApplicationError::Validation(format!("invalid date/time: {e}")))?;
|
||||
|
||||
let offset = FixedOffset::east_opt(0).expect("UTC offset is always valid");
|
||||
Ok(naive.and_local_timezone(offset).unwrap())
|
||||
}
|
||||
1
crates/application/src/import/use_cases/mod.rs
Normal file
1
crates/application/src/import/use_cases/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod import_entries;
|
||||
11
crates/application/src/lib.rs
Normal file
11
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod activity;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod entry;
|
||||
pub mod errors;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod media;
|
||||
pub mod push;
|
||||
pub mod reminder;
|
||||
pub mod user;
|
||||
1
crates/application/src/media/mod.rs
Normal file
1
crates/application/src/media/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
16
crates/application/src/media/use_cases/delete_photo.rs
Normal file
16
crates/application/src/media/use_cases/delete_photo.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::PhotoId;
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(photo_id: PhotoId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.storage.delete_photo(&photo_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
16
crates/application/src/media/use_cases/delete_voice_memo.rs
Normal file
16
crates/application/src/media/use_cases/delete_voice_memo.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::VoiceMemoId;
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(voice_memo_id: VoiceMemoId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.storage.delete_voice_memo(&voice_memo_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
4
crates/application/src/media/use_cases/mod.rs
Normal file
4
crates/application/src/media/use_cases/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod delete_photo;
|
||||
pub mod delete_voice_memo;
|
||||
pub mod upload_photo;
|
||||
pub mod upload_voice_memo;
|
||||
16
crates/application/src/media/use_cases/upload_photo.rs
Normal file
16
crates/application/src/media/use_cases/upload_photo.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{MediaUpload, PhotoId};
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<PhotoId, ApplicationError> {
|
||||
let photo_id = deps.storage.store_photo(upload).await?;
|
||||
Ok(photo_id)
|
||||
}
|
||||
16
crates/application/src/media/use_cases/upload_voice_memo.rs
Normal file
16
crates/application/src/media/use_cases/upload_voice_memo.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{MediaUpload, VoiceMemoId};
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<VoiceMemoId, ApplicationError> {
|
||||
let voice_memo_id = deps.storage.store_voice_memo(upload).await?;
|
||||
Ok(voice_memo_id)
|
||||
}
|
||||
14
crates/application/src/push/commands.rs
Normal file
14
crates/application/src/push/commands.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SubscribePushCommand {
|
||||
pub user_id: UserId,
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UnsubscribePushCommand {
|
||||
pub endpoint: String,
|
||||
}
|
||||
2
crates/application/src/push/mod.rs
Normal file
2
crates/application/src/push/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
2
crates/application/src/push/use_cases/mod.rs
Normal file
2
crates/application/src/push/use_cases/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod subscribe;
|
||||
pub mod unsubscribe;
|
||||
29
crates/application/src/push/use_cases/subscribe.rs
Normal file
29
crates/application/src/push/use_cases/subscribe.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
|
||||
use domain::push::PushSubscription;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::SubscribePushCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub push_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
pub push_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: SubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
if let Some(_existing) = deps.push_query.find_by_endpoint(&cmd.endpoint).await? {
|
||||
tracing::debug!(
|
||||
endpoint = cmd.endpoint,
|
||||
"push subscription already exists, updating"
|
||||
);
|
||||
}
|
||||
|
||||
let subscription = PushSubscription::new(cmd.user_id, cmd.endpoint, cmd.p256dh, cmd.auth);
|
||||
deps.push_command.save(&subscription).await?;
|
||||
|
||||
tracing::info!(user_id = %subscription.user_id(), "push subscription saved");
|
||||
Ok(())
|
||||
}
|
||||
18
crates/application/src/push/use_cases/unsubscribe.rs
Normal file
18
crates/application/src/push/use_cases/unsubscribe.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PushSubscriptionCommandPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::UnsubscribePushCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub push_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: UnsubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.push_command.delete_by_endpoint(&cmd.endpoint).await?;
|
||||
tracing::info!(endpoint = cmd.endpoint, "push subscription removed");
|
||||
Ok(())
|
||||
}
|
||||
15
crates/application/src/reminder/commands.rs
Normal file
15
crates/application/src/reminder/commands.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use domain::reminder::{DaySchedule, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CreateReminderCommand {
|
||||
pub user_id: UserId,
|
||||
pub schedule: DaySchedule,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateReminderCommand {
|
||||
pub reminder_id: ReminderId,
|
||||
pub schedule: Option<DaySchedule>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
2
crates/application/src/reminder/mod.rs
Normal file
2
crates/application/src/reminder/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
32
crates/application/src/reminder/use_cases/create_reminder.rs
Normal file
32
crates/application/src/reminder/use_cases/create_reminder.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EventPublisherPort, ReminderCommandPort};
|
||||
use domain::reminder::Reminder;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::CreateReminderCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub reminders: Arc<dyn ReminderCommandPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: CreateReminderCommand,
|
||||
deps: &Deps,
|
||||
) -> Result<Reminder, ApplicationError> {
|
||||
let reminder = Reminder::new(cmd.user_id, cmd.schedule);
|
||||
|
||||
deps.reminders.save(&reminder).await?;
|
||||
|
||||
let event = DomainEvent::ReminderCreated {
|
||||
reminder_id: reminder.id().clone(),
|
||||
user_id: reminder.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(reminder)
|
||||
}
|
||||
41
crates/application/src/reminder/use_cases/delete_reminder.rs
Normal file
41
crates/application/src/reminder/use_cases/delete_reminder.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EventPublisherPort, ReminderCommandPort, ReminderQueryPort};
|
||||
use domain::reminder::ReminderId;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ReminderCommandPort>,
|
||||
pub query: Arc<dyn ReminderQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
reminder_id: ReminderId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let reminder = deps
|
||||
.query
|
||||
.find_by_id(&reminder_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("reminder not found".into()))?;
|
||||
|
||||
verify_ownership(reminder.user_id(), &caller_id)?;
|
||||
|
||||
deps.command.delete(&reminder_id).await?;
|
||||
|
||||
let event = DomainEvent::ReminderDeleted {
|
||||
reminder_id,
|
||||
user_id: reminder.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
29
crates/application/src/reminder/use_cases/get_reminder.rs
Normal file
29
crates/application/src/reminder/use_cases/get_reminder.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ReminderQueryPort;
|
||||
use domain::reminder::{Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ReminderQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
reminder_id: ReminderId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Reminder, ApplicationError> {
|
||||
let reminder = deps
|
||||
.query
|
||||
.find_by_id(&reminder_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("reminder not found".into()))?;
|
||||
|
||||
verify_ownership(reminder.user_id(), &caller_id)?;
|
||||
Ok(reminder)
|
||||
}
|
||||
16
crates/application/src/reminder/use_cases/list_reminders.rs
Normal file
16
crates/application/src/reminder/use_cases/list_reminders.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ReminderQueryPort;
|
||||
use domain::reminder::Reminder;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ReminderQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<Reminder>, ApplicationError> {
|
||||
Ok(deps.query.find_by_user(&user_id).await?)
|
||||
}
|
||||
6
crates/application/src/reminder/use_cases/mod.rs
Normal file
6
crates/application/src/reminder/use_cases/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod create_reminder;
|
||||
pub mod delete_reminder;
|
||||
pub mod get_reminder;
|
||||
pub mod list_reminders;
|
||||
pub mod process_due_reminders;
|
||||
pub mod update_reminder;
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Datelike, NaiveTime, Utc};
|
||||
|
||||
use domain::ports::{ReminderQueryPort, ReminderSenderPort, UserQueryPort};
|
||||
use domain::reminder::Reminder;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub sender: Arc<dyn ReminderSenderPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(deps: &Deps) -> Result<u64, ApplicationError> {
|
||||
let reminders = deps.reminder_query.find_all_enabled().await?;
|
||||
tracing::debug!(
|
||||
reminder_count = reminders.len(),
|
||||
"checking enabled reminders"
|
||||
);
|
||||
|
||||
let mut sent_count = 0u64;
|
||||
|
||||
for reminder in &reminders {
|
||||
if should_send(reminder, &deps.user_query).await? {
|
||||
tracing::info!(user_id = %reminder.user_id(), "sending reminder");
|
||||
deps.sender.send_reminder(reminder.user_id()).await?;
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(sent_count, "reminder processing completed");
|
||||
Ok(sent_count)
|
||||
}
|
||||
|
||||
async fn should_send(
|
||||
reminder: &Reminder,
|
||||
user_query: &Arc<dyn UserQueryPort>,
|
||||
) -> Result<bool, ApplicationError> {
|
||||
let user = user_query.find_by_id(reminder.user_id()).await?;
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
tracing::warn!(user_id = %reminder.user_id(), "reminder references nonexistent user");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let now = match user.timezone() {
|
||||
Some(tz) => {
|
||||
let tz: chrono_tz::Tz = match tz.value().parse() {
|
||||
Ok(tz) => tz,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
user_id = %reminder.user_id(),
|
||||
timezone = tz.value(),
|
||||
"invalid timezone for user"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
Utc::now().with_timezone(&tz)
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(user_id = %reminder.user_id(), "user has no timezone set, skipping reminder");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let weekday = now.weekday();
|
||||
let scheduled_time = match reminder.schedule().time_for(weekday) {
|
||||
Some(time) => time,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let current_time = now.time();
|
||||
Ok(is_within_window(current_time, scheduled_time))
|
||||
}
|
||||
|
||||
fn is_within_window(current: NaiveTime, scheduled: NaiveTime) -> bool {
|
||||
let diff = current.signed_duration_since(scheduled);
|
||||
let minutes = diff.num_minutes();
|
||||
(0..5).contains(&minutes)
|
||||
}
|
||||
55
crates/application/src/reminder/use_cases/update_reminder.rs
Normal file
55
crates/application/src/reminder/use_cases/update_reminder.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EventPublisherPort, ReminderCommandPort, ReminderQueryPort};
|
||||
use domain::reminder::Reminder;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::UpdateReminderCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ReminderCommandPort>,
|
||||
pub query: Arc<dyn ReminderQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
cmd: UpdateReminderCommand,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Reminder, ApplicationError> {
|
||||
let mut reminder = deps
|
||||
.query
|
||||
.find_by_id(&cmd.reminder_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("reminder not found".into()))?;
|
||||
|
||||
verify_ownership(reminder.user_id(), &caller_id)?;
|
||||
|
||||
if let Some(schedule) = cmd.schedule {
|
||||
reminder.update_schedule(schedule);
|
||||
}
|
||||
|
||||
if let Some(enabled) = cmd.enabled {
|
||||
if enabled {
|
||||
reminder.enable();
|
||||
} else {
|
||||
reminder.disable();
|
||||
}
|
||||
}
|
||||
|
||||
deps.command.save(&reminder).await?;
|
||||
|
||||
let event = DomainEvent::ReminderUpdated {
|
||||
reminder_id: reminder.id().clone(),
|
||||
user_id: reminder.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(reminder)
|
||||
}
|
||||
28
crates/application/src/user/commands.rs
Normal file
28
crates/application/src/user/commands.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use domain::user::{DisplayName, Email, Timezone, UserId, Username};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RegisterCommand {
|
||||
pub username: Username,
|
||||
pub email: Email,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginCommand {
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateProfileCommand {
|
||||
pub user_id: UserId,
|
||||
pub display_name: Option<DisplayName>,
|
||||
pub timezone: Option<Timezone>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ChangePasswordCommand {
|
||||
pub user_id: UserId,
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
2
crates/application/src/user/mod.rs
Normal file
2
crates/application/src/user/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
38
crates/application/src/user/use_cases/change_password.rs
Normal file
38
crates/application/src/user/use_cases/change_password.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{PasswordHasherPort, UserCommandPort, UserQueryPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::ChangePasswordCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(user_id = %cmd.user_id))]
|
||||
pub async fn execute(cmd: ChangePasswordCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let mut user = deps
|
||||
.user_query
|
||||
.find_by_id(&cmd.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
|
||||
|
||||
let valid = deps
|
||||
.password_hasher
|
||||
.verify(&cmd.current_password, user.password_hash())?;
|
||||
|
||||
if !valid {
|
||||
return Err(DomainError::Unauthorized("current password is incorrect".into()).into());
|
||||
}
|
||||
|
||||
let new_hash = deps.password_hasher.hash(&cmd.new_password)?;
|
||||
user.update_password(new_hash);
|
||||
|
||||
deps.user_command.save(&user).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
crates/application/src/user/use_cases/clear_data.rs
Normal file
35
crates/application/src/user/use_cases/clear_data.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{CascadeDeletePort, MediaStoragePort, MoodEntryQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
|
||||
|
||||
for entry in &entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = deps.media_storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%e, "failed to delete photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.cascade.delete_all_user_data(&user_id).await?;
|
||||
|
||||
tracing::info!(%user_id, "all user data cleared");
|
||||
Ok(())
|
||||
}
|
||||
44
crates/application/src/user/use_cases/delete_user.rs
Normal file
44
crates/application/src/user/use_cases/delete_user.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, EventPublisherPort, MediaStoragePort, MoodEntryQueryPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.user_query
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
|
||||
|
||||
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
|
||||
for entry in &entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = deps.media_storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%e, "failed to delete photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.cascade.delete_user_account(&user_id).await?;
|
||||
|
||||
tracing::info!(%user_id, "user account deleted");
|
||||
Ok(())
|
||||
}
|
||||
19
crates/application/src/user/use_cases/get_profile.rs
Normal file
19
crates/application/src/user/use_cases/get_profile.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UserQueryPort;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<User, ApplicationError> {
|
||||
deps.user_query
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()).into())
|
||||
}
|
||||
6
crates/application/src/user/use_cases/mod.rs
Normal file
6
crates/application/src/user/use_cases/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod change_password;
|
||||
pub mod clear_data;
|
||||
pub mod delete_user;
|
||||
pub mod get_profile;
|
||||
pub mod register;
|
||||
pub mod update_profile;
|
||||
81
crates/application/src/user/use_cases/register.rs
Normal file
81
crates/application/src/user/use_cases/register.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use config::PresetConfig;
|
||||
use domain::activity::{Activity, ActivityName, CategoryName};
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, EventPublisherPort, PasswordHasherPort, UserCommandPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::RegisterCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
pub preset: PresetConfig,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(username = %cmd.username.value(), email = %cmd.email.value()))]
|
||||
pub async fn execute(cmd: RegisterCommand, deps: &Deps) -> Result<User, ApplicationError> {
|
||||
if deps
|
||||
.user_query
|
||||
.find_by_username(&cmd.username)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(DomainError::Conflict("username already taken".into()).into());
|
||||
}
|
||||
|
||||
if deps.user_query.find_by_email(&cmd.email).await?.is_some() {
|
||||
return Err(DomainError::Conflict("email already registered".into()).into());
|
||||
}
|
||||
|
||||
let password_hash = deps.password_hasher.hash(&cmd.password)?;
|
||||
let user = User::new(cmd.username, cmd.email, password_hash);
|
||||
|
||||
deps.user_command.save(&user).await?;
|
||||
seed_default_activities(user.id(), deps).await?;
|
||||
|
||||
let event = DomainEvent::UserRegistered {
|
||||
user_id: user.id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn seed_default_activities(user_id: &UserId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
for preset_activity in &deps.preset.activities {
|
||||
let name = match ActivityName::new(&preset_activity.name) {
|
||||
Ok(name) => name,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
activity_name = &preset_activity.name,
|
||||
error = %e,
|
||||
"skipped invalid preset activity name"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let category = preset_activity.category.as_ref().and_then(|c| {
|
||||
CategoryName::new(c)
|
||||
.inspect_err(|e| {
|
||||
tracing::warn!(category = c, error = %e, "skipped invalid preset category name");
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
let activity = Activity::new(user_id.clone(), name, category);
|
||||
deps.activity_command.save(&activity).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
30
crates/application/src/user/use_cases/update_profile.rs
Normal file
30
crates/application/src/user/use_cases/update_profile.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{UserCommandPort, UserQueryPort};
|
||||
use domain::user::User;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::UpdateProfileCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: UpdateProfileCommand, deps: &Deps) -> Result<User, ApplicationError> {
|
||||
let mut user = deps
|
||||
.user_query
|
||||
.find_by_id(&cmd.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
|
||||
|
||||
user.update_display_name(cmd.display_name);
|
||||
user.update_timezone(cmd.timezone);
|
||||
|
||||
deps.user_command.save(&user).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
2
crates/application/tests/activity.rs
Normal file
2
crates/application/tests/activity.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "activity/crud_test.rs"]
|
||||
mod crud_test;
|
||||
220
crates/application/tests/activity/crud_test.rs
Normal file
220
crates/application/tests/activity/crud_test.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::{ActivityName, CategoryName};
|
||||
use domain::ports::ActivityCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_activity, test_user};
|
||||
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::activity::commands::{
|
||||
CreateActivityCommand, RenameActivityCommand, SetCategoryCommand,
|
||||
};
|
||||
use application::activity::use_cases::{
|
||||
archive_activity, create_activity, delete_activity, get_activity, list_activities,
|
||||
rename_activity, set_category,
|
||||
};
|
||||
|
||||
fn store_and_events() -> Arc<InMemoryStore> {
|
||||
Arc::new(InMemoryStore::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_activity() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
|
||||
let deps = create_activity::Deps {
|
||||
activities: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = CreateActivityCommand {
|
||||
user_id: user.id().clone(),
|
||||
name: ActivityName::new("gaming").unwrap(),
|
||||
category: Some(CategoryName::new("hobbies").unwrap()),
|
||||
};
|
||||
|
||||
let activity = create_activity::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(activity.name().value(), "gaming");
|
||||
assert_eq!(activity.category().map(|c| c.value()), Some("hobbies"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renames_activity() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = rename_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = RenameActivityCommand {
|
||||
activity_id: activity.id().clone(),
|
||||
new_name: ActivityName::new("video games").unwrap(),
|
||||
};
|
||||
|
||||
rename_activity::execute(cmd, user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = get_activity::execute(
|
||||
activity.id().clone(),
|
||||
user.id().clone(),
|
||||
&get_activity::Deps {
|
||||
query: store.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fetched.name().value(), "video games");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sets_category() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = set_category::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = SetCategoryCommand {
|
||||
activity_id: activity.id().clone(),
|
||||
category: Some(CategoryName::new("hobbies").unwrap()),
|
||||
};
|
||||
|
||||
set_category::execute(cmd, user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = get_activity::execute(
|
||||
activity.id().clone(),
|
||||
user.id().clone(),
|
||||
&get_activity::Deps {
|
||||
query: store.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fetched.category().map(|c| c.value()), Some("hobbies"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archives_and_unarchives() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = archive_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
archive_activity::archive(activity.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list_deps = list_activities::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
let active = list_activities::active_only(user.id().clone(), &list_deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(active.is_empty());
|
||||
|
||||
archive_activity::unarchive(activity.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let active = list_activities::active_only(user.id().clone(), &list_deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_activity() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = delete_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
delete_activity::execute(activity.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.activity_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_rejects_different_user() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = rename_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = RenameActivityCommand {
|
||||
activity_id: activity.id().clone(),
|
||||
new_name: ActivityName::new("hacking").unwrap(),
|
||||
};
|
||||
|
||||
let result = rename_activity::execute(cmd, UserId::generate(), &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rejects_different_user() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = delete_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let result = delete_activity::execute(activity.id().clone(), UserId::generate(), &deps).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(store.activity_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_rejects_different_user() {
|
||||
let store = store_and_events();
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
store.save(&activity).await.unwrap();
|
||||
|
||||
let deps = archive_activity::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let result = archive_activity::archive(activity.id().clone(), UserId::generate(), &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
2
crates/application/tests/auth.rs
Normal file
2
crates/application/tests/auth.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "auth/refresh_test.rs"]
|
||||
mod refresh_test;
|
||||
78
crates/application/tests/auth/refresh_test.rs
Normal file
78
crates/application/tests/auth/refresh_test.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use domain::auth::{RefreshSession, RefreshSessionId};
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::auth::use_cases::refresh;
|
||||
|
||||
fn store_with_session(user_id: UserId, ttl_seconds: i64) -> (Arc<InMemoryStore>, String) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let session = RefreshSession::new(user_id, ttl_seconds);
|
||||
let token = session.token().to_string();
|
||||
store.refresh_sessions.write().unwrap().push(session);
|
||||
(store, token)
|
||||
}
|
||||
|
||||
fn deps(store: &Arc<InMemoryStore>) -> refresh::Deps {
|
||||
refresh::Deps {
|
||||
auth_service: store.clone(),
|
||||
refresh_session_command: store.clone(),
|
||||
refresh_session_query: store.clone(),
|
||||
refresh_token_ttl_seconds: 86400,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotates_refresh_token() {
|
||||
let user_id = UserId::generate();
|
||||
let (store, token) = store_with_session(user_id, 86400);
|
||||
|
||||
let result = refresh::execute(&token, &deps(&store)).await.unwrap();
|
||||
|
||||
assert!(!result.access_token.token().is_empty());
|
||||
assert_ne!(result.refresh_token, token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unknown_token() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let result = refresh::execute("nonexistent-token", &deps(&store)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_expired_token() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = UserId::generate();
|
||||
let session = RefreshSession::from_persistence(
|
||||
RefreshSessionId::generate(),
|
||||
user_id,
|
||||
"expired-token".into(),
|
||||
Utc::now() - Duration::seconds(1),
|
||||
Utc::now() - Duration::hours(1),
|
||||
);
|
||||
store.refresh_sessions.write().unwrap().push(session);
|
||||
|
||||
let result = refresh::execute("expired-token", &deps(&store)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_token_is_revoked_after_rotation() {
|
||||
let user_id = UserId::generate();
|
||||
let (store, token) = store_with_session(user_id, 86400);
|
||||
|
||||
refresh::execute(&token, &deps(&store)).await.unwrap();
|
||||
|
||||
let old_session = store
|
||||
.refresh_sessions
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.token() == token)
|
||||
.cloned();
|
||||
assert!(old_session.is_none());
|
||||
}
|
||||
23
crates/application/tests/entry.rs
Normal file
23
crates/application/tests/entry.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#[path = "entry/create_entry_test.rs"]
|
||||
mod create_entry_test;
|
||||
|
||||
#[path = "entry/update_entry_test.rs"]
|
||||
mod update_entry_test;
|
||||
|
||||
#[path = "entry/delete_entry_test.rs"]
|
||||
mod delete_entry_test;
|
||||
|
||||
#[path = "entry/get_entry_test.rs"]
|
||||
mod get_entry_test;
|
||||
|
||||
#[path = "entry/list_entries_test.rs"]
|
||||
mod list_entries_test;
|
||||
|
||||
#[path = "entry/filter_test.rs"]
|
||||
mod filter_test;
|
||||
|
||||
#[path = "entry/mood_stats_test.rs"]
|
||||
mod mood_stats_test;
|
||||
|
||||
#[path = "entry/replace_activity_test.rs"]
|
||||
mod replace_activity_test;
|
||||
62
crates/application/tests/entry/create_entry_test.rs
Normal file
62
crates/application/tests/entry/create_entry_test.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::testing::{test_entry, test_logged_at, test_user};
|
||||
|
||||
use application::entry::commands::CreateEntryCommand;
|
||||
use application::entry::use_cases::create_entry;
|
||||
|
||||
fn deps() -> (Arc<domain::testing::InMemoryStore>, create_entry::Deps) {
|
||||
let store = Arc::new(domain::testing::InMemoryStore::new());
|
||||
let deps = create_entry::Deps {
|
||||
entries: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
(store, deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_entry_with_required_fields() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("alice");
|
||||
|
||||
let cmd = CreateEntryCommand {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Good,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
};
|
||||
|
||||
let entry = create_entry::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(entry.mood(), Mood::Good);
|
||||
assert_eq!(store.entry_count(), 1);
|
||||
assert_eq!(store.published_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_entry_with_all_optional_fields() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("bob");
|
||||
let activity = domain::testing::test_activity(user.id().clone(), "gaming");
|
||||
|
||||
let cmd = CreateEntryCommand {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![activity.id().clone()],
|
||||
content: Some(domain::testing::test_content("great day")),
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
};
|
||||
|
||||
let entry = create_entry::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(entry.mood(), Mood::Rad);
|
||||
assert_eq!(entry.activities().len(), 1);
|
||||
assert!(entry.content().is_some());
|
||||
assert_eq!(store.entry_count(), 1);
|
||||
}
|
||||
69
crates/application/tests/entry/delete_entry_test.rs
Normal file
69
crates/application/tests/entry/delete_entry_test.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry, test_user};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::use_cases::delete_entry;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_existing_entry() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = delete_entry::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
};
|
||||
|
||||
delete_entry::execute(entry.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.entry_count(), 0);
|
||||
assert_eq!(store.published_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_nonexistent_entry_fails() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = delete_entry::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
};
|
||||
|
||||
let result = delete_entry::execute(
|
||||
domain::entry::MoodEntryId::generate(),
|
||||
UserId::generate(),
|
||||
&deps,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_delete_by_different_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = delete_entry::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
};
|
||||
|
||||
let other_user = UserId::generate();
|
||||
let result = delete_entry::execute(entry.id().clone(), other_user, &deps).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(store.entry_count(), 1);
|
||||
}
|
||||
67
crates/application/tests/entry/filter_test.rs
Normal file
67
crates/application/tests/entry/filter_test.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_activity, test_entry, test_user};
|
||||
|
||||
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery};
|
||||
use application::entry::use_cases::{filter_by_activity, filter_by_mood};
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_mood() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
|
||||
store
|
||||
.save(&test_entry(user.id().clone(), Mood::Good))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry(user.id().clone(), Mood::Good))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry(user.id().clone(), Mood::Bad))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = filter_by_mood::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = FilterByMoodQuery {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Good,
|
||||
};
|
||||
|
||||
let entries = filter_by_mood::execute(query, &deps).await.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_activity() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "exercise");
|
||||
|
||||
let mut tagged = test_entry(user.id().clone(), Mood::Good);
|
||||
tagged.set_activities(vec![activity.id().clone()]);
|
||||
store.save(&tagged).await.unwrap();
|
||||
|
||||
store
|
||||
.save(&test_entry(user.id().clone(), Mood::Meh))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = filter_by_activity::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = FilterByActivityQuery {
|
||||
user_id: user.id().clone(),
|
||||
activity_id: activity.id().clone(),
|
||||
};
|
||||
|
||||
let entries = filter_by_activity::execute(query, &deps).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
57
crates/application/tests/entry/get_entry_test.rs
Normal file
57
crates/application/tests/entry/get_entry_test.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry, test_user};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::use_cases::get_entry;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_existing_entry() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = get_entry::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let result = get_entry::execute(entry.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.mood(), Mood::Good);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_for_nonexistent_entry() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = get_entry::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let result = get_entry::execute(
|
||||
domain::entry::MoodEntryId::generate(),
|
||||
UserId::generate(),
|
||||
&deps,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_access_by_different_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = get_entry::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let other_user = UserId::generate();
|
||||
let result = get_entry::execute(entry.id().clone(), other_user, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
60
crates/application/tests/entry/list_entries_test.rs
Normal file
60
crates/application/tests/entry/list_entries_test.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry, test_user};
|
||||
|
||||
use application::entry::queries::ListEntriesQuery;
|
||||
use application::entry::use_cases::list_entries;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_entries_for_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let alice = test_user("alice");
|
||||
let bob = test_user("bob");
|
||||
|
||||
store
|
||||
.save(&test_entry(alice.id().clone(), Mood::Good))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry(alice.id().clone(), Mood::Rad))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry(bob.id().clone(), Mood::Meh))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = list_entries::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = ListEntriesQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
user_id: alice.id().clone(),
|
||||
range: None,
|
||||
};
|
||||
|
||||
let entries = list_entries::execute(query, &deps).await.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_for_user_with_no_entries() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = list_entries::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = ListEntriesQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
user_id: test_user("nobody").id().clone(),
|
||||
range: None,
|
||||
};
|
||||
|
||||
let entries = list_entries::execute(query, &deps).await.unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
62
crates/application/tests/entry/mood_stats_test.rs
Normal file
62
crates/application/tests/entry/mood_stats_test.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry_days_ago, test_user};
|
||||
|
||||
use application::entry::queries::MoodStatsQuery;
|
||||
use application::entry::use_cases::get_mood_stats;
|
||||
|
||||
#[tokio::test]
|
||||
async fn computes_stats_across_entries() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Good, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Rad, 1))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Meh, 2))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = get_mood_stats::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = MoodStatsQuery {
|
||||
user_id: user.id().clone(),
|
||||
range: None,
|
||||
};
|
||||
|
||||
let stats = get_mood_stats::execute(query, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(stats.total_entries, 3);
|
||||
assert!(stats.average.is_some());
|
||||
assert_eq!(stats.current_streak, 3);
|
||||
assert!(!stats.frequency.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_entries_give_zero_stats() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = get_mood_stats::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
let query = MoodStatsQuery {
|
||||
user_id: test_user("nobody").id().clone(),
|
||||
range: None,
|
||||
};
|
||||
|
||||
let stats = get_mood_stats::execute(query, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(stats.total_entries, 0);
|
||||
assert!(stats.average.is_none());
|
||||
assert_eq!(stats.current_streak, 0);
|
||||
}
|
||||
71
crates/application/tests/entry/replace_activity_test.rs
Normal file
71
crates/application/tests/entry/replace_activity_test.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::ports::ActivityCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_activity, test_user};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::use_cases::replace_activity;
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_activity_on_entries() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let old = test_activity(user.id().clone(), "gaming");
|
||||
let new = test_activity(user.id().clone(), "video games");
|
||||
store.save(&old).await.unwrap();
|
||||
store.save(&new).await.unwrap();
|
||||
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: store.clone(),
|
||||
activity_query: store.clone(),
|
||||
};
|
||||
|
||||
let result =
|
||||
replace_activity::execute(user.id().clone(), old.id().clone(), new.id().clone(), &deps)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_nonexistent_target_activity() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: store.clone(),
|
||||
activity_query: store.clone(),
|
||||
};
|
||||
|
||||
let result = replace_activity::execute(
|
||||
user.id().clone(),
|
||||
ActivityId::generate(),
|
||||
ActivityId::generate(),
|
||||
&deps,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_target_owned_by_different_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let alice = test_user("alice");
|
||||
let bob = test_user("bob");
|
||||
let bobs_activity = test_activity(bob.id().clone(), "gaming");
|
||||
store.save(&bobs_activity).await.unwrap();
|
||||
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: store.clone(),
|
||||
activity_query: store.clone(),
|
||||
};
|
||||
|
||||
let result = replace_activity::execute(
|
||||
alice.id().clone(),
|
||||
ActivityId::generate(),
|
||||
bobs_activity.id().clone(),
|
||||
&deps,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
93
crates/application/tests/entry/update_entry_test.rs
Normal file
93
crates/application/tests/entry/update_entry_test.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry, test_logged_at, test_user};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::commands::UpdateEntryCommand;
|
||||
use application::entry::use_cases::update_entry;
|
||||
|
||||
async fn setup() -> (
|
||||
Arc<InMemoryStore>,
|
||||
update_entry::Deps,
|
||||
domain::entry::MoodEntry,
|
||||
UserId,
|
||||
) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Meh);
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = update_entry::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
(store, deps, entry, user.id().clone())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_mood() {
|
||||
let (store, deps, entry, user_id) = setup().await;
|
||||
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
};
|
||||
|
||||
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(updated.mood(), Mood::Rad);
|
||||
assert_eq!(store.published_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updating_nonexistent_entry_fails() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = update_entry::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: domain::entry::MoodEntryId::generate(),
|
||||
mood: Mood::Good,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
};
|
||||
|
||||
let result = update_entry::execute(cmd, UserId::generate(), &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_update_by_different_user() {
|
||||
let (_store, deps, entry, _user_id) = setup().await;
|
||||
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
};
|
||||
|
||||
let other_user = UserId::generate();
|
||||
let result = update_entry::execute(cmd, other_user, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
2
crates/application/tests/reminder.rs
Normal file
2
crates/application/tests/reminder.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "reminder/crud_test.rs"]
|
||||
mod crud_test;
|
||||
119
crates/application/tests/reminder/crud_test.rs
Normal file
119
crates/application/tests/reminder/crud_test.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveTime;
|
||||
|
||||
use domain::ports::ReminderCommandPort;
|
||||
use domain::reminder::DaySchedule;
|
||||
use domain::testing::{InMemoryStore, test_reminder, test_user};
|
||||
|
||||
use application::reminder::commands::{CreateReminderCommand, UpdateReminderCommand};
|
||||
use application::reminder::use_cases::{
|
||||
create_reminder, delete_reminder, get_reminder, list_reminders, update_reminder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_reminder() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
|
||||
let deps = create_reminder::Deps {
|
||||
reminders: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
|
||||
let cmd = CreateReminderCommand {
|
||||
user_id: user.id().clone(),
|
||||
schedule: DaySchedule::every_day_at(time),
|
||||
};
|
||||
|
||||
let reminder = create_reminder::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert!(reminder.is_enabled());
|
||||
assert_eq!(store.published_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_and_disables_reminder() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let reminder = test_reminder(user.id().clone());
|
||||
store.save(&reminder).await.unwrap();
|
||||
|
||||
let deps = update_reminder::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = UpdateReminderCommand {
|
||||
reminder_id: reminder.id().clone(),
|
||||
schedule: None,
|
||||
enabled: Some(false),
|
||||
};
|
||||
|
||||
let updated = update_reminder::execute(cmd, user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!updated.is_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_reminders_for_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
store.save(&test_reminder(user.id().clone())).await.unwrap();
|
||||
store.save(&test_reminder(user.id().clone())).await.unwrap();
|
||||
|
||||
let deps = list_reminders::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
let reminders = list_reminders::execute(user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reminders.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gets_single_reminder() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let reminder = test_reminder(user.id().clone());
|
||||
store.save(&reminder).await.unwrap();
|
||||
|
||||
let deps = get_reminder::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
let result = get_reminder::execute(reminder.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*result.id(), *reminder.id());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_reminder() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let reminder = test_reminder(user.id().clone());
|
||||
store.save(&reminder).await.unwrap();
|
||||
|
||||
let deps = delete_reminder::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
delete_reminder::execute(reminder.id().clone(), user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list_deps = list_reminders::Deps {
|
||||
query: store.clone(),
|
||||
};
|
||||
let reminders = list_reminders::execute(user.id().clone(), &list_deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(reminders.is_empty());
|
||||
}
|
||||
17
crates/application/tests/user.rs
Normal file
17
crates/application/tests/user.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
#[path = "user/register_test.rs"]
|
||||
mod register_test;
|
||||
|
||||
#[path = "user/login_test.rs"]
|
||||
mod login_test;
|
||||
|
||||
#[path = "user/profile_test.rs"]
|
||||
mod profile_test;
|
||||
|
||||
#[path = "user/change_password_test.rs"]
|
||||
mod change_password_test;
|
||||
|
||||
#[path = "user/clear_data_test.rs"]
|
||||
mod clear_data_test;
|
||||
|
||||
#[path = "user/delete_user_test.rs"]
|
||||
mod delete_user_test;
|
||||
67
crates/application/tests/user/change_password_test.rs
Normal file
67
crates/application/tests/user/change_password_test.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::UserCommandPort;
|
||||
use domain::testing::{FakePasswordHasher, InMemoryStore};
|
||||
use domain::user::{Email, PasswordHash, User, Username};
|
||||
|
||||
use application::user::commands::ChangePasswordCommand;
|
||||
use application::user::use_cases::change_password;
|
||||
|
||||
async fn setup() -> (Arc<InMemoryStore>, change_password::Deps, User) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = User::new(
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@example.com").unwrap(),
|
||||
PasswordHash::new("hashed:secret".into()),
|
||||
);
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let deps = change_password::Deps {
|
||||
user_command: store.clone(),
|
||||
user_query: store.clone(),
|
||||
password_hasher: Arc::new(FakePasswordHasher),
|
||||
};
|
||||
|
||||
(store, deps, user)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changes_password_with_correct_current() {
|
||||
let (_store, deps, user) = setup().await;
|
||||
|
||||
let cmd = ChangePasswordCommand {
|
||||
user_id: user.id().clone(),
|
||||
current_password: "secret".into(),
|
||||
new_password: "new-secret".into(),
|
||||
};
|
||||
|
||||
change_password::execute(cmd, &deps).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_current_password() {
|
||||
let (_store, deps, user) = setup().await;
|
||||
|
||||
let cmd = ChangePasswordCommand {
|
||||
user_id: user.id().clone(),
|
||||
current_password: "wrong".into(),
|
||||
new_password: "new-secret".into(),
|
||||
};
|
||||
|
||||
let result = change_password::execute(cmd, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_nonexistent_user() {
|
||||
let (_store, deps, _user) = setup().await;
|
||||
|
||||
let cmd = ChangePasswordCommand {
|
||||
user_id: domain::user::UserId::generate(),
|
||||
current_password: "secret".into(),
|
||||
new_password: "new-secret".into(),
|
||||
};
|
||||
|
||||
let result = change_password::execute(cmd, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
32
crates/application/tests/user/clear_data_test.rs
Normal file
32
crates/application/tests/user/clear_data_test.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::{ActivityCommandPort, MoodEntryCommandPort, ReminderCommandPort};
|
||||
use domain::testing::{InMemoryStore, test_activity, test_entry, test_reminder, test_user};
|
||||
|
||||
use application::user::use_cases::clear_data;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clears_all_user_data() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
let reminder = test_reminder(user.id().clone());
|
||||
|
||||
MoodEntryCommandPort::save(&*store, &entry).await.unwrap();
|
||||
ActivityCommandPort::save(&*store, &activity).await.unwrap();
|
||||
ReminderCommandPort::save(&*store, &reminder).await.unwrap();
|
||||
|
||||
let deps = clear_data::Deps {
|
||||
entry_query: store.clone(),
|
||||
cascade: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
};
|
||||
|
||||
clear_data::execute(user.id().clone(), &deps).await.unwrap();
|
||||
|
||||
assert_eq!(store.entry_count(), 0);
|
||||
assert_eq!(store.activity_count(), 0);
|
||||
assert_eq!(store.reminder_count(), 0);
|
||||
}
|
||||
50
crates/application/tests/user/delete_user_test.rs
Normal file
50
crates/application/tests/user/delete_user_test.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, MoodEntryCommandPort, ReminderCommandPort, UserCommandPort,
|
||||
};
|
||||
use domain::testing::{InMemoryStore, test_activity, test_entry, test_reminder, test_user};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::user::use_cases::delete_user;
|
||||
|
||||
fn deps(store: &Arc<InMemoryStore>) -> delete_user::Deps {
|
||||
delete_user::Deps {
|
||||
user_query: store.clone(),
|
||||
entry_query: store.clone(),
|
||||
cascade: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
events: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_user_and_all_data() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
let activity = test_activity(user.id().clone(), "gaming");
|
||||
let reminder = test_reminder(user.id().clone());
|
||||
|
||||
UserCommandPort::save(&*store, &user).await.unwrap();
|
||||
MoodEntryCommandPort::save(&*store, &entry).await.unwrap();
|
||||
ActivityCommandPort::save(&*store, &activity).await.unwrap();
|
||||
ReminderCommandPort::save(&*store, &reminder).await.unwrap();
|
||||
|
||||
delete_user::execute(user.id().clone(), &deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.entry_count(), 0);
|
||||
assert_eq!(store.activity_count(), 0);
|
||||
assert_eq!(store.reminder_count(), 0);
|
||||
assert_eq!(store.user_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_nonexistent_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let result = delete_user::execute(UserId::generate(), &deps(&store)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
82
crates/application/tests/user/login_test.rs
Normal file
82
crates/application/tests/user/login_test.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::UserCommandPort;
|
||||
use domain::testing::{FakePasswordHasher, InMemoryStore};
|
||||
use domain::user::{Email, PasswordHash, User, Username};
|
||||
|
||||
use application::auth::use_cases::login;
|
||||
use application::user::commands::LoginCommand;
|
||||
|
||||
async fn setup() -> (Arc<InMemoryStore>, login::Deps) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = User::new(
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@example.com").unwrap(),
|
||||
PasswordHash::new("hashed:secret".into()),
|
||||
);
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let deps = login::Deps {
|
||||
user_query: store.clone(),
|
||||
password_hasher: Arc::new(FakePasswordHasher),
|
||||
auth_service: store.clone(),
|
||||
refresh_session_command: store.clone(),
|
||||
refresh_token_ttl_seconds: 86400,
|
||||
};
|
||||
|
||||
(store, deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_username() {
|
||||
let (_, deps) = setup().await;
|
||||
|
||||
let cmd = LoginCommand {
|
||||
identifier: "alice".into(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await.unwrap();
|
||||
assert_eq!(result.user.username().value(), "alice");
|
||||
assert!(!result.access_token.token().is_empty());
|
||||
assert!(!result.refresh_token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_email() {
|
||||
let (_, deps) = setup().await;
|
||||
|
||||
let cmd = LoginCommand {
|
||||
identifier: "alice@example.com".into(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await.unwrap();
|
||||
assert_eq!(result.user.username().value(), "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_password_fails() {
|
||||
let (_, deps) = setup().await;
|
||||
|
||||
let cmd = LoginCommand {
|
||||
identifier: "alice".into(),
|
||||
password: "wrong".into(),
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonexistent_user_fails() {
|
||||
let (_, deps) = setup().await;
|
||||
|
||||
let cmd = LoginCommand {
|
||||
identifier: "nobody".into(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
47
crates/application/tests/user/profile_test.rs
Normal file
47
crates/application/tests/user/profile_test.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::UserCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{DisplayName, Timezone};
|
||||
|
||||
use application::user::commands::UpdateProfileCommand;
|
||||
use application::user::use_cases::{get_profile, update_profile};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_profile_returns_user() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let deps = get_profile::Deps {
|
||||
user_query: store.clone(),
|
||||
};
|
||||
|
||||
let result = get_profile::execute(user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.username().value(), "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_display_name_and_timezone() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let deps = update_profile::Deps {
|
||||
user_command: store.clone(),
|
||||
user_query: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = UpdateProfileCommand {
|
||||
user_id: user.id().clone(),
|
||||
display_name: Some(DisplayName::new("Alice K").unwrap()),
|
||||
timezone: Some(Timezone::new("Europe/Warsaw").unwrap()),
|
||||
};
|
||||
|
||||
let updated = update_profile::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(updated.display_name().map(|d| d.value()), Some("Alice K"));
|
||||
assert_eq!(updated.timezone().map(|t| t.value()), Some("Europe/Warsaw"));
|
||||
}
|
||||
79
crates/application/tests/user/register_test.rs
Normal file
79
crates/application/tests/user/register_test.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{FakePasswordHasher, InMemoryStore};
|
||||
use domain::user::Username;
|
||||
|
||||
use application::user::commands::RegisterCommand;
|
||||
use application::user::use_cases::register;
|
||||
|
||||
fn deps() -> (Arc<InMemoryStore>, register::Deps) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = register::Deps {
|
||||
user_command: store.clone(),
|
||||
user_query: store.clone(),
|
||||
activity_command: store.clone(),
|
||||
password_hasher: Arc::new(FakePasswordHasher),
|
||||
events: store.clone(),
|
||||
preset: config::PresetConfig::default(),
|
||||
};
|
||||
(store, deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registers_user_and_seeds_activities() {
|
||||
let (store, deps) = deps();
|
||||
|
||||
let cmd = RegisterCommand {
|
||||
username: Username::new("alice").unwrap(),
|
||||
email: domain::user::Email::new("alice@example.com").unwrap(),
|
||||
password: "secret123".into(),
|
||||
};
|
||||
|
||||
let user = register::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(user.username().value(), "alice");
|
||||
assert!(store.activity_count() > 0);
|
||||
assert_eq!(store.published_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_duplicate_username() {
|
||||
let (_, deps) = deps();
|
||||
|
||||
let cmd1 = RegisterCommand {
|
||||
username: Username::new("alice").unwrap(),
|
||||
email: domain::user::Email::new("alice@example.com").unwrap(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
register::execute(cmd1, &deps).await.unwrap();
|
||||
|
||||
let cmd2 = RegisterCommand {
|
||||
username: Username::new("alice").unwrap(),
|
||||
email: domain::user::Email::new("alice2@example.com").unwrap(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
let result = register::execute(cmd2, &deps).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_duplicate_email() {
|
||||
let (_, deps) = deps();
|
||||
|
||||
let cmd1 = RegisterCommand {
|
||||
username: Username::new("alice").unwrap(),
|
||||
email: domain::user::Email::new("alice@example.com").unwrap(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
register::execute(cmd1, &deps).await.unwrap();
|
||||
|
||||
let cmd2 = RegisterCommand {
|
||||
username: Username::new("bob").unwrap(),
|
||||
email: domain::user::Email::new("alice@example.com").unwrap(),
|
||||
password: "secret".into(),
|
||||
};
|
||||
let result = register::execute(cmd2, &deps).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user