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)
|
||||
}
|
||||
Reference in New Issue
Block a user