spa hardening, offline logging, rate limit fixes

server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::activity::ActivityId;
use domain::errors::DomainError;
use domain::ports::{ActivityCommandPort, ActivityQueryPort};
use domain::ports::{ActivityCommandPort, ActivityQueryPort, MoodEntryQueryPort};
use domain::user::UserId;
use crate::authorization::verify_ownership;
@@ -11,6 +11,7 @@ use crate::errors::ApplicationError;
pub struct Deps {
pub command: Arc<dyn ActivityCommandPort>,
pub query: Arc<dyn ActivityQueryPort>,
pub entries: Arc<dyn MoodEntryQueryPort>,
}
#[tracing::instrument(skip(deps))]
@@ -26,8 +27,24 @@ pub async fn execute(
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
verify_ownership(activity.user_id(), &caller_id)?;
refuse_while_entries_still_wear_it(&activity_id, deps).await?;
deps.command.delete(&activity_id).await?;
Ok(())
}
async fn refuse_while_entries_still_wear_it(
activity_id: &ActivityId,
deps: &Deps,
) -> Result<(), ApplicationError> {
let tagged = deps.entries.count_tagged_with(activity_id).await?;
if tagged > 0 {
return Err(ApplicationError::Validation(format!(
"{tagged} entries are tagged with this activity, so deleting it would rewrite them: archive it instead"
)));
}
Ok(())
}

View File

@@ -1,3 +1,4 @@
use domain::api_token::TokenScopes;
use domain::provider::ProviderName;
use domain::user::UserId;
@@ -5,4 +6,5 @@ use domain::user::UserId;
pub struct MintApiTokenCommand {
pub user_id: UserId,
pub name: ProviderName,
pub scopes: TokenScopes,
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::api_token::ApiToken;
use domain::api_token::{ApiToken, TokenScope};
use domain::errors::DomainError;
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, ApiTokenSecretPort};
@@ -13,7 +13,11 @@ pub struct Deps {
}
#[tracing::instrument(skip(secret, deps))]
pub async fn execute(secret: &str, deps: &Deps) -> Result<ApiToken, ApplicationError> {
pub async fn execute(
secret: &str,
needed: TokenScope,
deps: &Deps,
) -> Result<ApiToken, ApplicationError> {
let digest = deps.secrets.digest(secret);
let token = deps
@@ -22,6 +26,17 @@ pub async fn execute(secret: &str, deps: &Deps) -> Result<ApiToken, ApplicationE
.await?
.ok_or_else(|| DomainError::Unauthorized("this api token is not valid".into()))?;
if !token.allows(needed) {
tracing::warn!(
token_id = %token.id(),
%needed,
granted = %token.scopes(),
"an api token was presented for something it does not grant"
);
return Err(DomainError::Forbidden(format!("this token does not grant {needed}")).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");
}

View File

@@ -19,10 +19,15 @@ pub async fn execute(
let secret = deps.secrets.mint();
let digest = deps.secrets.digest(&secret);
let token = ApiToken::new(command.user_id, command.name, digest);
let token = ApiToken::new(command.user_id, command.name, digest, command.scopes);
deps.command.save(&token).await?;
tracing::info!(token_id = %token.id(), name = token.name().value(), "minted an api token");
tracing::info!(
token_id = %token.id(),
name = token.name().value(),
scopes = %token.scopes(),
"minted an api token"
);
Ok(MintedApiToken::new(token, secret))
}

View File

@@ -6,18 +6,20 @@ use domain::correlation::{
Adjustment, Agreement, Coefficient, CorrelationInput, CorrelationStrategy, Observation, PValue,
Tested,
};
use domain::dimension::ComposedEntry;
use domain::entry::{Date, DayMood};
use domain::metric::MetricKind;
use domain::moon::MoonPhase;
use domain::ports::{
ActivityQueryPort, CycleStartQueryPort, DailyMetricQueryPort, MoodEntryQueryPort,
UserPreferencesQueryPort, UserQueryPort,
ActivityQueryPort, CycleStartQueryPort, DailyMetricQueryPort, EntryDimensionPort,
MoodEntryQueryPort, UserPreferencesQueryPort, UserQueryPort,
};
use domain::user::{Timezone, UserId};
use domain::user::Timezone;
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::entry::composition::EntryComposer;
use crate::errors::ApplicationError;
use crate::user::preferences::preferences_of;
@@ -43,7 +45,8 @@ pub struct Deps {
pub metrics: Arc<dyn DailyMetricQueryPort>,
pub activities: Arc<dyn ActivityQueryPort>,
pub cycles: Arc<dyn CycleStartQueryPort>,
pub weather_store: Arc<dyn domain::ports::EntryDimensionPort>,
pub weather_store: Arc<dyn EntryDimensionPort>,
pub activity_store: Arc<dyn EntryDimensionPort>,
pub preferences: Arc<dyn UserPreferencesQueryPort>,
pub users: Arc<dyn UserQueryPort>,
}
@@ -63,6 +66,13 @@ pub async fn execute(
let mut moods = day_moods(&entries, &timezone);
moods.retain(|date, _| query.span.contains(date));
let composed = EntryComposer::new(vec![
deps.weather_store.clone(),
deps.activity_store.clone(),
])
.compose(entries)
.await?;
let mut scored = metric_rows(&query, deps, &moods).await?;
let lunar = paired(&moods, |date| Some(MoonPhase::on(date).illumination()));
@@ -77,9 +87,9 @@ pub async fn execute(
scored.push(cycle);
}
scored.push(temperature_row(&query, deps, &moods, &timezone).await?);
scored.push(temperature_row(&query, &composed, &moods, &timezone));
scored.extend(activity_rows(&query, deps, &moods, &timezone).await?);
scored.extend(activity_rows(&query, deps, &composed, &moods, &timezone).await?);
Ok(mark_what_holds_up(scored, query.false_discovery_rate))
}
@@ -144,56 +154,60 @@ async fn cycle_row(
)))
}
async fn temperature_row(
fn temperature_row(
query: &CorrelationQuery,
deps: &Deps,
composed: &[ComposedEntry],
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(),
);
}
}
) -> Pending {
let warmest = warmest_by_date(composed, timezone);
let observations = paired(moods, |date| warmest.get(date).copied());
Ok(row(
row(
CorrelationInput::Temperature,
None,
&observations,
query.minimum_sample_size,
))
)
}
fn warmest_by_date(composed: &[ComposedEntry], timezone: &Timezone) -> BTreeMap<Date, f64> {
let mut warmest: BTreeMap<Date, f64> = BTreeMap::new();
for entry in composed {
let Some(weather) = entry.weather() else {
continue;
};
let date = date_of(&entry.entry, timezone);
let temperature = weather.temperature().value();
warmest
.entry(date)
.and_modify(|highest| *highest = highest.max(temperature))
.or_insert(temperature);
}
warmest
}
async fn activity_rows(
query: &CorrelationQuery,
deps: &Deps,
composed: &[ComposedEntry],
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());
let catalogue = deps.activities.find_active_by_user(&query.user_id).await?;
let tagged = days_tagged_by_activity(composed, timezone);
let mut rows = Vec::with_capacity(catalogue.len());
for activity in catalog {
let days = days_tagged_with(&query.user_id, activity.id(), deps, timezone).await?;
for activity in catalogue {
let days = tagged.get(activity.id());
if never_logged(&days, moods) {
let Some(days) = days.filter(|days| ever_logged(days, moods)) else {
continue;
}
};
let observations = paired(moods, |date| {
Some(if days.contains(date) { PRESENT } else { ABSENT })
@@ -210,22 +224,25 @@ async fn activity_rows(
Ok(rows)
}
fn never_logged(days: &BTreeSet<Date>, moods: &BTreeMap<Date, DayMood>) -> bool {
!days.iter().any(|date| moods.contains_key(date))
fn days_tagged_by_activity(
composed: &[ComposedEntry],
timezone: &Timezone,
) -> BTreeMap<ActivityId, BTreeSet<Date>> {
let mut tagged: BTreeMap<ActivityId, BTreeSet<Date>> = BTreeMap::new();
for entry in composed {
let date = date_of(&entry.entry, timezone);
for activity_id in entry.activities() {
tagged.entry(activity_id.clone()).or_default().insert(date);
}
}
tagged
}
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 ever_logged(days: &BTreeSet<Date>, moods: &BTreeMap<Date, DayMood>) -> bool {
days.iter().any(|date| moods.contains_key(date))
}
fn paired(
@@ -249,15 +266,14 @@ fn mark_what_holds_up(pending: Vec<Pending>, false_discovery_rate: f64) -> Vec<C
let tested: Vec<Tested> = places
.iter()
.map(|(row_index, score_index)| {
.filter_map(|(row_index, score_index)| {
let entry = &pending[*row_index];
Tested {
Some(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"),
}
p_value: entry.significance[*score_index]?,
})
})
.collect();

View File

@@ -15,7 +15,7 @@ pub struct CreateEntryCommand {
#[derive(Debug)]
pub struct UpdateEntryCommand {
pub entry_id: MoodEntryId,
pub mood: Mood,
pub mood: Option<Mood>,
pub logged_at: Option<DateTime<FixedOffset>>,
pub dimensions: Vec<DimensionValue>,
pub dimensions: Option<Vec<DimensionValue>>,
}

View File

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

View File

@@ -1,25 +1,10 @@
use domain::activity::ActivityId;
use domain::entry::{DateRange, Mood};
use domain::entry::{DateRange, EntrySelection, Pagination};
use domain::user::UserId;
#[derive(Debug)]
pub struct ListEntriesQuery {
pub user_id: UserId,
pub range: Option<DateRange>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug)]
pub struct FilterByMoodQuery {
pub user_id: UserId,
pub mood: Mood,
}
#[derive(Debug)]
pub struct FilterByActivityQuery {
pub user_id: UserId,
pub activity_id: ActivityId,
pub selection: EntrySelection,
pub page: Pagination,
}
#[derive(Debug)]

View File

@@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::sync::Arc;
use domain::activity::{Activity, ActivityId};
use domain::dimension::{DimensionValue, lookup};
use domain::errors::DomainError;
use domain::ports::ActivityQueryPort;
use domain::user::UserId;
use crate::errors::ApplicationError;
pub async fn verify_activities_may_be_tagged(
owner: &UserId,
wanted: &[DimensionValue],
already_worn: &[ActivityId],
activities: &Arc<dyn ActivityQueryPort>,
) -> Result<(), ApplicationError> {
let tagged = lookup::activities_in(wanted);
if tagged.is_empty() {
return Ok(());
}
let catalogue = catalogue_of(owner, activities).await?;
for activity_id in tagged {
let activity = catalogue
.get(activity_id)
.ok_or_else(|| not_in_this_catalogue(activity_id))?;
if activity.is_archived() && !already_worn.contains(activity_id) {
return Err(retired(activity));
}
}
Ok(())
}
async fn catalogue_of(
owner: &UserId,
activities: &Arc<dyn ActivityQueryPort>,
) -> Result<HashMap<ActivityId, Activity>, ApplicationError> {
Ok(activities
.find_by_user(owner)
.await?
.into_iter()
.map(|activity| (activity.id().clone(), activity))
.collect())
}
fn not_in_this_catalogue(activity_id: &ActivityId) -> ApplicationError {
DomainError::Forbidden(format!(
"activity {activity_id} is not in this account's catalogue"
))
.into()
}
fn retired(activity: &Activity) -> ApplicationError {
ApplicationError::Validation(format!(
"{} is archived, so it cannot be tagged onto an entry it was not already on: unarchive it first",
activity.name().value()
))
}

View File

@@ -0,0 +1,65 @@
use std::sync::Arc;
use domain::entry::MoodEntry;
use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::{
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort,
};
use domain::user::UserId;
use crate::entry::tagging::verify_activities_may_be_tagged;
use crate::errors::ApplicationError;
use super::super::commands::CreateEntryCommand;
const NOTHING_YET: &[domain::activity::ActivityId] = &[];
pub struct Deps {
pub entries: Arc<dyn MoodEntryCommandPort>,
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
pub activities: Arc<dyn ActivityQueryPort>,
pub events: Arc<dyn EventPublisherPort>,
}
#[tracing::instrument(skip(deps, wanted), fields(count = wanted.len()))]
pub async fn execute(
user_id: UserId,
wanted: Vec<CreateEntryCommand>,
deps: &Deps,
) -> Result<Vec<MoodEntry>, ApplicationError> {
for one in &wanted {
verify_activities_may_be_tagged(&user_id, &one.dimensions, NOTHING_YET, &deps.activities)
.await?;
}
let written: Vec<(MoodEntry, Vec<domain::dimension::DimensionValue>)> = wanted
.into_iter()
.map(|one| {
(
MoodEntry::new(one.user_id, one.mood, one.logged_at),
one.dimensions,
)
})
.collect();
let entries: Vec<MoodEntry> = written.iter().map(|(entry, _)| entry.clone()).collect();
deps.entries.save_batch(&entries).await?;
for (entry, dimensions) in &written {
for port in &deps.dimensions {
port.save(entry.id(), dimensions).await?;
}
}
for entry in &entries {
let event = DomainEvent::EntryCreated {
entry_id: entry.id().clone(),
user_id: entry.user_id().clone(),
};
deps.events.publish(EventEnvelope::wrap(event)).await?;
}
tracing::info!(created = entries.len(), "created entries in one request");
Ok(entries)
}

View File

@@ -2,20 +2,29 @@ use std::sync::Arc;
use domain::entry::MoodEntry;
use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::{EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort};
use domain::ports::{
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort,
};
use crate::entry::tagging::verify_activities_may_be_tagged;
use crate::errors::ApplicationError;
use super::super::commands::CreateEntryCommand;
const NOTHING_YET: &[domain::activity::ActivityId] = &[];
pub struct Deps {
pub entries: Arc<dyn MoodEntryCommandPort>,
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
pub activities: Arc<dyn ActivityQueryPort>,
pub events: Arc<dyn EventPublisherPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(cmd: CreateEntryCommand, deps: &Deps) -> Result<MoodEntry, ApplicationError> {
verify_activities_may_be_tagged(&cmd.user_id, &cmd.dimensions, NOTHING_YET, &deps.activities)
.await?;
let entry = MoodEntry::new(cmd.user_id, cmd.mood, cmd.logged_at);
deps.entries.save(&entry).await?;

View File

@@ -1,24 +0,0 @@
use std::sync::Arc;
use domain::entry::MoodEntry;
use domain::ports::MoodEntryQueryPort;
use crate::errors::ApplicationError;
use super::super::queries::FilterByActivityQuery;
pub struct Deps {
pub query: Arc<dyn MoodEntryQueryPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(
query: FilterByActivityQuery,
deps: &Deps,
) -> Result<Vec<MoodEntry>, ApplicationError> {
let entries = deps
.query
.find_by_activity(&query.user_id, &query.activity_id)
.await?;
Ok(entries)
}

View File

@@ -1,21 +0,0 @@
use std::sync::Arc;
use domain::entry::MoodEntry;
use domain::ports::MoodEntryQueryPort;
use crate::errors::ApplicationError;
use super::super::queries::FilterByMoodQuery;
pub struct Deps {
pub query: Arc<dyn MoodEntryQueryPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(
query: FilterByMoodQuery,
deps: &Deps,
) -> Result<Vec<MoodEntry>, ApplicationError> {
let entries = deps.query.find_by_mood(&query.user_id, query.mood).await?;
Ok(entries)
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::entry::Mood;
use domain::entry::{Date, Mood};
use domain::ports::{MoodEntryQueryPort, UserQueryPort};
use domain::services::MoodAnalyzerService;
@@ -14,6 +14,8 @@ pub struct MoodStats {
pub frequency: Vec<(Mood, usize)>,
pub current_streak: usize,
pub total_entries: usize,
pub first_logged_on: Option<Date>,
pub last_logged_on: Option<Date>,
}
pub struct Deps {
@@ -29,7 +31,7 @@ pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, Ap
.find_by_date_range(&query.user_id, &range)
.await?
}
None => deps.query.find_by_user(&query.user_id, None, None).await?,
None => deps.query.find_all_by_user(&query.user_id).await?,
};
let timezone = timezone_for(&query.user_id, &deps.users).await?;
@@ -43,5 +45,7 @@ pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, Ap
frequency: MoodAnalyzerService::mood_frequency(&entries),
current_streak: MoodAnalyzerService::current_streak(&dates, today_in(&timezone)),
total_entries: entries.len(),
first_logged_on: dates.iter().min().copied(),
last_logged_on: dates.iter().max().copied(),
})
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::entry::MoodEntry;
use domain::entry::{MoodEntry, Page};
use domain::ports::MoodEntryQueryPort;
use crate::errors::ApplicationError;
@@ -15,19 +15,9 @@ pub struct Deps {
pub async fn execute(
query: ListEntriesQuery,
deps: &Deps,
) -> Result<Vec<MoodEntry>, ApplicationError> {
let entries = match query.range {
Some(range) => {
deps.query
.find_by_date_range(&query.user_id, &range)
.await?
}
None => {
deps.query
.find_by_user(&query.user_id, query.limit, query.offset)
.await?
}
};
) -> Result<Page<MoodEntry>, ApplicationError> {
let total = deps.query.count(&query.selection).await?;
let entries = deps.query.select(&query.selection, query.page).await?;
Ok(entries)
Ok(Page::new(entries, total, query.page))
}

View File

@@ -1,8 +1,7 @@
pub mod create_entries;
pub mod create_entry;
pub mod delete_entries_by_date_range;
pub mod delete_entry;
pub mod filter_by_activity;
pub mod filter_by_mood;
pub mod get_calendar;
pub mod get_entry;
pub mod get_mood_stats;

View File

@@ -1,18 +1,20 @@
use std::sync::Arc;
use domain::dimension::lookup;
use domain::dimension::DimensionValue;
use domain::entry::MoodEntry;
use domain::errors::DomainError;
use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::{
EntryDimensionPort, EventPublisherPort, MediaStoragePort, MoodEntryCommandPort,
MoodEntryQueryPort,
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MediaStoragePort,
MoodEntryCommandPort, MoodEntryQueryPort,
};
use domain::user::UserId;
use crate::authorization::verify_ownership;
use crate::entry::composition::EntryComposer;
use crate::entry::tagging::verify_activities_may_be_tagged;
use crate::errors::ApplicationError;
use crate::media::dropped::{DroppedMedia, delete_dropped};
use super::super::commands::UpdateEntryCommand;
@@ -20,6 +22,7 @@ pub struct Deps {
pub command: Arc<dyn MoodEntryCommandPort>,
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
pub query: Arc<dyn MoodEntryQueryPort>,
pub activities: Arc<dyn ActivityQueryPort>,
pub media_storage: Arc<dyn MediaStoragePort>,
pub events: Arc<dyn EventPublisherPort>,
}
@@ -38,38 +41,28 @@ pub async fn execute(
verify_ownership(entry.user_id(), &caller_id)?;
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();
let dropped = match &cmd.dimensions {
Some(wanted) => Some(replace_dimensions(&entry, wanted, &caller_id, deps).await?),
None => None,
};
entry.update_mood(cmd.mood);
if let Some(mood) = cmd.mood {
entry.update_mood(mood);
}
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 !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 !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");
}
}
deps.command.save(&entry).await?;
for port in &deps.dimensions {
port.save(entry.id(), &cmd.dimensions).await?;
if let Some(wanted) = &cmd.dimensions {
for port in &deps.dimensions {
port.save(entry.id(), wanted).await?;
}
}
if let Some(dropped) = dropped {
delete_dropped(&dropped, &deps.media_storage).await;
}
let event = DomainEvent::EntryUpdated {
@@ -80,3 +73,24 @@ pub async fn execute(
Ok(entry)
}
async fn replace_dimensions(
entry: &MoodEntry,
wanted: &[DimensionValue],
caller_id: &UserId,
deps: &Deps,
) -> Result<DroppedMedia, ApplicationError> {
let previous = EntryComposer::new(deps.dimensions.clone())
.compose(vec![entry.clone()])
.await?;
verify_activities_may_be_tagged(
caller_id,
wanted,
previous[0].activities(),
&deps.activities,
)
.await?;
Ok(DroppedMedia::between(&previous[0], wanted))
}

View File

@@ -29,7 +29,7 @@ pub struct Deps {
#[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?)
.compose(deps.entries.find_all_by_user(&user_id).await?)
.await?;
let media = gather_media(&entries, deps).await?;
@@ -81,26 +81,46 @@ async fn gather_media(
) -> Result<BackupMedia, ApplicationError> {
let mut photos = Vec::new();
let mut voice_memos = Vec::new();
let mut missing: Vec<String> = 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 {
match deps.media_storage.get_photo(photo_id).await {
Ok(Some(file)) => photos.push(MediaBlob {
id: photo_id.value().to_string(),
data: file.data,
});
content_type: file.content_type,
}),
Ok(None) => missing.push(format!("photo {photo_id}")),
Err(error) => {
tracing::warn!(%photo_id, %error, "a photo could not be read for the backup");
missing.push(format!("photo {photo_id}"));
}
}
}
for memo_id in entry.voice_memos() {
if let Some(file) = deps.media_storage.get_voice_memo(memo_id).await? {
voice_memos.push(MediaBlob {
match deps.media_storage.get_voice_memo(memo_id).await {
Ok(Some(file)) => voice_memos.push(MediaBlob {
id: memo_id.value().to_string(),
data: file.data,
});
content_type: file.content_type,
}),
Ok(None) => missing.push(format!("voice memo {memo_id}")),
Err(error) => {
tracing::warn!(%memo_id, %error, "a voice memo could not be read for the backup");
missing.push(format!("voice memo {memo_id}"));
}
}
}
}
if !missing.is_empty() {
tracing::warn!(
missing = missing.len(),
"some media could not be read, so the backup carries the rest"
);
}
Ok(BackupMedia {
photos,
voice_memos,

View File

@@ -18,7 +18,7 @@ pub struct Deps {
#[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?)
.compose(deps.entries.find_all_by_user(&user_id).await?)
.await?;
let extract = SharedExtract {

View File

@@ -0,0 +1,41 @@
use std::collections::HashSet;
use chrono::{DateTime, FixedOffset};
use domain::entry::{Mood, MoodEntry};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Fingerprint {
instant: i64,
mood: u8,
}
impl Fingerprint {
fn of(logged_at: &DateTime<FixedOffset>, mood: Mood) -> Self {
Self {
instant: logged_at.timestamp(),
mood: mood.value(),
}
}
}
pub struct AlreadyHere(HashSet<Fingerprint>);
impl AlreadyHere {
pub fn holding(entries: &[MoodEntry]) -> Self {
Self(
entries
.iter()
.map(|entry| Fingerprint::of(entry.logged_at(), entry.mood()))
.collect(),
)
}
pub fn holds(&self, logged_at: &DateTime<FixedOffset>, mood: Mood) -> bool {
self.0.contains(&Fingerprint::of(logged_at, mood))
}
pub fn remember(&mut self, logged_at: &DateTime<FixedOffset>, mood: Mood) {
self.0.insert(Fingerprint::of(logged_at, mood));
}
}

View File

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

View File

@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use chrono::FixedOffset;
@@ -14,6 +14,7 @@ use domain::ports::{
use domain::user::Timezone;
use crate::errors::ApplicationError;
use crate::import::already_here::AlreadyHere;
use super::super::commands::ImportCommand;
@@ -42,14 +43,8 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
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)
.await?;
let existing_keys: HashSet<(String, u8)> = existing_entries
.iter()
.map(|e| (e.logged_at().to_rfc3339(), e.mood().value()))
.collect();
let existing_entries = deps.entry_query.find_all_by_user(&cmd.user_id).await?;
let mut already_here = AlreadyHere::holding(&existing_entries);
let existing_activities = deps.activity_query.find_by_user(&cmd.user_id).await?;
let mut activity_cache: HashMap<String, ActivityId> = existing_activities
@@ -74,7 +69,7 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
&cmd.user_id,
&mut activity_cache,
&category_map,
&existing_keys,
&mut already_here,
&timezone,
deps,
)
@@ -118,17 +113,17 @@ async fn build_entry(
user_id: &domain::user::UserId,
activity_cache: &mut HashMap<String, ActivityId>,
category_map: &HashMap<String, String>,
existing_keys: &HashSet<(String, u8)>,
already_here: &mut AlreadyHere,
timezone: &Timezone,
deps: &Deps,
) -> Result<Option<ComposedEntry>, ApplicationError> {
let mood = Mood::try_from(row.mood)?;
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) {
if already_here.holds(&logged_at, mood) {
return Ok(None);
}
already_here.remember(&logged_at, mood);
let mut activity_ids = Vec::new();
for activity_name in &row.activities {

View File

@@ -0,0 +1,15 @@
use std::sync::Arc;
use domain::job::Job;
use domain::ports::JobQueueQueryPort;
use crate::errors::ApplicationError;
pub struct Deps {
pub jobs: Arc<dyn JobQueueQueryPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(most: usize, deps: &Deps) -> Result<Vec<Job>, ApplicationError> {
Ok(deps.jobs.find_exhausted(most).await?)
}

View File

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

View File

@@ -95,13 +95,13 @@ async fn observe_weather(job: &Job, deps: &Deps) -> Result<(), String> {
return Err("weather lookups are switched off".into());
};
let waiting = deps
let found = deps
.places
.find_places_without_weather(usize::MAX)
.find_place_without_weather(entry_id)
.await
.map_err(|error| error.to_string())?;
let Some(place) = waiting.iter().find(|place| &place.entry_id == entry_id) else {
let Some(place) = found else {
return Ok(());
};
@@ -116,7 +116,7 @@ async fn observe_weather(job: &Job, deps: &Deps) -> Result<(), String> {
return Ok(());
};
remember_weather(place, weather, deps).await
remember_weather(&place, weather, deps).await
}
async fn remember_weather(
@@ -133,13 +133,13 @@ async fn remember_weather(
async fn backfill_recording_identity(job: &Job, deps: &Deps) -> Result<(), String> {
let JobSubject::Entry(entry_id) = job.subject();
let waiting = deps
let found = deps
.backfill
.find_songs_without_a_recording(usize::MAX)
.find_song_without_a_recording(entry_id)
.await
.map_err(|error| error.to_string())?;
let Some(song) = waiting.iter().find(|song| &song.entry_id == entry_id) else {
let Some(song) = found else {
return Ok(());
};
@@ -155,7 +155,7 @@ async fn backfill_recording_identity(job: &Job, deps: &Deps) -> Result<(), Strin
return Ok(());
};
remember(song, &recording_id, deps).await
remember(&song, &recording_id, deps).await
}
async fn remember(

View File

@@ -1,19 +1,51 @@
use std::sync::Arc;
use domain::attachment::MediaRef;
use domain::dimension::ComposedEntry;
use domain::ports::MediaStoragePort;
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::errors::ApplicationError;
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");
if let Err(error) = storage.delete_photo(photo_id).await {
tracing::warn!(%error, "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");
if let Err(error) = storage.delete_voice_memo(memo_id).await {
tracing::warn!(%error, "failed to delete voice memo blob");
}
}
}
}
pub async fn delete_everything_owned_by(
owner: &UserId,
storage: &Arc<dyn MediaStoragePort>,
ownership: &Arc<dyn MediaOwnershipPort>,
) -> Result<usize, ApplicationError> {
let held = ownership.owned_by(owner).await?;
for media in &held {
delete_one(*media, storage).await;
}
Ok(held.len())
}
async fn delete_one(media: MediaRef, storage: &Arc<dyn MediaStoragePort>) {
if let Some(photo_id) = media.to_photo()
&& let Err(error) = storage.delete_photo(&photo_id).await
{
tracing::warn!(%error, %media, "failed to delete photo blob");
}
if let Some(memo_id) = media.to_voice_memo()
&& let Err(error) = storage.delete_voice_memo(&memo_id).await
{
tracing::warn!(%error, %media, "failed to delete voice memo blob");
}
}

View File

@@ -0,0 +1,22 @@
use std::sync::Arc;
use domain::attachment::MediaRef;
use domain::errors::DomainError;
use domain::ports::MediaOwnershipPort;
use domain::user::UserId;
use crate::authorization::verify_ownership;
use crate::errors::ApplicationError;
pub async fn verify_custody(
media: MediaRef,
caller: &UserId,
ownership: &Arc<dyn MediaOwnershipPort>,
) -> Result<(), ApplicationError> {
let owner = ownership
.owner_of(media)
.await?
.ok_or_else(|| DomainError::NotFound(format!("{media} not found")))?;
verify_ownership(&owner, caller)
}

View File

@@ -0,0 +1,58 @@
use std::sync::Arc;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::dimension::{ComposedEntry, DimensionValue, lookup};
use domain::ports::MediaStoragePort;
pub struct DroppedMedia {
photos: Vec<PhotoId>,
voice_memos: Vec<VoiceMemoId>,
}
impl DroppedMedia {
pub fn between(previous: &ComposedEntry, kept: &[DimensionValue]) -> Self {
let still_here_photos = lookup::photos_in(kept);
let still_here_memos = lookup::voice_memos_in(kept);
Self {
photos: previous
.photos()
.iter()
.filter(|photo_id| !still_here_photos.contains(photo_id))
.cloned()
.collect(),
voice_memos: previous
.voice_memos()
.iter()
.filter(|memo_id| !still_here_memos.contains(memo_id))
.cloned()
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.photos.is_empty() && self.voice_memos.is_empty()
}
pub fn photos(&self) -> &[PhotoId] {
&self.photos
}
pub fn voice_memos(&self) -> &[VoiceMemoId] {
&self.voice_memos
}
}
pub async fn delete_dropped(dropped: &DroppedMedia, storage: &Arc<dyn MediaStoragePort>) {
for photo_id in dropped.photos() {
if let Err(error) = storage.delete_photo(photo_id).await {
tracing::warn!(%error, "failed to delete a photo dropped from an edit");
}
}
for memo_id in dropped.voice_memos() {
if let Err(error) = storage.delete_voice_memo(memo_id).await {
tracing::warn!(%error, "failed to delete a voice memo dropped from an edit");
}
}
}

View File

@@ -1,2 +1,4 @@
pub mod cleanup;
pub mod custody;
pub mod dropped;
pub mod use_cases;

View File

@@ -1,16 +1,28 @@
use std::sync::Arc;
use domain::attachment::PhotoId;
use domain::ports::MediaStoragePort;
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::errors::ApplicationError;
use crate::media::custody::verify_custody;
pub struct Deps {
pub storage: Arc<dyn MediaStoragePort>,
pub ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(photo_id: PhotoId, deps: &Deps) -> Result<(), ApplicationError> {
pub async fn execute(
photo_id: PhotoId,
caller_id: UserId,
deps: &Deps,
) -> Result<(), ApplicationError> {
let media = (&photo_id).into();
verify_custody(media, &caller_id, &deps.ownership).await?;
deps.storage.delete_photo(&photo_id).await?;
deps.ownership.forget(media).await?;
Ok(())
}

View File

@@ -1,16 +1,28 @@
use std::sync::Arc;
use domain::attachment::VoiceMemoId;
use domain::ports::MediaStoragePort;
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::errors::ApplicationError;
use crate::media::custody::verify_custody;
pub struct Deps {
pub storage: Arc<dyn MediaStoragePort>,
pub ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(voice_memo_id: VoiceMemoId, deps: &Deps) -> Result<(), ApplicationError> {
pub async fn execute(
voice_memo_id: VoiceMemoId,
caller_id: UserId,
deps: &Deps,
) -> Result<(), ApplicationError> {
let media = (&voice_memo_id).into();
verify_custody(media, &caller_id, &deps.ownership).await?;
deps.storage.delete_voice_memo(&voice_memo_id).await?;
deps.ownership.forget(media).await?;
Ok(())
}

View File

@@ -0,0 +1,16 @@
use std::sync::Arc;
use domain::attachment::MediaRef;
use domain::ports::MediaOwnershipPort;
use domain::user::UserId;
use crate::errors::ApplicationError;
pub struct Deps {
pub ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(owner: UserId, deps: &Deps) -> Result<Vec<MediaRef>, ApplicationError> {
Ok(deps.ownership.owned_by(&owner).await?)
}

View File

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

View File

@@ -1,16 +1,25 @@
use std::sync::Arc;
use domain::attachment::{MediaUpload, PhotoId};
use domain::ports::MediaStoragePort;
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::errors::ApplicationError;
pub struct Deps {
pub storage: Arc<dyn MediaStoragePort>,
pub ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<PhotoId, ApplicationError> {
#[tracing::instrument(skip(deps, upload))]
pub async fn execute(
owner: UserId,
upload: MediaUpload,
deps: &Deps,
) -> Result<PhotoId, ApplicationError> {
let photo_id = deps.storage.store_photo(upload).await?;
deps.ownership.remember(&owner, (&photo_id).into()).await?;
Ok(photo_id)
}

View File

@@ -1,16 +1,27 @@
use std::sync::Arc;
use domain::attachment::{MediaUpload, VoiceMemoId};
use domain::ports::MediaStoragePort;
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::errors::ApplicationError;
pub struct Deps {
pub storage: Arc<dyn MediaStoragePort>,
pub ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<VoiceMemoId, ApplicationError> {
#[tracing::instrument(skip(deps, upload))]
pub async fn execute(
owner: UserId,
upload: MediaUpload,
deps: &Deps,
) -> Result<VoiceMemoId, ApplicationError> {
let voice_memo_id = deps.storage.store_voice_memo(upload).await?;
deps.ownership
.remember(&owner, (&voice_memo_id).into())
.await?;
Ok(voice_memo_id)
}

View File

@@ -0,0 +1,19 @@
use std::sync::Arc;
use domain::ports::RejectionQueryPort;
use domain::rejection::RejectedMetric;
use domain::user::UserId;
use crate::errors::ApplicationError;
pub struct Deps {
pub rejections: Arc<dyn RejectionQueryPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(
user_id: UserId,
deps: &Deps,
) -> Result<Vec<RejectedMetric>, ApplicationError> {
Ok(deps.rejections.find_recent_by_user(&user_id).await?)
}

View File

@@ -1,2 +1,3 @@
pub mod list_daily_metrics;
pub mod list_rejections;
pub mod set_daily_metrics;

View File

@@ -10,5 +10,6 @@ pub struct SubscribePushCommand {
#[derive(Debug)]
pub struct UnsubscribePushCommand {
pub user_id: UserId,
pub endpoint: String,
}

View File

@@ -0,0 +1,19 @@
use std::sync::Arc;
use domain::ports::PushSubscriptionQueryPort;
use domain::push::PushSubscription;
use domain::user::UserId;
use crate::errors::ApplicationError;
pub struct Deps {
pub push_query: Arc<dyn PushSubscriptionQueryPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(
user_id: UserId,
deps: &Deps,
) -> Result<Vec<PushSubscription>, ApplicationError> {
Ok(deps.push_query.find_by_user(&user_id).await?)
}

View File

@@ -1,2 +1,3 @@
pub mod list_subscriptions;
pub mod subscribe;
pub mod unsubscribe;

View File

@@ -14,16 +14,28 @@ pub struct Deps {
#[tracing::instrument(skip(deps))]
pub async fn execute(cmd: SubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
if let Some(_existing) = deps.push_query.find_by_endpoint(&cmd.endpoint).await? {
tracing::debug!(
endpoint = cmd.endpoint,
"push subscription already exists, updating"
);
}
let known = deps.push_query.find_by_endpoint(&cmd.endpoint).await?;
let subscription = match known {
Some(mut existing) => {
if existing.user_id() != &cmd.user_id {
tracing::info!(
endpoint = cmd.endpoint,
from = %existing.user_id(),
to = %cmd.user_id,
"a device already subscribed is moving to another account"
);
}
existing.renew(cmd.user_id, cmd.p256dh, cmd.auth);
existing
}
None => PushSubscription::new(cmd.user_id, cmd.endpoint, cmd.p256dh, cmd.auth),
};
let subscription = PushSubscription::new(cmd.user_id, cmd.endpoint, cmd.p256dh, cmd.auth);
deps.push_command.save(&subscription).await?;
tracing::info!(user_id = %subscription.user_id(), "push subscription saved");
Ok(())
}

View File

@@ -12,7 +12,11 @@ pub struct Deps {
#[tracing::instrument(skip(deps))]
pub async fn execute(cmd: UnsubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
deps.push_command.delete_by_endpoint(&cmd.endpoint).await?;
tracing::info!(endpoint = cmd.endpoint, "push subscription removed");
deps.push_command
.delete_by_endpoint(&cmd.user_id, &cmd.endpoint)
.await?;
tracing::info!(user_id = %cmd.user_id, endpoint = cmd.endpoint, "push subscription removed");
Ok(())
}

View File

@@ -1,16 +1,19 @@
use std::sync::Arc;
use chrono::{Datelike, NaiveTime, Utc};
use chrono::{Duration, Utc};
use domain::ports::{ReminderQueryPort, ReminderSenderPort, UserQueryPort};
use domain::reminder::Reminder;
use domain::ports::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort, UserQueryPort};
use domain::reminder::{DueOccurrence, Reminder};
use domain::user::Timezone;
use crate::errors::ApplicationError;
pub struct Deps {
pub reminder_query: Arc<dyn ReminderQueryPort>,
pub reminder_command: Arc<dyn ReminderCommandPort>,
pub user_query: Arc<dyn UserQueryPort>,
pub sender: Arc<dyn ReminderSenderPort>,
pub grace: Duration,
}
#[tracing::instrument(skip(deps))]
@@ -21,58 +24,74 @@ pub async fn execute(deps: &Deps) -> Result<u64, ApplicationError> {
"checking enabled reminders"
);
let now = Utc::now();
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");
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")
}
}
for reminder in reminders {
let Some(timezone) = timezone_of(&reminder, deps).await else {
continue;
};
let Some(occurrence) = reminder.occurrence_reached(now, &timezone, deps.grace) else {
continue;
};
if send(reminder, occurrence, deps).await {
sent_count += 1;
}
}
tracing::info!(sent_count, "reminder processing completed");
Ok(sent_count)
}
async fn should_send(
reminder: &Reminder,
user_query: &Arc<dyn UserQueryPort>,
) -> Result<bool, ApplicationError> {
let user = user_query.find_by_id(reminder.user_id()).await?;
let user = match user {
Some(u) => u,
None => {
async fn send(mut reminder: Reminder, occurrence: DueOccurrence, deps: &Deps) -> bool {
tracing::info!(user_id = %reminder.user_id(), "sending reminder");
if let Err(error) = deps.sender.send_reminder(reminder.user_id()).await {
tracing::warn!(user_id = %reminder.user_id(), %error, "could not send reminder");
return false;
}
reminder.mark_sent(occurrence);
if let Err(error) = deps.reminder_command.save(&reminder).await {
tracing::error!(
reminder_id = %reminder.id(),
%error,
"sent a reminder but could not record it, so it may be sent again"
);
}
true
}
async fn timezone_of(reminder: &Reminder, deps: &Deps) -> Option<Timezone> {
let found = deps.user_query.find_by_id(reminder.user_id()).await;
let user = match found {
Ok(Some(user)) => user,
Ok(None) => {
tracing::warn!(user_id = %reminder.user_id(), "reminder references nonexistent user");
return Ok(false);
return None;
}
Err(error) => {
tracing::warn!(
user_id = %reminder.user_id(),
%error,
"could not read the account behind a reminder, so the rest of the sweep goes on"
);
return None;
}
};
let Some(timezone) = user.timezone() else {
if user.timezone().is_none() {
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,
None => return Ok(false),
};
let current_time = now.time();
Ok(is_within_window(current_time, scheduled_time))
}
fn is_within_window(current: NaiveTime, scheduled: NaiveTime) -> bool {
let diff = current.signed_duration_since(scheduled);
let minutes = diff.num_minutes();
(0..5).contains(&minutes)
user.timezone().copied()
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{NaiveTime, Weekday};
@@ -10,8 +11,9 @@ 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,
DailyMetricCommandPort, EntryDimensionPort, MediaOwnershipPort, MediaStoragePort,
MoodEntryCommandPort, MoodEntryQueryPort, ReminderCommandPort, ReminderQueryPort,
RestorableActivity, RestorableContents, RestorableEntry, RestorableMedia, RestorableMetric,
RestorableReminder, UserPreferencesCommandPort, UserPreferencesQueryPort,
};
use domain::provider::ProviderName;
@@ -19,6 +21,7 @@ use domain::reminder::{DaySchedule, Reminder};
use domain::user::UserId;
use crate::errors::ApplicationError;
use crate::import::already_here::AlreadyHere;
use crate::restore::commands::RestoreBackupCommand;
use crate::user::preferences::preferences_of;
@@ -32,6 +35,9 @@ const WEEK: [Weekday; 7] = [
Weekday::Sun,
];
const PHOTO_FALLBACK_TYPE: &str = "image/jpeg";
const VOICE_MEMO_FALLBACK_TYPE: &str = "audio/webm";
#[derive(Debug, Default, PartialEq, Eq)]
pub struct RestoreOutcome {
pub entries: usize,
@@ -40,21 +46,25 @@ pub struct RestoreOutcome {
pub activities: usize,
pub reminders: usize,
pub media: usize,
pub skipped: usize,
pub unreadable: Vec<String>,
}
pub struct Deps {
pub reader: Arc<dyn BackupReaderPort>,
pub entry_command: Arc<dyn MoodEntryCommandPort>,
pub entry_query: Arc<dyn MoodEntryQueryPort>,
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 reminder_query: Arc<dyn ReminderQueryPort>,
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>,
pub media_ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
@@ -67,7 +77,7 @@ pub async fn execute(
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 media = restore_media(&user_id, &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?;
@@ -82,6 +92,7 @@ pub async fn execute(
activities = outcome.activities,
reminders = outcome.reminders,
media = outcome.media,
skipped = outcome.skipped,
unreadable = outcome.unreadable.len(),
"restored a backup"
);
@@ -124,8 +135,12 @@ async fn restore_activities(
.and_then(|category| CategoryName::new(category).ok());
let mut activity = Activity::new(user_id.clone(), name, category);
if held.archived {
let _ = activity.archive();
if held.archived
&& let Err(error) = activity.archive()
{
outcome
.unreadable
.push(format!("activity {}: {error}", held.name));
}
deps.activity_command.save(&activity).await?;
@@ -143,6 +158,9 @@ async fn restore_entries(
deps: &Deps,
outcome: &mut RestoreOutcome,
) -> Result<(), ApplicationError> {
let existing = deps.entry_query.find_all_by_user(user_id).await?;
let mut already_here = AlreadyHere::holding(&existing);
for held in backed_up {
let Ok(logged_at) = chrono::DateTime::parse_from_rfc3339(&held.logged_at) else {
outcome
@@ -158,6 +176,12 @@ async fn restore_entries(
continue;
};
if already_here.holds(&logged_at, mood) {
outcome.skipped += 1;
continue;
}
already_here.remember(&logged_at, mood);
let entry = MoodEntry::new(user_id.clone(), mood, logged_at);
deps.entry_command.save(&entry).await?;
@@ -239,7 +263,14 @@ async fn restore_metrics(
None => Source::Manual,
Some(name) => match ProviderName::new(name) {
Ok(provider) => Source::Provider(provider),
Err(_) => Source::Manual,
Err(_) => {
outcome.unreadable.push(format!(
"metric {} on {}: {name} is not a usable provider name, so it reads as manual",
held.kind, held.date
));
Source::Manual
}
},
};
@@ -276,14 +307,20 @@ async fn restore_reminders(
deps: &Deps,
outcome: &mut RestoreOutcome,
) -> Result<(), ApplicationError> {
for held in backed_up {
let mut schedule = DaySchedule::new();
let existing: Vec<DaySchedule> = deps
.reminder_query
.find_by_user(user_id)
.await?
.iter()
.map(|reminder| reminder.schedule().clone())
.collect();
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);
for held in backed_up {
let schedule = rebuilt_schedule(held);
if existing.contains(&schedule) {
outcome.skipped += 1;
continue;
}
let mut reminder = Reminder::new(user_id.clone(), schedule);
@@ -300,6 +337,19 @@ async fn restore_reminders(
Ok(())
}
fn rebuilt_schedule(held: &RestorableReminder) -> DaySchedule {
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);
}
schedule
}
async fn restore_preferences(
user_id: &UserId,
tracks_cycle: bool,
@@ -314,41 +364,81 @@ async fn restore_preferences(
}
async fn restore_media(
owner: &UserId,
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;
for held in &contents.photos {
let upload = match upload_of(held, PHOTO_FALLBACK_TYPE) {
Ok(upload) => upload,
Err(reason) => {
outcome
.unreadable
.push(format!("photo {}: {reason}", held.id));
continue;
}
};
if let Ok(now) = deps.media_storage.store_photo(upload).await {
renamed.insert(was.clone(), now.value().to_string());
outcome.media += 1;
match deps.media_storage.store_photo(upload).await {
Ok(now) => {
remember(owner, (&now).into(), deps, outcome).await;
renamed.insert(held.id.clone(), now.value().to_string());
outcome.media += 1;
}
Err(error) => outcome
.unreadable
.push(format!("photo {}: could not be stored: {error}", held.id)),
}
}
for (was, bytes) in &contents.voice_memos {
let Ok(upload) = memo_upload(bytes) else {
continue;
for held in &contents.voice_memos {
let upload = match upload_of(held, VOICE_MEMO_FALLBACK_TYPE) {
Ok(upload) => upload,
Err(reason) => {
outcome
.unreadable
.push(format!("voice memo {}: {reason}", held.id));
continue;
}
};
if let Ok(now) = deps.media_storage.store_voice_memo(upload).await {
renamed.insert(was.clone(), now.value().to_string());
outcome.media += 1;
match deps.media_storage.store_voice_memo(upload).await {
Ok(now) => {
remember(owner, (&now).into(), deps, outcome).await;
renamed.insert(held.id.clone(), now.value().to_string());
outcome.media += 1;
}
Err(error) => outcome.unreadable.push(format!(
"voice memo {}: could not be stored: {error}",
held.id
)),
}
}
renamed
}
fn photo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
MediaUpload::new(bytes.to_vec(), ContentType::new("image/jpeg")?)
async fn remember(
owner: &UserId,
media: domain::attachment::MediaRef,
deps: &Deps,
outcome: &mut RestoreOutcome,
) {
if let Err(error) = deps.media_ownership.remember(owner, media).await {
outcome
.unreadable
.push(format!("{media}: restored but not claimed: {error}"));
}
}
fn memo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
MediaUpload::new(bytes.to_vec(), ContentType::new("audio/webm")?)
fn upload_of(held: &RestorableMedia, fallback: &str) -> Result<MediaUpload, String> {
let content_type = match held.content_type.clone() {
Some(content_type) => content_type,
None => ContentType::new(fallback).map_err(|error| error.to_string())?,
};
MediaUpload::new(held.data.clone(), content_type).map_err(|error| error.to_string())
}

View File

@@ -1,7 +1,9 @@
use std::sync::Arc;
use domain::errors::DomainError;
use domain::ports::{PasswordHasherPort, UserCommandPort, UserQueryPort};
use domain::ports::{
PasswordHasherPort, RefreshSessionCommandPort, UserCommandPort, UserQueryPort,
};
use crate::errors::ApplicationError;
@@ -11,6 +13,7 @@ pub struct Deps {
pub user_command: Arc<dyn UserCommandPort>,
pub user_query: Arc<dyn UserQueryPort>,
pub password_hasher: Arc<dyn PasswordHasherPort>,
pub refresh_sessions: Arc<dyn RefreshSessionCommandPort>,
}
#[tracing::instrument(skip_all, fields(user_id = %cmd.user_id))]
@@ -33,6 +36,9 @@ pub async fn execute(cmd: ChangePasswordCommand, deps: &Deps) -> Result<(), Appl
user.update_password(new_hash);
deps.user_command.save(&user).await?;
deps.refresh_sessions.revoke_all_for_user(user.id()).await?;
tracing::info!(user_id = %user.id(), "password changed and every session revoked");
Ok(())
}

View File

@@ -1,30 +1,25 @@
use std::sync::Arc;
use domain::ports::{CascadeDeletePort, EntryDimensionPort, MediaStoragePort, MoodEntryQueryPort};
use domain::ports::{CascadeDeletePort, MediaOwnershipPort, MediaStoragePort};
use domain::user::UserId;
use crate::entry::composition::EntryComposer;
use crate::errors::ApplicationError;
use crate::media::cleanup::delete_media_for;
use crate::media::cleanup::delete_everything_owned_by;
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>,
pub media_ownership: Arc<dyn MediaOwnershipPort>,
}
#[tracing::instrument(skip(deps))]
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationError> {
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
let composed = EntryComposer::new(deps.dimensions.clone())
.compose(entries)
.await?;
delete_media_for(&composed, &deps.media_storage).await;
let blobs =
delete_everything_owned_by(&user_id, &deps.media_storage, &deps.media_ownership).await?;
deps.cascade.delete_all_user_data(&user_id).await?;
tracing::info!(%user_id, "all user data cleared");
tracing::info!(%user_id, blobs, "all user data cleared");
Ok(())
}

View File

@@ -2,21 +2,18 @@ use std::sync::Arc;
use domain::errors::DomainError;
use domain::ports::{
CascadeDeletePort, EntryDimensionPort, EventPublisherPort, MediaStoragePort,
MoodEntryQueryPort, UserQueryPort,
CascadeDeletePort, EventPublisherPort, MediaOwnershipPort, MediaStoragePort, UserQueryPort,
};
use domain::user::UserId;
use crate::entry::composition::EntryComposer;
use crate::errors::ApplicationError;
use crate::media::cleanup::delete_media_for;
use crate::media::cleanup::delete_everything_owned_by;
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 media_ownership: Arc<dyn MediaOwnershipPort>,
pub events: Arc<dyn EventPublisherPort>,
}
@@ -27,14 +24,12 @@ pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationErro
.await?
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
let composed = EntryComposer::new(deps.dimensions.clone())
.compose(entries)
.await?;
delete_media_for(&composed, &deps.media_storage).await;
let blobs =
delete_everything_owned_by(&user_id, &deps.media_storage, &deps.media_ownership).await?;
deps.cascade.delete_user_account(&user_id).await?;
tracing::info!(%user_id, "user account deleted");
tracing::info!(%user_id, blobs, "user account deleted");
Ok(())
}