changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
pub mod forget_cycle_start;
pub mod read_cycle;
pub mod record_cycle_start;

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
pub mod export_user_data;
pub mod write_backup;
pub mod write_extract;

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

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

View File

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

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

View File

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

View File

@@ -1 +1,2 @@
pub mod import_daily_metrics;
pub mod import_entries;

View File

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

View File

@@ -0,0 +1,3 @@
pub mod run_due_jobs;
pub mod sweep_recording_backlog;
pub mod sweep_weather_backlog;

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

View File

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

View File

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

View File

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

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

View File

@@ -1 +1,2 @@
pub mod cleanup;
pub mod use_cases;

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
pub mod list_daily_metrics;
pub mod set_daily_metrics;

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,4 @@
pub mod connect_provider;
pub mod disconnect_provider;
pub mod get_now_playing;
pub mod list_connections;

View File

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

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

View File

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

View File

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

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

View File

@@ -1,2 +1,3 @@
pub mod commands;
pub mod preferences;
pub mod use_cases;

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

View File

@@ -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?;

View File

@@ -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?;

View File

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

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