8
crates/application/src/api_token/commands.rs
Normal file
8
crates/application/src/api_token/commands.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MintApiTokenCommand {
|
||||
pub user_id: UserId,
|
||||
pub name: ProviderName,
|
||||
}
|
||||
2
crates/application/src/api_token/mod.rs
Normal file
2
crates/application/src/api_token/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::ApiToken;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, ApiTokenSecretPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ApiTokenQueryPort>,
|
||||
pub command: Arc<dyn ApiTokenCommandPort>,
|
||||
pub secrets: Arc<dyn ApiTokenSecretPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(secret, deps))]
|
||||
pub async fn execute(secret: &str, deps: &Deps) -> Result<ApiToken, ApplicationError> {
|
||||
let digest = deps.secrets.digest(secret);
|
||||
|
||||
let token = deps
|
||||
.query
|
||||
.find_by_digest(&digest)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("this api token is not valid".into()))?;
|
||||
|
||||
if let Err(error) = deps.command.mark_used(token.id()).await {
|
||||
tracing::warn!(token_id = %token.id(), %error, "could not record that a token was used");
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::ApiToken;
|
||||
use domain::ports::ApiTokenQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ApiTokenQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<ApiToken>, ApplicationError> {
|
||||
Ok(deps.query.find_by_user(&user_id).await?)
|
||||
}
|
||||
28
crates/application/src/api_token/use_cases/mint_api_token.rs
Normal file
28
crates/application/src/api_token/use_cases/mint_api_token.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::{ApiToken, MintedApiToken};
|
||||
use domain::ports::{ApiTokenCommandPort, ApiTokenSecretPort};
|
||||
|
||||
use crate::api_token::commands::MintApiTokenCommand;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ApiTokenCommandPort>,
|
||||
pub secrets: Arc<dyn ApiTokenSecretPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
command: MintApiTokenCommand,
|
||||
deps: &Deps,
|
||||
) -> Result<MintedApiToken, ApplicationError> {
|
||||
let secret = deps.secrets.mint();
|
||||
let digest = deps.secrets.digest(&secret);
|
||||
|
||||
let token = ApiToken::new(command.user_id, command.name, digest);
|
||||
deps.command.save(&token).await?;
|
||||
|
||||
tracing::info!(token_id = %token.id(), name = token.name().value(), "minted an api token");
|
||||
|
||||
Ok(MintedApiToken::new(token, secret))
|
||||
}
|
||||
4
crates/application/src/api_token/use_cases/mod.rs
Normal file
4
crates/application/src/api_token/use_cases/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod authenticate_api_token;
|
||||
pub mod list_api_tokens;
|
||||
pub mod mint_api_token;
|
||||
pub mod revoke_api_token;
|
||||
@@ -0,0 +1,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::ApiTokenId;
|
||||
use domain::ports::ApiTokenCommandPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ApiTokenCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, id: ApiTokenId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.command.revoke(&user_id, &id).await?;
|
||||
|
||||
tracing::info!(%user_id, token_id = %id, "revoked an api token");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
2
crates/application/src/correlation/mod.rs
Normal file
2
crates/application/src/correlation/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod queries;
|
||||
pub mod use_cases;
|
||||
10
crates/application/src/correlation/queries.rs
Normal file
10
crates/application/src/correlation/queries.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use domain::entry::DateSpan;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CorrelationQuery {
|
||||
pub user_id: UserId,
|
||||
pub span: DateSpan,
|
||||
pub minimum_sample_size: usize,
|
||||
pub false_discovery_rate: f64,
|
||||
}
|
||||
340
crates/application/src/correlation/use_cases/get_correlations.rs
Normal file
340
crates/application/src/correlation/use_cases/get_correlations.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::correlation::{
|
||||
Adjustment, Agreement, Coefficient, CorrelationInput, CorrelationStrategy, Observation, PValue,
|
||||
Tested,
|
||||
};
|
||||
use domain::entry::{Date, DayMood};
|
||||
use domain::metric::MetricKind;
|
||||
use domain::moon::MoonPhase;
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, CycleStartQueryPort, DailyMetricQueryPort, MoodEntryQueryPort,
|
||||
UserPreferencesQueryPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::{Timezone, UserId};
|
||||
|
||||
use crate::correlation::queries::CorrelationQuery;
|
||||
use crate::cycle::use_cases::read_cycle::calendar_of;
|
||||
use crate::day::{date_of, day_moods, instants_of, timezone_for};
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
const PRESENT: f64 = 1.0;
|
||||
const ABSENT: f64 = 0.0;
|
||||
|
||||
pub struct Score {
|
||||
pub strategy: CorrelationStrategy,
|
||||
pub coefficient: Coefficient,
|
||||
pub held_up: bool,
|
||||
}
|
||||
|
||||
pub struct CorrelationRow {
|
||||
pub input: CorrelationInput,
|
||||
pub label: Option<String>,
|
||||
pub sample_size: usize,
|
||||
pub scores: Vec<Score>,
|
||||
pub agreement: Agreement,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryQueryPort>,
|
||||
pub metrics: Arc<dyn DailyMetricQueryPort>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub cycles: Arc<dyn CycleStartQueryPort>,
|
||||
pub weather_store: Arc<dyn domain::ports::EntryDimensionPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: CorrelationQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<CorrelationRow>, ApplicationError> {
|
||||
let timezone = timezone_for(&query.user_id, &deps.users).await?;
|
||||
|
||||
let entries = deps
|
||||
.entries
|
||||
.find_by_date_range(&query.user_id, &instants_of(&query.span))
|
||||
.await?;
|
||||
|
||||
let mut moods = day_moods(&entries, &timezone);
|
||||
moods.retain(|date, _| query.span.contains(date));
|
||||
|
||||
let mut scored = metric_rows(&query, deps, &moods).await?;
|
||||
|
||||
let lunar = paired(&moods, |date| Some(MoonPhase::on(date).illumination()));
|
||||
scored.push(row(
|
||||
CorrelationInput::MoonPhase,
|
||||
None,
|
||||
&lunar,
|
||||
query.minimum_sample_size,
|
||||
));
|
||||
|
||||
if let Some(cycle) = cycle_row(&query, deps, &moods).await? {
|
||||
scored.push(cycle);
|
||||
}
|
||||
|
||||
scored.push(temperature_row(&query, deps, &moods, &timezone).await?);
|
||||
|
||||
scored.extend(activity_rows(&query, deps, &moods, &timezone).await?);
|
||||
|
||||
Ok(mark_what_holds_up(scored, query.false_discovery_rate))
|
||||
}
|
||||
|
||||
async fn metric_rows(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
) -> Result<Vec<Pending>, ApplicationError> {
|
||||
let metrics = deps
|
||||
.metrics
|
||||
.find_by_span(&query.user_id, &query.span)
|
||||
.await?;
|
||||
|
||||
let mut by_kind: BTreeMap<MetricKind, BTreeMap<Date, f64>> = BTreeMap::new();
|
||||
for metric in metrics {
|
||||
by_kind
|
||||
.entry(metric.kind())
|
||||
.or_default()
|
||||
.insert(*metric.date(), metric.value().count() as f64);
|
||||
}
|
||||
|
||||
Ok(MetricKind::ALL
|
||||
.into_iter()
|
||||
.map(|kind| {
|
||||
let values = by_kind.remove(&kind).unwrap_or_default();
|
||||
let observations = paired(moods, |date| values.get(date).copied());
|
||||
|
||||
row(
|
||||
CorrelationInput::Metric(kind),
|
||||
None,
|
||||
&observations,
|
||||
query.minimum_sample_size,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn cycle_row(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
) -> Result<Option<Pending>, ApplicationError> {
|
||||
let preferences = preferences_of(&query.user_id, &deps.preferences).await?;
|
||||
|
||||
if !preferences.tracks_cycle() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let calendar = calendar_of(&query.user_id, &deps.cycles).await?;
|
||||
let observations = paired(moods, |date| {
|
||||
calendar
|
||||
.position_on(date)
|
||||
.map(|position| position.progress())
|
||||
});
|
||||
|
||||
Ok(Some(row(
|
||||
CorrelationInput::CycleProgress,
|
||||
None,
|
||||
&observations,
|
||||
query.minimum_sample_size,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn temperature_row(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
timezone: &Timezone,
|
||||
) -> Result<Pending, ApplicationError> {
|
||||
let entries = deps
|
||||
.entries
|
||||
.find_by_date_range(&query.user_id, &instants_of(&query.span))
|
||||
.await?;
|
||||
|
||||
let composed = crate::entry::composition::EntryComposer::new(vec![deps.weather_store.clone()])
|
||||
.compose(entries)
|
||||
.await?;
|
||||
|
||||
let mut warmest: BTreeMap<Date, f64> = BTreeMap::new();
|
||||
for entry in &composed {
|
||||
if let Some(weather) = entry.weather() {
|
||||
warmest.insert(
|
||||
date_of(&entry.entry, timezone),
|
||||
weather.temperature().value(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let observations = paired(moods, |date| warmest.get(date).copied());
|
||||
|
||||
Ok(row(
|
||||
CorrelationInput::Temperature,
|
||||
None,
|
||||
&observations,
|
||||
query.minimum_sample_size,
|
||||
))
|
||||
}
|
||||
|
||||
async fn activity_rows(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
timezone: &Timezone,
|
||||
) -> Result<Vec<Pending>, ApplicationError> {
|
||||
let catalog = deps.activities.find_active_by_user(&query.user_id).await?;
|
||||
let mut rows = Vec::with_capacity(catalog.len());
|
||||
|
||||
for activity in catalog {
|
||||
let days = days_tagged_with(&query.user_id, activity.id(), deps, timezone).await?;
|
||||
|
||||
if never_logged(&days, moods) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let observations = paired(moods, |date| {
|
||||
Some(if days.contains(date) { PRESENT } else { ABSENT })
|
||||
});
|
||||
|
||||
rows.push(row(
|
||||
CorrelationInput::Activity(activity.id().clone()),
|
||||
Some(activity.name().value().to_string()),
|
||||
&observations,
|
||||
query.minimum_sample_size,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn never_logged(days: &BTreeSet<Date>, moods: &BTreeMap<Date, DayMood>) -> bool {
|
||||
!days.iter().any(|date| moods.contains_key(date))
|
||||
}
|
||||
|
||||
async fn days_tagged_with(
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
deps: &Deps,
|
||||
timezone: &Timezone,
|
||||
) -> Result<BTreeSet<Date>, ApplicationError> {
|
||||
let tagged = deps.entries.find_by_activity(user_id, activity_id).await?;
|
||||
|
||||
Ok(tagged
|
||||
.iter()
|
||||
.map(|entry| date_of(entry, timezone))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn paired(
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
value_on: impl Fn(&Date) -> Option<f64>,
|
||||
) -> Vec<Observation> {
|
||||
moods
|
||||
.iter()
|
||||
.filter_map(|(date, mood)| value_on(date).map(|value| Observation::new(value, *mood)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
row: CorrelationRow,
|
||||
significance: Vec<Option<PValue>>,
|
||||
}
|
||||
|
||||
fn mark_what_holds_up(pending: Vec<Pending>, false_discovery_rate: f64) -> Vec<CorrelationRow> {
|
||||
let mut pending = pending;
|
||||
let places = scored_places(&pending);
|
||||
|
||||
let tested: Vec<Tested> = places
|
||||
.iter()
|
||||
.map(|(row_index, score_index)| {
|
||||
let entry = &pending[*row_index];
|
||||
|
||||
Tested {
|
||||
family: entry.row.input.family(),
|
||||
strategy: entry.row.scores[*score_index].strategy,
|
||||
p_value: entry.significance[*score_index]
|
||||
.expect("only places with a p-value are collected"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let adjustment = Adjustment::controlling_false_discovery_at(false_discovery_rate);
|
||||
|
||||
for (place, holds) in places.iter().zip(adjustment.holds_up_across(&tested)) {
|
||||
pending[place.0].row.scores[place.1].held_up = holds;
|
||||
}
|
||||
|
||||
pending.into_iter().map(|entry| entry.row).collect()
|
||||
}
|
||||
|
||||
fn scored_places(pending: &[Pending]) -> Vec<(usize, usize)> {
|
||||
pending
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(row_index, entry)| {
|
||||
entry
|
||||
.significance
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p_value)| p_value.is_some())
|
||||
.map(move |(score_index, _)| (row_index, score_index))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn row(
|
||||
input: CorrelationInput,
|
||||
label: Option<String>,
|
||||
observations: &[Observation],
|
||||
minimum_sample_size: usize,
|
||||
) -> Pending {
|
||||
let sample_size = observations.len();
|
||||
let applicable = CorrelationStrategy::ALL
|
||||
.into_iter()
|
||||
.filter(|strategy| strategy.can_score(&input))
|
||||
.count();
|
||||
|
||||
let (scores, significance) = if sample_size < minimum_sample_size {
|
||||
(Vec::new(), Vec::new())
|
||||
} else {
|
||||
scored(&input, observations)
|
||||
};
|
||||
|
||||
let coefficients: Vec<Coefficient> = scores.iter().map(|score| score.coefficient).collect();
|
||||
|
||||
Pending {
|
||||
row: CorrelationRow {
|
||||
input,
|
||||
label,
|
||||
sample_size,
|
||||
agreement: Agreement::of(&coefficients, applicable),
|
||||
scores,
|
||||
},
|
||||
significance,
|
||||
}
|
||||
}
|
||||
|
||||
fn scored(
|
||||
input: &CorrelationInput,
|
||||
observations: &[Observation],
|
||||
) -> (Vec<Score>, Vec<Option<PValue>>) {
|
||||
CorrelationStrategy::ALL
|
||||
.into_iter()
|
||||
.filter(|strategy| strategy.can_score(input))
|
||||
.filter_map(|strategy| {
|
||||
let coefficient = strategy.score(observations)?;
|
||||
|
||||
Some((
|
||||
Score {
|
||||
strategy,
|
||||
coefficient,
|
||||
held_up: false,
|
||||
},
|
||||
strategy.significance(observations),
|
||||
))
|
||||
})
|
||||
.unzip()
|
||||
}
|
||||
1
crates/application/src/correlation/use_cases/mod.rs
Normal file
1
crates/application/src/correlation/use_cases/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod get_correlations;
|
||||
1
crates/application/src/cycle/mod.rs
Normal file
1
crates/application/src/cycle/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
18
crates/application/src/cycle/use_cases/forget_cycle_start.rs
Normal file
18
crates/application/src/cycle/use_cases/forget_cycle_start.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::ports::CycleStartCommandPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn CycleStartCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, date: Date, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.command.forget(&user_id, &date).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3
crates/application/src/cycle/use_cases/mod.rs
Normal file
3
crates/application/src/cycle/use_cases/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod forget_cycle_start;
|
||||
pub mod read_cycle;
|
||||
pub mod record_cycle_start;
|
||||
55
crates/application/src/cycle/use_cases/read_cycle.rs
Normal file
55
crates/application/src/cycle/use_cases/read_cycle.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::cycle::{CycleCalendar, CyclePosition};
|
||||
use domain::entry::Date;
|
||||
use domain::ports::{CycleStartQueryPort, UserPreferencesQueryPort, UserQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::day::{timezone_for, today_in};
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
pub struct CycleView {
|
||||
pub tracking: bool,
|
||||
pub starts: Vec<Date>,
|
||||
pub today: Option<CyclePosition>,
|
||||
pub usual_length: i64,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn CycleStartQueryPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<CycleView, ApplicationError> {
|
||||
let preferences = preferences_of(&user_id, &deps.preferences).await?;
|
||||
|
||||
if !preferences.tracks_cycle() {
|
||||
return Ok(CycleView {
|
||||
tracking: false,
|
||||
starts: Vec::new(),
|
||||
today: None,
|
||||
usual_length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let calendar = calendar_of(&user_id, &deps.query).await?;
|
||||
let timezone = timezone_for(&user_id, &deps.users).await?;
|
||||
let today = calendar.position_on(&today_in(&timezone));
|
||||
|
||||
Ok(CycleView {
|
||||
tracking: true,
|
||||
starts: calendar.starts().to_vec(),
|
||||
today,
|
||||
usual_length: calendar.usual_length(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn calendar_of(
|
||||
user_id: &UserId,
|
||||
query: &Arc<dyn CycleStartQueryPort>,
|
||||
) -> Result<CycleCalendar, ApplicationError> {
|
||||
Ok(CycleCalendar::new(query.find_by_user(user_id).await?))
|
||||
}
|
||||
28
crates/application/src/cycle/use_cases/record_cycle_start.rs
Normal file
28
crates/application/src/cycle/use_cases/record_cycle_start.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::ports::{CycleStartCommandPort, UserPreferencesQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn CycleStartCommandPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, date: Date, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let preferences = preferences_of(&user_id, &deps.preferences).await?;
|
||||
|
||||
if !preferences.tracks_cycle() {
|
||||
return Err(ApplicationError::Validation(
|
||||
"cycle tracking is off for this account: turn it on in settings first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
deps.command.record(&user_id, &date).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
69
crates/application/src/day.rs
Normal file
69
crates/application/src/day.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveTime, Utc};
|
||||
|
||||
use domain::entry::{Date, DateRange, DateSpan, DayMood, MoodEntry};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UserQueryPort;
|
||||
use domain::user::{Timezone, UserId};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub async fn timezone_for(
|
||||
user_id: &UserId,
|
||||
users: &Arc<dyn UserQueryPort>,
|
||||
) -> Result<Timezone, ApplicationError> {
|
||||
let user = users
|
||||
.find_by_id(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
|
||||
|
||||
user.timezone().copied().ok_or_else(|| {
|
||||
DomainError::InvalidInput(
|
||||
"this account has no timezone set, so days cannot be resolved: set one in settings"
|
||||
.into(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn today_in(timezone: &Timezone) -> Date {
|
||||
Date::from_instant(&Utc::now().fixed_offset(), timezone)
|
||||
}
|
||||
|
||||
pub fn date_of(entry: &MoodEntry, timezone: &Timezone) -> Date {
|
||||
Date::from_instant(entry.logged_at(), timezone)
|
||||
}
|
||||
|
||||
pub fn day_moods(entries: &[MoodEntry], timezone: &Timezone) -> BTreeMap<Date, DayMood> {
|
||||
let mut moods: BTreeMap<Date, Vec<domain::entry::Mood>> = BTreeMap::new();
|
||||
|
||||
for entry in entries {
|
||||
moods
|
||||
.entry(date_of(entry, timezone))
|
||||
.or_default()
|
||||
.push(entry.mood());
|
||||
}
|
||||
|
||||
moods
|
||||
.into_iter()
|
||||
.filter_map(|(date, moods)| DayMood::of(&moods).map(|mood| (date, mood)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn instants_of(span: &DateSpan) -> DateRange {
|
||||
let from = midnight_utc(&span.start().previous());
|
||||
let to = midnight_utc(&span.end().next());
|
||||
|
||||
DateRange::new(from, to).unwrap_or_else(|_| {
|
||||
DateRange::new(from, from).expect("a range of one instant is always valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn midnight_utc(date: &Date) -> DateTime<FixedOffset> {
|
||||
date.value()
|
||||
.and_time(NaiveTime::MIN)
|
||||
.and_utc()
|
||||
.fixed_offset()
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntryId};
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::entry::{Mood, MoodEntryId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -10,19 +9,13 @@ 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>,
|
||||
pub dimensions: Vec<DimensionValue>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
pub logged_at: Option<DateTime<FixedOffset>>,
|
||||
pub dimensions: Vec<DimensionValue>,
|
||||
}
|
||||
|
||||
44
crates/application/src/entry/composition.rs
Normal file
44
crates/application/src/entry/composition.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::{ComposedEntry, DimensionValue};
|
||||
use domain::entry::{MoodEntry, MoodEntryId};
|
||||
use domain::ports::EntryDimensionPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct EntryComposer {
|
||||
dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
}
|
||||
|
||||
impl EntryComposer {
|
||||
pub fn new(dimensions: Vec<Arc<dyn EntryDimensionPort>>) -> Self {
|
||||
Self { dimensions }
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryComposer {
|
||||
pub async fn compose(
|
||||
&self,
|
||||
entries: Vec<MoodEntry>,
|
||||
) -> Result<Vec<ComposedEntry>, ApplicationError> {
|
||||
let entry_ids: Vec<MoodEntryId> = entries.iter().map(|e| e.id().clone()).collect();
|
||||
|
||||
let mut loaded: Vec<HashMap<MoodEntryId, DimensionValue>> =
|
||||
Vec::with_capacity(self.dimensions.len());
|
||||
for port in &self.dimensions {
|
||||
loaded.push(port.load(&entry_ids).await?);
|
||||
}
|
||||
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let dimensions = loaded
|
||||
.iter()
|
||||
.filter_map(|by_entry| by_entry.get(entry.id()).cloned())
|
||||
.collect();
|
||||
ComposedEntry { entry, dimensions }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod composition;
|
||||
pub mod queries;
|
||||
pub mod use_cases;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EventPublisherPort, MoodEntryCommandPort};
|
||||
use domain::ports::{EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
@@ -10,19 +10,18 @@ use super::super::commands::CreateEntryCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
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);
|
||||
let entry = MoodEntry::new(cmd.user_id, cmd.mood, cmd.logged_at);
|
||||
|
||||
deps.entries.save(&entry).await?;
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), &cmd.dimensions).await?;
|
||||
}
|
||||
|
||||
let event = DomainEvent::EntryCreated {
|
||||
entry_id: entry.id().clone(),
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::entry::DateRange;
|
||||
use domain::ports::{CascadeDeletePort, MediaStoragePort};
|
||||
use domain::ports::{CascadeDeletePort, EntryDimensionPort, MediaStoragePort, MoodEntryQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
|
||||
pub struct Deps {
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
@@ -17,23 +22,26 @@ pub async fn execute(
|
||||
range: &DateRange,
|
||||
deps: &Deps,
|
||||
) -> Result<u64, ApplicationError> {
|
||||
let entries = deps
|
||||
let media = media_of_entries_about_to_be_deleted(&user_id, range, deps).await?;
|
||||
|
||||
let deleted = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
delete_media_for(&media, &deps.media_storage).await;
|
||||
|
||||
Ok(entries.len() as u64)
|
||||
Ok(deleted.len() as u64)
|
||||
}
|
||||
|
||||
async fn media_of_entries_about_to_be_deleted(
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<ComposedEntry>, ApplicationError> {
|
||||
let doomed = deps.query.find_by_date_range(user_id, range).await?;
|
||||
|
||||
EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(doomed)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -4,16 +4,20 @@ use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
EventPublisherPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
EntryDimensionPort, EventPublisherPort, MediaStoragePort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
@@ -32,23 +36,15 @@ pub async fn execute(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
let user_id = entry.user_id().clone();
|
||||
let composed = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(vec![entry])
|
||||
.await?;
|
||||
delete_media_for(&composed, &deps.media_storage).await;
|
||||
|
||||
deps.command.delete(&entry_id).await?;
|
||||
|
||||
let event = DomainEvent::EntryDeleted {
|
||||
entry_id,
|
||||
user_id: entry.user_id().clone(),
|
||||
};
|
||||
let event = DomainEvent::EntryDeleted { entry_id, user_id };
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
))
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use domain::entry::{DateRange, Mood, MoodEntry};
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::cycle::CycleDay;
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::entry::{Date, DateRange, DayMood};
|
||||
use domain::ports::{
|
||||
CycleStartQueryPort, EntryDimensionPort, MoodEntryQueryPort, UserPreferencesQueryPort,
|
||||
UserQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::cycle::use_cases::read_cycle::calendar_of;
|
||||
use crate::day::{date_of, timezone_for};
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
pub struct CalendarDay {
|
||||
pub date: NaiveDate,
|
||||
pub entries: Vec<MoodEntry>,
|
||||
pub dominant_mood: Option<Mood>,
|
||||
pub date: Date,
|
||||
pub entries: Vec<ComposedEntry>,
|
||||
pub day_mood: Option<DayMood>,
|
||||
pub cycle_day: Option<CycleDay>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub cycles: Arc<dyn CycleStartQueryPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -26,21 +38,34 @@ pub async fn execute(
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<CalendarDay>, ApplicationError> {
|
||||
let entries = deps.query.find_by_date_range(&user_id, &range).await?;
|
||||
let entries = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await?;
|
||||
|
||||
let mut by_date: BTreeMap<NaiveDate, Vec<MoodEntry>> = BTreeMap::new();
|
||||
let timezone = timezone_for(&user_id, &deps.users).await?;
|
||||
|
||||
let mut by_date: BTreeMap<Date, Vec<ComposedEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let date = entry.logged_at().date_naive();
|
||||
let date = date_of(&entry.entry, &timezone);
|
||||
by_date.entry(date).or_default().push(entry);
|
||||
}
|
||||
|
||||
let cycles = tracked_cycle(&user_id, deps).await?;
|
||||
|
||||
let days = by_date
|
||||
.into_iter()
|
||||
.map(|(date, entries)| {
|
||||
let dominant_mood = find_dominant_mood(&entries);
|
||||
let day_mood = day_mood_of(&entries);
|
||||
let cycle_day = cycles
|
||||
.as_ref()
|
||||
.and_then(|calendar| calendar.position_on(&date))
|
||||
.map(|position| position.day());
|
||||
|
||||
CalendarDay {
|
||||
date,
|
||||
entries,
|
||||
dominant_mood,
|
||||
day_mood,
|
||||
cycle_day,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -48,21 +73,21 @@ pub async fn execute(
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
fn find_dominant_mood(entries: &[MoodEntry]) -> Option<Mood> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
async fn tracked_cycle(
|
||||
user_id: &domain::user::UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Option<domain::cycle::CycleCalendar>, ApplicationError> {
|
||||
let preferences = preferences_of(user_id, &deps.preferences).await?;
|
||||
|
||||
if !preferences.tracks_cycle() {
|
||||
return Ok(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()
|
||||
Ok(Some(calendar_of(user_id, &deps.cycles).await?))
|
||||
}
|
||||
|
||||
fn day_mood_of(entries: &[ComposedEntry]) -> Option<DayMood> {
|
||||
let moods: Vec<_> = entries.iter().map(|entry| entry.entry.mood()).collect();
|
||||
|
||||
DayMood::of(&moods)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
use domain::ports::{MoodEntryQueryPort, UserQueryPort};
|
||||
use domain::services::MoodAnalyzerService;
|
||||
|
||||
use crate::day::{date_of, timezone_for, today_in};
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::MoodStatsQuery;
|
||||
@@ -17,6 +18,7 @@ pub struct MoodStats {
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -30,10 +32,16 @@ pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, Ap
|
||||
None => deps.query.find_by_user(&query.user_id, None, None).await?,
|
||||
};
|
||||
|
||||
let timezone = timezone_for(&query.user_id, &deps.users).await?;
|
||||
let dates: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|entry| date_of(entry, &timezone))
|
||||
.collect();
|
||||
|
||||
Ok(MoodStats {
|
||||
average: MoodAnalyzerService::average_mood(&entries),
|
||||
frequency: MoodAnalyzerService::mood_frequency(&entries),
|
||||
current_streak: MoodAnalyzerService::current_streak(&entries),
|
||||
current_streak: MoodAnalyzerService::current_streak(&dates, today_in(&timezone)),
|
||||
total_entries: entries.len(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::lookup;
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
EventPublisherPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
EntryDimensionPort, EventPublisherPort, MediaStoragePort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::UpdateEntryCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
@@ -34,25 +38,29 @@ pub async fn execute(
|
||||
|
||||
verify_ownership(entry.user_id(), &caller_id)?;
|
||||
|
||||
let old_photos = entry.photos().to_vec();
|
||||
let old_memos = entry.voice_memos().to_vec();
|
||||
let previous = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(vec![entry.clone()])
|
||||
.await?;
|
||||
let old_photos = previous[0].photos().to_vec();
|
||||
let old_memos = previous[0].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);
|
||||
if let Some(logged_at) = cmd.logged_at {
|
||||
entry.update_logged_at(logged_at);
|
||||
}
|
||||
|
||||
let new_photos = lookup::photos_in(&cmd.dimensions);
|
||||
let new_memos = lookup::voice_memos_in(&cmd.dimensions);
|
||||
|
||||
for photo_id in &old_photos {
|
||||
if !entry.photos().contains(photo_id)
|
||||
if !new_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)
|
||||
if !new_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");
|
||||
@@ -60,6 +68,9 @@ pub async fn execute(
|
||||
}
|
||||
|
||||
deps.command.save(&entry).await?;
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), &cmd.dimensions).await?;
|
||||
}
|
||||
|
||||
let event = DomainEvent::EntryUpdated {
|
||||
entry_id: entry.id().clone(),
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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 +1,2 @@
|
||||
pub mod export_user_data;
|
||||
pub mod write_backup;
|
||||
pub mod write_extract;
|
||||
|
||||
108
crates/application/src/export/use_cases/write_backup.rs
Normal file
108
crates/application/src/export/use_cases/write_backup.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::cycle::CycleStartRestore;
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::entry::DateSpan;
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, BackupMedia, BackupWriterPort, CycleStartQueryPort, DailyMetricQueryPort,
|
||||
EntryDimensionPort, MediaBlob, MediaStoragePort, MoodEntryQueryPort, ReminderQueryPort,
|
||||
UserPreferencesQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub reminders: Arc<dyn ReminderQueryPort>,
|
||||
pub metrics: Arc<dyn DailyMetricQueryPort>,
|
||||
pub cycles: Arc<dyn CycleStartQueryPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub writer: Arc<dyn BackupWriterPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<u8>, ApplicationError> {
|
||||
let entries = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(deps.entries.find_by_user(&user_id, None, None).await?)
|
||||
.await?;
|
||||
|
||||
let media = gather_media(&entries, deps).await?;
|
||||
|
||||
let backup = domain::ports::UserBackup {
|
||||
metrics: deps.metrics.find_by_span(&user_id, &everything()).await?,
|
||||
cycle_starts: deps
|
||||
.cycles
|
||||
.find_by_user(&user_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(CycleStartRestore::on)
|
||||
.collect(),
|
||||
activities: deps.activities.find_by_user(&user_id).await?,
|
||||
reminders: deps.reminders.find_by_user(&user_id).await?,
|
||||
preferences: preferences_of(&user_id, &deps.preferences).await?,
|
||||
entries,
|
||||
media,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
entries = backup.entries.len(),
|
||||
metrics = backup.metrics.len(),
|
||||
cycle_starts = backup.cycle_starts.len(),
|
||||
activities = backup.activities.len(),
|
||||
reminders = backup.reminders.len(),
|
||||
photos = backup.media.photos.len(),
|
||||
voice_memos = backup.media.voice_memos.len(),
|
||||
"writing a complete backup"
|
||||
);
|
||||
|
||||
Ok(deps.writer.write(&backup).await?)
|
||||
}
|
||||
|
||||
fn everything() -> DateSpan {
|
||||
let first = domain::entry::Date::from_persistence(
|
||||
chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap_or_default(),
|
||||
);
|
||||
let last = domain::entry::Date::from_persistence(
|
||||
chrono::NaiveDate::from_ymd_opt(9999, 12, 31).unwrap_or_default(),
|
||||
);
|
||||
|
||||
DateSpan::new(first, last).expect("1970 precedes 9999")
|
||||
}
|
||||
|
||||
async fn gather_media(
|
||||
entries: &[ComposedEntry],
|
||||
deps: &Deps,
|
||||
) -> Result<BackupMedia, ApplicationError> {
|
||||
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 memo_id in entry.voice_memos() {
|
||||
if let Some(file) = deps.media_storage.get_voice_memo(memo_id).await? {
|
||||
voice_memos.push(MediaBlob {
|
||||
id: memo_id.value().to_string(),
|
||||
data: file.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BackupMedia {
|
||||
photos,
|
||||
voice_memos,
|
||||
})
|
||||
}
|
||||
35
crates/application/src/export/use_cases/write_extract.rs
Normal file
35
crates/application/src/export/use_cases/write_extract.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, EntryDimensionPort, ExtractWriterPort, MoodEntryQueryPort, SharedExtract,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub writer: Arc<dyn ExtractWriterPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<u8>, ApplicationError> {
|
||||
let entries = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(deps.entries.find_by_user(&user_id, None, None).await?)
|
||||
.await?;
|
||||
|
||||
let extract = SharedExtract {
|
||||
entries,
|
||||
activities: deps.activities.find_by_user(&user_id).await?,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
entries = extract.entries.len(),
|
||||
"writing a shareable extract"
|
||||
);
|
||||
|
||||
Ok(deps.writer.write(&extract).await?)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -5,3 +6,23 @@ pub struct ImportCommand {
|
||||
pub user_id: UserId,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportedMetric {
|
||||
pub kind: String,
|
||||
pub value: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportedDay {
|
||||
pub date: String,
|
||||
pub metrics: Vec<ImportedMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportDailyMetricsCommand {
|
||||
pub user_id: UserId,
|
||||
pub provider: ProviderName,
|
||||
pub days: Vec<ImportedDay>,
|
||||
pub maximum_days: usize,
|
||||
}
|
||||
|
||||
150
crates/application/src/import/use_cases/import_daily_metrics.rs
Normal file
150
crates/application/src/import/use_cases/import_daily_metrics.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source};
|
||||
use domain::ports::{DailyMetricCommandPort, RejectionCommandPort};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::import::commands::{ImportDailyMetricsCommand, ImportedDay, ImportedMetric};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RejectionSummary {
|
||||
pub date: Option<String>,
|
||||
pub kind: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportOutcome {
|
||||
pub accepted: usize,
|
||||
pub superseded: usize,
|
||||
pub rejected: Vec<RejectionSummary>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub metrics: Arc<dyn DailyMetricCommandPort>,
|
||||
pub rejections: Arc<dyn RejectionCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps), fields(days = command.days.len()))]
|
||||
pub async fn execute(
|
||||
command: ImportDailyMetricsCommand,
|
||||
deps: &Deps,
|
||||
) -> Result<ImportOutcome, ApplicationError> {
|
||||
if command.days.len() > command.maximum_days {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"an import may carry at most {} days, this one carries {}",
|
||||
command.maximum_days,
|
||||
command.days.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut writable = Vec::new();
|
||||
let mut refused = Vec::new();
|
||||
|
||||
for day in &command.days {
|
||||
read_a_day(
|
||||
day,
|
||||
&command.user_id,
|
||||
&command.provider,
|
||||
&mut writable,
|
||||
&mut refused,
|
||||
);
|
||||
}
|
||||
|
||||
let submitted = writable.len();
|
||||
let written = deps.metrics.save(&writable).await?;
|
||||
|
||||
record(&refused, deps).await;
|
||||
|
||||
tracing::info!(
|
||||
accepted = written,
|
||||
superseded = submitted - written,
|
||||
rejected = refused.len(),
|
||||
provider = command.provider.value(),
|
||||
"imported daily metrics"
|
||||
);
|
||||
|
||||
Ok(ImportOutcome {
|
||||
accepted: written,
|
||||
superseded: submitted - written,
|
||||
rejected: refused.iter().map(summarise).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_a_day(
|
||||
day: &ImportedDay,
|
||||
user_id: &UserId,
|
||||
provider: &ProviderName,
|
||||
writable: &mut Vec<DailyMetric>,
|
||||
refused: &mut Vec<RejectedMetric>,
|
||||
) {
|
||||
let Some(date) = day.date.parse().ok().map(Date::from_persistence) else {
|
||||
for metric in &day.metrics {
|
||||
refused.push(rejection(
|
||||
user_id,
|
||||
RejectionDetail::new(Some(provider.clone()), None, &metric.kind, metric.value),
|
||||
format!("{} is not a date", day.date),
|
||||
));
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
for metric in &day.metrics {
|
||||
match read_a_metric(metric) {
|
||||
Ok(value) => writable.push(DailyMetric::new(
|
||||
user_id.clone(),
|
||||
date,
|
||||
value,
|
||||
Source::Provider(provider.clone()),
|
||||
)),
|
||||
Err(reason) => refused.push(rejection(
|
||||
user_id,
|
||||
RejectionDetail::new(
|
||||
Some(provider.clone()),
|
||||
Some(date),
|
||||
&metric.kind,
|
||||
metric.value,
|
||||
),
|
||||
reason,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_a_metric(metric: &ImportedMetric) -> Result<MetricValue, String> {
|
||||
let kind = MetricKind::from_name(&metric.kind)
|
||||
.ok_or_else(|| format!("unknown metric kind: {}", metric.kind))?;
|
||||
|
||||
let Some(count) = metric.value else {
|
||||
return Err("a provider cannot clear a reading: only the user can".into());
|
||||
};
|
||||
|
||||
MetricValue::of_kind(kind, count).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn rejection(user_id: &UserId, detail: RejectionDetail, reason: String) -> RejectedMetric {
|
||||
RejectedMetric::new(user_id.clone(), RejectionOrigin::Import, detail, reason)
|
||||
}
|
||||
|
||||
fn summarise(rejected: &RejectedMetric) -> RejectionSummary {
|
||||
RejectionSummary {
|
||||
date: rejected.detail().date().map(|date| date.to_string()),
|
||||
kind: rejected.detail().kind().to_string(),
|
||||
reason: rejected.reason().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record(refused: &[RejectedMetric], deps: &Deps) {
|
||||
if refused.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = deps.rejections.record(refused).await {
|
||||
tracing::warn!(%error, "could not write to the rejection trace");
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,19 @@ use chrono::FixedOffset;
|
||||
|
||||
use config::PresetConfig;
|
||||
use domain::activity::{ActivityId, ActivityName, CategoryName};
|
||||
use domain::dimension::{ComposedEntry, DimensionValue};
|
||||
use domain::entry::{Content, Mood, MoodEntry};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, ImportSourcePort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort,
|
||||
ActivityCommandPort, ActivityQueryPort, EntryDimensionPort, ImportSourcePort,
|
||||
MoodEntryCommandPort, MoodEntryQueryPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::Timezone;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::ImportCommand;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportResult {
|
||||
pub imported: u64,
|
||||
pub skipped: u64,
|
||||
@@ -27,6 +30,8 @@ pub struct Deps {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
pub preset: PresetConfig,
|
||||
}
|
||||
|
||||
@@ -35,6 +40,8 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
|
||||
let rows = deps.source.read_entries(&cmd.data).await?;
|
||||
tracing::info!(row_count = rows.len(), "parsed import data");
|
||||
|
||||
let timezone = crate::day::timezone_for(&cmd.user_id, &deps.users).await?;
|
||||
|
||||
let existing_entries = deps
|
||||
.entry_query
|
||||
.find_by_user(&cmd.user_id, None, None)
|
||||
@@ -68,6 +75,7 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
|
||||
&mut activity_cache,
|
||||
&category_map,
|
||||
&existing_keys,
|
||||
&timezone,
|
||||
deps,
|
||||
)
|
||||
.await
|
||||
@@ -83,7 +91,18 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
|
||||
}
|
||||
|
||||
let imported = batch.len() as u64;
|
||||
deps.entry_command.save_batch(&batch).await?;
|
||||
|
||||
let entries: Vec<MoodEntry> = batch
|
||||
.iter()
|
||||
.map(|composed| composed.entry.clone())
|
||||
.collect();
|
||||
deps.entry_command.save_batch(&entries).await?;
|
||||
|
||||
for composed in &batch {
|
||||
for port in &deps.dimensions {
|
||||
port.save(composed.entry.id(), &composed.dimensions).await?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(imported, skipped, "import completed");
|
||||
|
||||
@@ -100,10 +119,11 @@ async fn build_entry(
|
||||
activity_cache: &mut HashMap<String, ActivityId>,
|
||||
category_map: &HashMap<String, String>,
|
||||
existing_keys: &HashSet<(String, u8)>,
|
||||
timezone: &Timezone,
|
||||
deps: &Deps,
|
||||
) -> Result<Option<MoodEntry>, ApplicationError> {
|
||||
) -> Result<Option<ComposedEntry>, ApplicationError> {
|
||||
let mood = Mood::try_from(row.mood)?;
|
||||
let logged_at = parse_datetime(&row.date, &row.time)?;
|
||||
let logged_at = the_users_own_wall_clock(&row.date, &row.time, timezone)?;
|
||||
|
||||
let key = (logged_at.to_rfc3339(), mood.value());
|
||||
if existing_keys.contains(&key) {
|
||||
@@ -117,13 +137,21 @@ async fn build_entry(
|
||||
activity_ids.push(id);
|
||||
}
|
||||
|
||||
let content = row.note.as_ref().and_then(|n| Content::new(n).ok());
|
||||
let mut dimensions: Vec<DimensionValue> = row
|
||||
.note
|
||||
.as_ref()
|
||||
.and_then(|note| Content::new(note).ok())
|
||||
.map(DimensionValue::Content)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut entry = MoodEntry::new(user_id.clone(), mood, logged_at);
|
||||
entry.set_activities(activity_ids);
|
||||
entry.set_content(content);
|
||||
if !activity_ids.is_empty() {
|
||||
dimensions.push(DimensionValue::activities(activity_ids));
|
||||
}
|
||||
|
||||
Ok(Some(entry))
|
||||
let entry = MoodEntry::new(user_id.clone(), mood, logged_at);
|
||||
|
||||
Ok(Some(ComposedEntry { entry, dimensions }))
|
||||
}
|
||||
|
||||
async fn resolve_activity(
|
||||
@@ -152,16 +180,47 @@ async fn resolve_activity(
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn parse_datetime(
|
||||
fn the_users_own_wall_clock(
|
||||
date: &str,
|
||||
time: &str,
|
||||
timezone: &Timezone,
|
||||
) -> Result<chrono::DateTime<FixedOffset>, ApplicationError> {
|
||||
let datetime_str = format!("{date} {time}");
|
||||
let written = 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 wall_clock = chrono::NaiveDateTime::parse_from_str(&written, "%Y-%m-%d %I:%M %p")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(&written, "%Y-%m-%d %H:%M"))
|
||||
.map_err(|error| ApplicationError::Validation(format!("invalid date/time: {error}")))?;
|
||||
|
||||
let offset = FixedOffset::east_opt(0).expect("UTC offset is always valid");
|
||||
Ok(naive.and_local_timezone(offset).unwrap())
|
||||
Ok(placed_in(wall_clock, timezone).fixed_offset())
|
||||
}
|
||||
|
||||
fn placed_in(
|
||||
wall_clock: chrono::NaiveDateTime,
|
||||
timezone: &Timezone,
|
||||
) -> chrono::DateTime<chrono_tz::Tz> {
|
||||
use chrono::offset::LocalResult;
|
||||
|
||||
match wall_clock.and_local_timezone(timezone.resolve()) {
|
||||
LocalResult::Single(placed) => placed,
|
||||
LocalResult::Ambiguous(earlier, _) => earlier,
|
||||
LocalResult::None => an_hour_the_clocks_skipped(wall_clock, timezone),
|
||||
}
|
||||
}
|
||||
|
||||
fn an_hour_the_clocks_skipped(
|
||||
wall_clock: chrono::NaiveDateTime,
|
||||
timezone: &Timezone,
|
||||
) -> chrono::DateTime<chrono_tz::Tz> {
|
||||
let after_the_jump = wall_clock + chrono::Duration::hours(1);
|
||||
|
||||
tracing::warn!(
|
||||
wall_clock = %wall_clock,
|
||||
timezone = timezone.value(),
|
||||
"this wall clock time never happened, so the entry lands an hour later"
|
||||
);
|
||||
|
||||
after_the_jump
|
||||
.and_local_timezone(timezone.resolve())
|
||||
.earliest()
|
||||
.unwrap_or_else(|| wall_clock.and_utc().with_timezone(&timezone.resolve()))
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod import_daily_metrics;
|
||||
pub mod import_entries;
|
||||
|
||||
1
crates/application/src/job/mod.rs
Normal file
1
crates/application/src/job/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
3
crates/application/src/job/use_cases/mod.rs
Normal file
3
crates/application/src/job/use_cases/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod run_due_jobs;
|
||||
pub mod sweep_recording_backlog;
|
||||
pub mod sweep_weather_backlog;
|
||||
170
crates/application/src/job/use_cases/run_due_jobs.rs
Normal file
170
crates/application/src/job/use_cases/run_due_jobs.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::job::{Job, JobKind, JobSubject};
|
||||
use domain::ports::{
|
||||
EntryDimensionPort, JobQueueCommandPort, RecordingBackfillQueryPort, RecordingLookupPort,
|
||||
UnidentifiedSong, UnwatchedPlace, WeatherBacklogQueryPort, WeatherLookupPort,
|
||||
};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Worked {
|
||||
pub finished: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub queue: Arc<dyn JobQueueCommandPort>,
|
||||
pub backfill: Arc<dyn RecordingBackfillQueryPort>,
|
||||
pub recordings: Arc<dyn RecordingLookupPort>,
|
||||
pub places: Arc<dyn WeatherBacklogQueryPort>,
|
||||
pub weather: Option<Arc<dyn WeatherLookupPort>>,
|
||||
pub weather_store: Arc<dyn EntryDimensionPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
most: usize,
|
||||
most_attempts: u32,
|
||||
deps: &Deps,
|
||||
) -> Result<Worked, ApplicationError> {
|
||||
let mut claimed = Vec::new();
|
||||
|
||||
for kind in JobKind::ALL {
|
||||
claimed.extend(deps.queue.claim(kind, most).await?);
|
||||
}
|
||||
|
||||
let mut worked = Worked::default();
|
||||
|
||||
for job in claimed {
|
||||
match attempt(&job, deps).await {
|
||||
Ok(()) => {
|
||||
deps.queue.finish(job.id()).await?;
|
||||
worked.finished += 1;
|
||||
}
|
||||
Err(reason) => {
|
||||
give_up_or_retry(&job, &reason, most_attempts, deps).await?;
|
||||
worked.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(worked)
|
||||
}
|
||||
|
||||
async fn give_up_or_retry(
|
||||
job: &Job,
|
||||
reason: &str,
|
||||
most_attempts: u32,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let attempted = job.attempts() + 1;
|
||||
|
||||
if attempted < most_attempts {
|
||||
deps.queue.release(job.id(), reason).await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
job_id = %job.id(),
|
||||
kind = job.kind().name(),
|
||||
attempts = attempted,
|
||||
reason,
|
||||
"giving up on a job; it stays visible so it can be retried deliberately"
|
||||
);
|
||||
|
||||
deps.queue.exhaust(job.id(), reason).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn attempt(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
match job.kind() {
|
||||
JobKind::BackfillRecordingIdentity => backfill_recording_identity(job, deps).await,
|
||||
JobKind::ObserveWeather => observe_weather(job, deps).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_weather(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
let JobSubject::Entry(entry_id) = job.subject();
|
||||
|
||||
let Some(lookup) = &deps.weather else {
|
||||
return Err("weather lookups are switched off".into());
|
||||
};
|
||||
|
||||
let waiting = deps
|
||||
.places
|
||||
.find_places_without_weather(usize::MAX)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(place) = waiting.iter().find(|place| &place.entry_id == entry_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let observed = lookup
|
||||
.observed_at(&place.coordinates, &place.logged_at)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(weather) = observed else {
|
||||
tracing::debug!(entry_id = %entry_id, "the provider had no weather for this place and time");
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
remember_weather(place, weather, deps).await
|
||||
}
|
||||
|
||||
async fn remember_weather(
|
||||
place: &UnwatchedPlace,
|
||||
weather: domain::weather::Weather,
|
||||
deps: &Deps,
|
||||
) -> Result<(), String> {
|
||||
deps.weather_store
|
||||
.save(&place.entry_id, &[DimensionValue::Weather(weather)])
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn backfill_recording_identity(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
let JobSubject::Entry(entry_id) = job.subject();
|
||||
|
||||
let waiting = deps
|
||||
.backfill
|
||||
.find_songs_without_a_recording(usize::MAX)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(song) = waiting.iter().find(|song| &song.entry_id == entry_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let found = deps
|
||||
.recordings
|
||||
.find_recording(&song.title, &song.artist)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(recording_id) = found else {
|
||||
tracing::debug!(title = %song.title, "no recording matched this song");
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
remember(song, &recording_id, deps).await
|
||||
}
|
||||
|
||||
async fn remember(
|
||||
song: &UnidentifiedSong,
|
||||
recording_id: &domain::song::RecordingId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), String> {
|
||||
deps.backfill
|
||||
.record_identity(&song.entry_id, recording_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::job::JobKind;
|
||||
use domain::ports::{JobQueueCommandPort, RecordingBackfillQueryPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub backlog: Arc<dyn RecordingBackfillQueryPort>,
|
||||
pub queue: Arc<dyn JobQueueCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(most: usize, deps: &Deps) -> Result<usize, ApplicationError> {
|
||||
let stranded = deps.backlog.find_songs_without_a_recording(most).await?;
|
||||
let mut enqueued = 0;
|
||||
|
||||
for song in &stranded {
|
||||
if deps
|
||||
.queue
|
||||
.enqueue(JobKind::BackfillRecordingIdentity, &song.subject())
|
||||
.await?
|
||||
{
|
||||
enqueued += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if enqueued > 0 {
|
||||
tracing::info!(
|
||||
enqueued,
|
||||
found = stranded.len(),
|
||||
"swept songs with no recording identity onto the queue"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(enqueued)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::job::JobKind;
|
||||
use domain::ports::{JobQueueCommandPort, WeatherBacklogQueryPort};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub backlog: Arc<dyn WeatherBacklogQueryPort>,
|
||||
pub queue: Arc<dyn JobQueueCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(most: usize, deps: &Deps) -> Result<usize, ApplicationError> {
|
||||
let stranded = deps.backlog.find_places_without_weather(most).await?;
|
||||
let mut enqueued = 0;
|
||||
|
||||
for place in &stranded {
|
||||
if deps
|
||||
.queue
|
||||
.enqueue(JobKind::ObserveWeather, &place.subject())
|
||||
.await?
|
||||
{
|
||||
enqueued += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if enqueued > 0 {
|
||||
tracing::info!(
|
||||
enqueued,
|
||||
found = stranded.len(),
|
||||
"swept places with no weather onto the queue"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(enqueued)
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
pub mod activity;
|
||||
pub mod api_token;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod correlation;
|
||||
pub mod cycle;
|
||||
pub mod day;
|
||||
pub mod entry;
|
||||
pub mod errors;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod job;
|
||||
pub mod media;
|
||||
pub mod metric;
|
||||
pub mod provider;
|
||||
pub mod push;
|
||||
pub mod reminder;
|
||||
pub mod restore;
|
||||
pub mod user;
|
||||
|
||||
19
crates/application/src/media/cleanup.rs
Normal file
19
crates/application/src/media/cleanup.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
pub async fn delete_media_for(entries: &[ComposedEntry], storage: &Arc<dyn MediaStoragePort>) {
|
||||
for entry in entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = 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) = storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod cleanup;
|
||||
pub mod use_cases;
|
||||
|
||||
26
crates/application/src/metric/commands.rs
Normal file
26
crates/application/src/metric/commands.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use domain::entry::Date;
|
||||
use domain::metric::{MetricKind, MetricValue, Source};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetricChange {
|
||||
Stated(MetricValue),
|
||||
Cleared(MetricKind),
|
||||
}
|
||||
|
||||
impl MetricChange {
|
||||
pub fn kind(&self) -> MetricKind {
|
||||
match self {
|
||||
Self::Stated(value) => value.kind(),
|
||||
Self::Cleared(kind) => *kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetDailyMetricsCommand {
|
||||
pub user_id: UserId,
|
||||
pub date: Date,
|
||||
pub changes: Vec<MetricChange>,
|
||||
pub source: Source,
|
||||
}
|
||||
2
crates/application/src/metric/mod.rs
Normal file
2
crates/application/src/metric/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::DateSpan;
|
||||
use domain::metric::DailyMetric;
|
||||
use domain::ports::DailyMetricQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub metrics: Arc<dyn DailyMetricQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
span: DateSpan,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<DailyMetric>, ApplicationError> {
|
||||
Ok(deps.metrics.find_by_span(&user_id, &span).await?)
|
||||
}
|
||||
2
crates/application/src/metric/use_cases/mod.rs
Normal file
2
crates/application/src/metric/use_cases/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod list_daily_metrics;
|
||||
pub mod set_daily_metrics;
|
||||
101
crates/application/src/metric/use_cases/set_daily_metrics.rs
Normal file
101
crates/application/src/metric/use_cases/set_daily_metrics.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::metric::{DailyMetric, MetricKind};
|
||||
use domain::ports::{DailyMetricCommandPort, UserQueryPort};
|
||||
|
||||
use crate::day::{timezone_for, today_in};
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::metric::commands::{MetricChange, SetDailyMetricsCommand};
|
||||
|
||||
pub struct Deps {
|
||||
pub metrics: Arc<dyn DailyMetricCommandPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(command: SetDailyMetricsCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let timezone = timezone_for(&command.user_id, &deps.users).await?;
|
||||
|
||||
if command.date > today_in(&timezone) {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"cannot record a metric for {}, which has not happened yet",
|
||||
command.date
|
||||
)));
|
||||
}
|
||||
|
||||
reject_a_kind_named_twice(&command.changes)?;
|
||||
reject_clearing_by_anyone_but_the_user(&command)?;
|
||||
|
||||
let cleared: Vec<MetricKind> = command
|
||||
.changes
|
||||
.iter()
|
||||
.filter_map(|change| match change {
|
||||
MetricChange::Cleared(kind) => Some(*kind),
|
||||
MetricChange::Stated(_) => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let stated: Vec<DailyMetric> = command
|
||||
.changes
|
||||
.iter()
|
||||
.filter_map(|change| match change {
|
||||
MetricChange::Stated(value) => Some(DailyMetric::new(
|
||||
command.user_id.clone(),
|
||||
command.date,
|
||||
*value,
|
||||
command.source.clone(),
|
||||
)),
|
||||
MetricChange::Cleared(_) => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !cleared.is_empty() {
|
||||
deps.metrics
|
||||
.delete(&command.user_id, &command.date, &cleared)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !stated.is_empty() {
|
||||
deps.metrics.save(&stated).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_clearing_by_anyone_but_the_user(
|
||||
command: &SetDailyMetricsCommand,
|
||||
) -> Result<(), ApplicationError> {
|
||||
if command.source.is_manual() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let clears = command
|
||||
.changes
|
||||
.iter()
|
||||
.any(|change| matches!(change, MetricChange::Cleared(_)));
|
||||
|
||||
if clears {
|
||||
return Err(ApplicationError::Validation(
|
||||
"a provider cannot clear a reading: only the user can".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_a_kind_named_twice(changes: &[MetricChange]) -> Result<(), ApplicationError> {
|
||||
for (position, change) in changes.iter().enumerate() {
|
||||
let named_again = changes[position + 1..]
|
||||
.iter()
|
||||
.any(|later| later.kind() == change.kind());
|
||||
|
||||
if named_again {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"{} appears more than once in the same request",
|
||||
change.kind().name()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
18
crates/application/src/provider/commands.rs
Normal file
18
crates/application/src/provider/commands.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
pub struct ConnectProviderCommand {
|
||||
pub user_id: UserId,
|
||||
pub provider: ProviderName,
|
||||
pub credential: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ConnectProviderCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ConnectProviderCommand")
|
||||
.field("user_id", &self.user_id)
|
||||
.field("provider", &self.provider)
|
||||
.field("credential", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
2
crates/application/src/provider/mod.rs
Normal file
2
crates/application/src/provider/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ProviderConnectionCommandPort;
|
||||
use domain::provider::{CredentialCipher, ProviderConnection};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::ConnectProviderCommand;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ProviderConnectionCommandPort>,
|
||||
pub cipher: Arc<dyn CredentialCipher>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps, cmd), fields(provider = cmd.provider.value()))]
|
||||
pub async fn execute(cmd: ConnectProviderCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let credential = deps.cipher.encrypt(&cmd.credential)?;
|
||||
let connection = ProviderConnection::new(cmd.user_id, cmd.provider, credential);
|
||||
|
||||
deps.command.save(&connection).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ProviderConnectionCommandPort;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ProviderConnectionCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
provider: ProviderName,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
deps.command.delete(&user_id, &provider).await?;
|
||||
Ok(())
|
||||
}
|
||||
78
crates/application/src/provider/use_cases/get_now_playing.rs
Normal file
78
crates/application/src/provider/use_cases/get_now_playing.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{NowPlayingPort, ProviderConnectionQueryPort, RecordingLookupPort};
|
||||
use domain::provider::{CredentialCipher, ProviderName};
|
||||
use domain::song::Song;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ProviderConnectionQueryPort>,
|
||||
pub cipher: Arc<dyn CredentialCipher>,
|
||||
pub now_playing: Arc<dyn NowPlayingPort>,
|
||||
pub recordings: Arc<dyn RecordingLookupPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Option<Song>, ApplicationError> {
|
||||
let provider = ProviderName::new(deps.now_playing.provider())?;
|
||||
|
||||
let connection = deps
|
||||
.query
|
||||
.find_by_user_and_provider(&user_id, &provider)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DomainError::NotFound(format!("no {} connection for this user", provider.value()))
|
||||
})?;
|
||||
|
||||
let credential = deps.cipher.decrypt(connection.credential())?;
|
||||
|
||||
let asked = deps.now_playing.now_playing(&credential).await;
|
||||
|
||||
let Some(song) = unreachable_is_not_playing(asked, &provider) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(enrich(song, deps).await))
|
||||
}
|
||||
|
||||
fn unreachable_is_not_playing(
|
||||
asked: Result<Option<Song>, DomainError>,
|
||||
provider: &ProviderName,
|
||||
) -> Option<Song> {
|
||||
match asked {
|
||||
Ok(playing) => playing,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = provider.value(),
|
||||
%error,
|
||||
"could not ask what is playing, so nothing is offered"
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich(song: Song, deps: &Deps) -> Song {
|
||||
if song.recording_id().is_some() {
|
||||
return song;
|
||||
}
|
||||
|
||||
let found = deps
|
||||
.recordings
|
||||
.find_recording(song.title().value(), song.artist().value())
|
||||
.await;
|
||||
|
||||
match found {
|
||||
Ok(Some(recording_id)) => Song::from_persistence(
|
||||
song.title().clone(),
|
||||
song.artist().clone(),
|
||||
song.album().cloned(),
|
||||
Some(recording_id),
|
||||
),
|
||||
_ => song,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use domain::ports::ProviderConnectionQueryPort;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct ConnectionSummary {
|
||||
pub provider: ProviderName,
|
||||
pub connected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn ProviderConnectionQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<ConnectionSummary>, ApplicationError> {
|
||||
let connections = deps.query.find_by_user(&user_id).await?;
|
||||
|
||||
Ok(connections
|
||||
.into_iter()
|
||||
.map(|connection| ConnectionSummary {
|
||||
provider: connection.provider().clone(),
|
||||
connected_at: *connection.created_at(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
4
crates/application/src/provider/use_cases/mod.rs
Normal file
4
crates/application/src/provider/use_cases/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod connect_provider;
|
||||
pub mod disconnect_provider;
|
||||
pub mod get_now_playing;
|
||||
pub mod list_connections;
|
||||
@@ -23,11 +23,17 @@ pub async fn execute(deps: &Deps) -> Result<u64, ApplicationError> {
|
||||
|
||||
let mut sent_count = 0u64;
|
||||
|
||||
// One user with no reachable device must not stop the sweep for everyone
|
||||
// scheduled behind them.
|
||||
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;
|
||||
match deps.sender.send_reminder(reminder.user_id()).await {
|
||||
Ok(()) => sent_count += 1,
|
||||
Err(error) => {
|
||||
tracing::warn!(user_id = %reminder.user_id(), %error, "could not send reminder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,27 +54,13 @@ async fn should_send(
|
||||
}
|
||||
};
|
||||
|
||||
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 Some(timezone) = user.timezone() else {
|
||||
tracing::debug!(user_id = %reminder.user_id(), "user has no timezone set, skipping reminder");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let now = Utc::now().with_timezone(&timezone.resolve());
|
||||
|
||||
let weekday = now.weekday();
|
||||
let scheduled_time = match reminder.schedule().time_for(weekday) {
|
||||
Some(time) => time,
|
||||
|
||||
15
crates/application/src/restore/commands.rs
Normal file
15
crates/application/src/restore/commands.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use domain::user::UserId;
|
||||
|
||||
pub struct RestoreBackupCommand {
|
||||
pub user_id: UserId,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RestoreBackupCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RestoreBackupCommand")
|
||||
.field("user_id", &self.user_id)
|
||||
.field("bytes", &self.data.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
2
crates/application/src/restore/mod.rs
Normal file
2
crates/application/src/restore/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
1
crates/application/src/restore/use_cases/mod.rs
Normal file
1
crates/application/src/restore/use_cases/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod restore_backup;
|
||||
354
crates/application/src/restore/use_cases/restore_backup.rs
Normal file
354
crates/application/src/restore/use_cases/restore_backup.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{NaiveTime, Weekday};
|
||||
|
||||
use domain::activity::{Activity, ActivityId, ActivityName, CategoryName};
|
||||
use domain::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::entry::{Date, Mood, MoodEntry};
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, BackupReaderPort, CycleStartCommandPort,
|
||||
DailyMetricCommandPort, EntryDimensionPort, MediaStoragePort, MoodEntryCommandPort,
|
||||
ReminderCommandPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric,
|
||||
RestorableReminder, UserPreferencesCommandPort, UserPreferencesQueryPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::restore::commands::RestoreBackupCommand;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
const WEEK: [Weekday; 7] = [
|
||||
Weekday::Mon,
|
||||
Weekday::Tue,
|
||||
Weekday::Wed,
|
||||
Weekday::Thu,
|
||||
Weekday::Fri,
|
||||
Weekday::Sat,
|
||||
Weekday::Sun,
|
||||
];
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct RestoreOutcome {
|
||||
pub entries: usize,
|
||||
pub metrics: usize,
|
||||
pub cycle_starts: usize,
|
||||
pub activities: usize,
|
||||
pub reminders: usize,
|
||||
pub media: usize,
|
||||
pub unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub reader: Arc<dyn BackupReaderPort>,
|
||||
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub reminder_command: Arc<dyn ReminderCommandPort>,
|
||||
pub metrics: Arc<dyn DailyMetricCommandPort>,
|
||||
pub cycles: Arc<dyn CycleStartCommandPort>,
|
||||
pub preferences_command: Arc<dyn UserPreferencesCommandPort>,
|
||||
pub preferences_query: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
command: RestoreBackupCommand,
|
||||
deps: &Deps,
|
||||
) -> Result<RestoreOutcome, ApplicationError> {
|
||||
let contents = deps.reader.read(&command.data).await?;
|
||||
let user_id = command.user_id;
|
||||
let mut outcome = RestoreOutcome::default();
|
||||
|
||||
let activities = restore_activities(&user_id, &contents.activities, deps, &mut outcome).await?;
|
||||
let media = restore_media(&contents, deps, &mut outcome).await;
|
||||
let renamed = Renamed { activities, media };
|
||||
restore_entries(&user_id, &contents.entries, &renamed, deps, &mut outcome).await?;
|
||||
restore_metrics(&user_id, &contents.metrics, deps, &mut outcome).await?;
|
||||
restore_cycle_starts(&user_id, &contents.cycle_starts, deps, &mut outcome).await?;
|
||||
restore_reminders(&user_id, &contents.reminders, deps, &mut outcome).await?;
|
||||
restore_preferences(&user_id, contents.tracks_cycle, deps).await?;
|
||||
|
||||
tracing::info!(
|
||||
entries = outcome.entries,
|
||||
metrics = outcome.metrics,
|
||||
cycle_starts = outcome.cycle_starts,
|
||||
activities = outcome.activities,
|
||||
reminders = outcome.reminders,
|
||||
media = outcome.media,
|
||||
unreadable = outcome.unreadable.len(),
|
||||
"restored a backup"
|
||||
);
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn restore_activities(
|
||||
user_id: &UserId,
|
||||
backed_up: &[RestorableActivity],
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<HashMap<String, ActivityId>, ApplicationError> {
|
||||
let existing: HashMap<String, ActivityId> = deps
|
||||
.activity_query
|
||||
.find_by_user(user_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|activity| (activity.name().value().to_string(), activity.id().clone()))
|
||||
.collect();
|
||||
|
||||
let mut renamed = HashMap::new();
|
||||
|
||||
for held in backed_up {
|
||||
if let Some(already_here) = existing.get(&held.name) {
|
||||
renamed.insert(held.id.clone(), already_here.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(name) = ActivityName::new(&held.name) else {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("activity {}: unusable name", held.name));
|
||||
continue;
|
||||
};
|
||||
|
||||
let category = held
|
||||
.category
|
||||
.as_ref()
|
||||
.and_then(|category| CategoryName::new(category).ok());
|
||||
|
||||
let mut activity = Activity::new(user_id.clone(), name, category);
|
||||
if held.archived {
|
||||
let _ = activity.archive();
|
||||
}
|
||||
|
||||
deps.activity_command.save(&activity).await?;
|
||||
renamed.insert(held.id.clone(), activity.id().clone());
|
||||
outcome.activities += 1;
|
||||
}
|
||||
|
||||
Ok(renamed)
|
||||
}
|
||||
|
||||
async fn restore_entries(
|
||||
user_id: &UserId,
|
||||
backed_up: &[RestorableEntry],
|
||||
renamed: &Renamed,
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
for held in backed_up {
|
||||
let Ok(logged_at) = chrono::DateTime::parse_from_rfc3339(&held.logged_at) else {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("entry at {}: unreadable instant", held.logged_at));
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(mood) = Mood::try_from(held.mood) else {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("entry at {}: unreadable mood", held.logged_at));
|
||||
continue;
|
||||
};
|
||||
|
||||
let entry = MoodEntry::new(user_id.clone(), mood, logged_at);
|
||||
deps.entry_command.save(&entry).await?;
|
||||
|
||||
let dimensions = rebuilt_dimensions(&held.dimensions, renamed);
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), &dimensions).await?;
|
||||
}
|
||||
|
||||
outcome.entries += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Renamed {
|
||||
activities: HashMap<String, ActivityId>,
|
||||
media: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Renamed {
|
||||
fn activity(&self, was: &ActivityId) -> Option<ActivityId> {
|
||||
self.activities.get(&was.value().to_string()).cloned()
|
||||
}
|
||||
|
||||
fn photo(&self, was: &PhotoId) -> Option<PhotoId> {
|
||||
self.media
|
||||
.get(&was.value().to_string())
|
||||
.and_then(|now| now.parse().ok())
|
||||
.map(PhotoId::from_uuid)
|
||||
}
|
||||
|
||||
fn voice_memo(&self, was: &VoiceMemoId) -> Option<VoiceMemoId> {
|
||||
self.media
|
||||
.get(&was.value().to_string())
|
||||
.and_then(|now| now.parse().ok())
|
||||
.map(VoiceMemoId::from_uuid)
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuilt_dimensions(dimensions: &[DimensionValue], renamed: &Renamed) -> Vec<DimensionValue> {
|
||||
dimensions
|
||||
.iter()
|
||||
.map(|dimension| match dimension {
|
||||
DimensionValue::Activities(ids) => DimensionValue::activities(
|
||||
ids.iter().filter_map(|id| renamed.activity(id)).collect(),
|
||||
),
|
||||
DimensionValue::Photos(ids) => {
|
||||
DimensionValue::Photos(ids.iter().filter_map(|id| renamed.photo(id)).collect())
|
||||
}
|
||||
DimensionValue::VoiceMemos(ids) => DimensionValue::VoiceMemos(
|
||||
ids.iter().filter_map(|id| renamed.voice_memo(id)).collect(),
|
||||
),
|
||||
kept => kept.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn restore_metrics(
|
||||
user_id: &UserId,
|
||||
backed_up: &[RestorableMetric],
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut writable = Vec::new();
|
||||
|
||||
for held in backed_up {
|
||||
let readable = MetricKind::from_name(&held.kind)
|
||||
.and_then(|kind| MetricValue::of_kind(kind, held.value).ok())
|
||||
.zip(held.date.parse().ok().map(Date::from_persistence));
|
||||
|
||||
let Some((value, date)) = readable else {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("metric {} on {}", held.kind, held.date));
|
||||
continue;
|
||||
};
|
||||
|
||||
let source = match &held.provider {
|
||||
None => Source::Manual,
|
||||
Some(name) => match ProviderName::new(name) {
|
||||
Ok(provider) => Source::Provider(provider),
|
||||
Err(_) => Source::Manual,
|
||||
},
|
||||
};
|
||||
|
||||
writable.push(DailyMetric::new(user_id.clone(), date, value, source));
|
||||
}
|
||||
|
||||
outcome.metrics = deps.metrics.save(&writable).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_cycle_starts(
|
||||
user_id: &UserId,
|
||||
backed_up: &[String],
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
for held in backed_up {
|
||||
let Some(date) = held.parse().ok().map(Date::from_persistence) else {
|
||||
outcome.unreadable.push(format!("cycle start {held}"));
|
||||
continue;
|
||||
};
|
||||
|
||||
deps.cycles.record(user_id, &date).await?;
|
||||
outcome.cycle_starts += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_reminders(
|
||||
user_id: &UserId,
|
||||
backed_up: &[RestorableReminder],
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
for held in backed_up {
|
||||
let mut schedule = DaySchedule::new();
|
||||
|
||||
for (day, time) in WEEK.iter().zip(&held.times) {
|
||||
let at = time
|
||||
.as_ref()
|
||||
.and_then(|time| NaiveTime::parse_from_str(time, "%H:%M").ok());
|
||||
schedule.set_time(*day, at);
|
||||
}
|
||||
|
||||
let mut reminder = Reminder::new(user_id.clone(), schedule);
|
||||
if held.enabled {
|
||||
reminder.enable();
|
||||
} else {
|
||||
reminder.disable();
|
||||
}
|
||||
|
||||
deps.reminder_command.save(&reminder).await?;
|
||||
outcome.reminders += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_preferences(
|
||||
user_id: &UserId,
|
||||
tracks_cycle: bool,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let mut preferences = preferences_of(user_id, &deps.preferences_query).await?;
|
||||
preferences.track_cycle(tracks_cycle);
|
||||
|
||||
deps.preferences_command.save(&preferences).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_media(
|
||||
contents: &RestorableContents,
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> HashMap<String, String> {
|
||||
let mut renamed = HashMap::new();
|
||||
|
||||
for (was, bytes) in &contents.photos {
|
||||
let Ok(upload) = photo_upload(bytes) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(now) = deps.media_storage.store_photo(upload).await {
|
||||
renamed.insert(was.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (was, bytes) in &contents.voice_memos {
|
||||
let Ok(upload) = memo_upload(bytes) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(now) = deps.media_storage.store_voice_memo(upload).await {
|
||||
renamed.insert(was.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
}
|
||||
}
|
||||
|
||||
renamed
|
||||
}
|
||||
|
||||
fn photo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
|
||||
MediaUpload::new(bytes.to_vec(), ContentType::new("image/jpeg")?)
|
||||
}
|
||||
|
||||
fn memo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
|
||||
MediaUpload::new(bytes.to_vec(), ContentType::new("audio/webm")?)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod preferences;
|
||||
pub mod use_cases;
|
||||
|
||||
16
crates/application/src/user/preferences.rs
Normal file
16
crates/application/src/user/preferences.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::UserPreferencesQueryPort;
|
||||
use domain::user::{UserId, UserPreferences};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub async fn preferences_of(
|
||||
user_id: &UserId,
|
||||
query: &Arc<dyn UserPreferencesQueryPort>,
|
||||
) -> Result<UserPreferences, ApplicationError> {
|
||||
Ok(query
|
||||
.find_by_user(user_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| UserPreferences::off_by_default(user_id.clone())))
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{CascadeDeletePort, MediaStoragePort, MoodEntryQueryPort};
|
||||
use domain::ports::{CascadeDeletePort, EntryDimensionPort, MediaStoragePort, MoodEntryQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
|
||||
pub struct Deps {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
}
|
||||
@@ -15,18 +18,10 @@ pub struct 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
let composed = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await?;
|
||||
delete_media_for(&composed, &deps.media_storage).await;
|
||||
|
||||
deps.cascade.delete_all_user_data(&user_id).await?;
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@ use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, EventPublisherPort, MediaStoragePort, MoodEntryQueryPort, UserQueryPort,
|
||||
CascadeDeletePort, EntryDimensionPort, EventPublisherPort, MediaStoragePort,
|
||||
MoodEntryQueryPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
@@ -24,18 +28,10 @@ pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationErro
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
let composed = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await?;
|
||||
delete_media_for(&composed, &deps.media_storage).await;
|
||||
|
||||
deps.cascade.delete_user_account(&user_id).await?;
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ pub mod clear_data;
|
||||
pub mod delete_user;
|
||||
pub mod get_profile;
|
||||
pub mod register;
|
||||
pub mod set_preferences;
|
||||
pub mod update_profile;
|
||||
|
||||
26
crates/application/src/user/use_cases/set_preferences.rs
Normal file
26
crates/application/src/user/use_cases/set_preferences.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{UserPreferencesCommandPort, UserPreferencesQueryPort};
|
||||
use domain::user::{UserId, UserPreferences};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn UserPreferencesCommandPort>,
|
||||
pub query: Arc<dyn UserPreferencesQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
tracks_cycle: bool,
|
||||
deps: &Deps,
|
||||
) -> Result<UserPreferences, ApplicationError> {
|
||||
let mut preferences = preferences_of(&user_id, &deps.query).await?;
|
||||
preferences.track_cycle(tracks_cycle);
|
||||
|
||||
deps.command.save(&preferences).await?;
|
||||
|
||||
Ok(preferences)
|
||||
}
|
||||
Reference in New Issue
Block a user