init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View 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>,
}

View File

@@ -0,0 +1,2 @@
pub mod commands;
pub mod use_cases;

View File

@@ -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(())
}

View 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)
}

View 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(())
}

View 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)
}

View 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?)
}

View 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;

View 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(())
}

View 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(())
}

View File

@@ -0,0 +1 @@
pub mod use_cases;

View 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())
}

View 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(())
}

View File

@@ -0,0 +1,3 @@
pub mod login;
pub mod logout;
pub mod refresh;

View 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,
})
}

View 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(())
}

View 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>,
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod queries;
pub mod use_cases;

View 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>,
}

View 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)
}

View File

@@ -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)
}

View 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(())
}

View 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)
}

View 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)
}

View File

@@ -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,
))
}

View 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()
}

View 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)
}

View 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(),
})
}

View 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)
}

View 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;

View 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)
}

View 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)
}

View 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),
}

View File

@@ -0,0 +1 @@
pub mod use_cases;

View 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)
}

View File

@@ -0,0 +1 @@
pub mod export_user_data;

View File

@@ -0,0 +1,7 @@
use domain::user::UserId;
#[derive(Debug)]
pub struct ImportCommand {
pub user_id: UserId,
pub data: Vec<u8>,
}

View File

@@ -0,0 +1,2 @@
pub mod commands;
pub mod use_cases;

View 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())
}

View File

@@ -0,0 +1 @@
pub mod import_entries;

View 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;

View File

@@ -0,0 +1 @@
pub mod use_cases;

View 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(())
}

View 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(())
}

View File

@@ -0,0 +1,4 @@
pub mod delete_photo;
pub mod delete_voice_memo;
pub mod upload_photo;
pub mod upload_voice_memo;

View 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)
}

View 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)
}

View 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,
}

View File

@@ -0,0 +1,2 @@
pub mod commands;
pub mod use_cases;

View File

@@ -0,0 +1,2 @@
pub mod subscribe;
pub mod unsubscribe;

View 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(())
}

View 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(())
}

View 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>,
}

View File

@@ -0,0 +1,2 @@
pub mod commands;
pub mod use_cases;

View 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)
}

View 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(())
}

View 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)
}

View 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?)
}

View 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;

View File

@@ -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)
}

View 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)
}

View 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,
}

View File

@@ -0,0 +1,2 @@
pub mod commands;
pub mod use_cases;

View 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(())
}

View 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(())
}

View 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(())
}

View 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())
}

View 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;

View 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(())
}

View 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)
}