@@ -8,6 +8,7 @@ thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
serde.workspace = true
|
||||
email_address.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
89
crates/domain/src/api_token/api_token.rs
Normal file
89
crates/domain/src/api_token/api_token.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::provider::ProviderName;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{ApiTokenId, TokenDigest, TokenScope};
|
||||
|
||||
pub struct ApiTokenData {
|
||||
pub id: ApiTokenId,
|
||||
pub user_id: UserId,
|
||||
pub name: ProviderName,
|
||||
pub digest: TokenDigest,
|
||||
pub scope: TokenScope,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiToken {
|
||||
id: ApiTokenId,
|
||||
user_id: UserId,
|
||||
name: ProviderName,
|
||||
digest: TokenDigest,
|
||||
scope: TokenScope,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ApiToken {
|
||||
pub fn new(user_id: UserId, name: ProviderName, digest: TokenDigest) -> Self {
|
||||
Self {
|
||||
id: ApiTokenId::generate(),
|
||||
user_id,
|
||||
name,
|
||||
digest,
|
||||
scope: TokenScope::WriteMetrics,
|
||||
created_at: Utc::now(),
|
||||
last_used_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: ApiTokenData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
user_id: data.user_id,
|
||||
name: data.name,
|
||||
digest: data.digest,
|
||||
scope: data.scope,
|
||||
created_at: data.created_at,
|
||||
last_used_at: data.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiToken {
|
||||
pub fn id(&self) -> &ApiTokenId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &ProviderName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn digest(&self) -> &TokenDigest {
|
||||
&self.digest
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> TokenScope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn last_used_at(&self) -> Option<&DateTime<Utc>> {
|
||||
self.last_used_at.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiToken {
|
||||
pub fn mark_used(&mut self) {
|
||||
self.last_used_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
3
crates/domain/src/api_token/api_token_id.rs
Normal file
3
crates/domain/src/api_token/api_token_id.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
use crate::macros::uuid_id;
|
||||
|
||||
uuid_id!(ApiTokenId);
|
||||
29
crates/domain/src/api_token/minted_api_token.rs
Normal file
29
crates/domain/src/api_token/minted_api_token.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use super::ApiToken;
|
||||
|
||||
pub struct MintedApiToken {
|
||||
token: ApiToken,
|
||||
secret: String,
|
||||
}
|
||||
|
||||
impl MintedApiToken {
|
||||
pub fn new(token: ApiToken, secret: String) -> Self {
|
||||
Self { token, secret }
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &ApiToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn secret(&self) -> &str {
|
||||
&self.secret
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MintedApiToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MintedApiToken")
|
||||
.field("token", &self.token)
|
||||
.field("secret", &"<shown once, never logged>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
11
crates/domain/src/api_token/mod.rs
Normal file
11
crates/domain/src/api_token/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod api_token;
|
||||
mod api_token_id;
|
||||
mod minted_api_token;
|
||||
mod token_digest;
|
||||
mod token_scope;
|
||||
|
||||
pub use api_token::{ApiToken, ApiTokenData};
|
||||
pub use api_token_id::ApiTokenId;
|
||||
pub use minted_api_token::MintedApiToken;
|
||||
pub use token_digest::TokenDigest;
|
||||
pub use token_scope::TokenScope;
|
||||
18
crates/domain/src/api_token/token_digest.rs
Normal file
18
crates/domain/src/api_token/token_digest.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct TokenDigest(String);
|
||||
|
||||
impl TokenDigest {
|
||||
pub fn from_persistence(digest: String) -> Self {
|
||||
Self(digest)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TokenDigest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("TokenDigest(<redacted>)")
|
||||
}
|
||||
}
|
||||
21
crates/domain/src/api_token/token_scope.rs
Normal file
21
crates/domain/src/api_token/token_scope.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
const WRITE_METRICS: &str = "writeMetrics";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TokenScope {
|
||||
WriteMetrics,
|
||||
}
|
||||
|
||||
impl TokenScope {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::WriteMetrics => WRITE_METRICS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
WRITE_METRICS => Some(Self::WriteMetrics),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/domain/src/correlation/adjustment.rs
Normal file
76
crates/domain/src/correlation/adjustment.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{CorrelationStrategy, Family, PValue};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tested {
|
||||
pub family: Family,
|
||||
pub strategy: CorrelationStrategy,
|
||||
pub p_value: PValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct Adjustment {
|
||||
false_discovery_rate: f64,
|
||||
}
|
||||
|
||||
impl Adjustment {
|
||||
pub fn controlling_false_discovery_at(false_discovery_rate: f64) -> Self {
|
||||
Self {
|
||||
false_discovery_rate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn holds_up_across(&self, tested: &[Tested]) -> Vec<bool> {
|
||||
let mut groups: BTreeMap<(Family, CorrelationStrategy), Vec<usize>> = BTreeMap::new();
|
||||
for (index, entry) in tested.iter().enumerate() {
|
||||
groups
|
||||
.entry((entry.family, entry.strategy))
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
|
||||
let mut held = vec![false; tested.len()];
|
||||
|
||||
for places in groups.values() {
|
||||
let p_values: Vec<PValue> = places.iter().map(|index| tested[*index].p_value).collect();
|
||||
|
||||
for (place, holds) in places.iter().zip(self.holds_up(&p_values)) {
|
||||
held[*place] = holds;
|
||||
}
|
||||
}
|
||||
|
||||
held
|
||||
}
|
||||
|
||||
pub fn holds_up(&self, p_values: &[PValue]) -> Vec<bool> {
|
||||
let tested = p_values.len();
|
||||
|
||||
let mut by_size: Vec<usize> = (0..tested).collect();
|
||||
by_size.sort_by(|left, right| p_values[*left].value().total_cmp(&p_values[*right].value()));
|
||||
|
||||
let largest_passing_rank = by_size
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(position, index)| {
|
||||
p_values[**index].value() <= self.threshold_at(position + 1, tested)
|
||||
})
|
||||
.map(|(position, _)| position + 1)
|
||||
.max();
|
||||
|
||||
let Some(rank) = largest_passing_rank else {
|
||||
return vec![false; tested];
|
||||
};
|
||||
|
||||
let mut held = vec![false; tested];
|
||||
for index in &by_size[..rank] {
|
||||
held[*index] = true;
|
||||
}
|
||||
|
||||
held
|
||||
}
|
||||
|
||||
fn threshold_at(&self, rank: usize, tested: usize) -> f64 {
|
||||
rank as f64 / tested as f64 * self.false_discovery_rate
|
||||
}
|
||||
}
|
||||
29
crates/domain/src/correlation/agreement.rs
Normal file
29
crates/domain/src/correlation/agreement.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use super::Coefficient;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Agreement {
|
||||
agreeing: usize,
|
||||
applicable: usize,
|
||||
}
|
||||
|
||||
impl Agreement {
|
||||
pub fn of(scores: &[Coefficient], applicable: usize) -> Self {
|
||||
let positive = scores.iter().filter(|score| score.value() > 0.0).count();
|
||||
let negative = scores.iter().filter(|score| score.value() < 0.0).count();
|
||||
|
||||
Self {
|
||||
agreeing: positive.max(negative),
|
||||
applicable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Agreement {
|
||||
pub fn agreeing(&self) -> usize {
|
||||
self.agreeing
|
||||
}
|
||||
|
||||
pub fn applicable(&self) -> usize {
|
||||
self.applicable
|
||||
}
|
||||
}
|
||||
29
crates/domain/src/correlation/coefficient.rs
Normal file
29
crates/domain/src/correlation/coefficient.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const STRONGEST_NEGATIVE: f64 = -1.0;
|
||||
const STRONGEST_POSITIVE: f64 = 1.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct Coefficient(f64);
|
||||
|
||||
impl Coefficient {
|
||||
pub fn new(value: f64) -> Result<Self, DomainError> {
|
||||
if !value.is_finite() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"a correlation coefficient must be a finite number".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !(STRONGEST_NEGATIVE..=STRONGEST_POSITIVE).contains(&value) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"a correlation coefficient must be between {STRONGEST_NEGATIVE} and {STRONGEST_POSITIVE}, got {value}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
48
crates/domain/src/correlation/correlation_input.rs
Normal file
48
crates/domain/src/correlation/correlation_input.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::metric::MetricKind;
|
||||
|
||||
use super::{Family, SeriesShape};
|
||||
|
||||
const MOON_PHASE: &str = "moonPhase";
|
||||
const ACTIVITY: &str = "activity";
|
||||
const CYCLE_PROGRESS: &str = "cycleProgress";
|
||||
const TEMPERATURE: &str = "temperature";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CorrelationInput {
|
||||
Metric(MetricKind),
|
||||
MoonPhase,
|
||||
CycleProgress,
|
||||
Temperature,
|
||||
Activity(ActivityId),
|
||||
}
|
||||
|
||||
impl CorrelationInput {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Metric(kind) => kind.name(),
|
||||
Self::MoonPhase => MOON_PHASE,
|
||||
Self::CycleProgress => CYCLE_PROGRESS,
|
||||
Self::Temperature => TEMPERATURE,
|
||||
Self::Activity(_) => ACTIVITY,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn family(&self) -> Family {
|
||||
match self {
|
||||
Self::Metric(_) | Self::MoonPhase | Self::CycleProgress | Self::Temperature => {
|
||||
Family::Measurements
|
||||
}
|
||||
Self::Activity(_) => Family::Activities,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn series(&self) -> SeriesShape {
|
||||
match self {
|
||||
Self::Metric(_) | Self::MoonPhase | Self::CycleProgress | Self::Temperature => {
|
||||
SeriesShape::Continuous
|
||||
}
|
||||
Self::Activity(_) => SeriesShape::Presence,
|
||||
}
|
||||
}
|
||||
}
|
||||
113
crates/domain/src/correlation/correlation_strategy.rs
Normal file
113
crates/domain/src/correlation/correlation_strategy.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use super::PValue;
|
||||
use super::kendall::kendall_tau_b;
|
||||
use super::mean_difference::mean_difference;
|
||||
use super::pearson::pearson;
|
||||
use super::ranks::average_ranks;
|
||||
use super::welch::welch_standard_score;
|
||||
use super::{Coefficient, CorrelationInput, Observation, SeriesShape};
|
||||
|
||||
const PEARSON: &str = "pearson";
|
||||
const SPEARMAN: &str = "spearman";
|
||||
const KENDALL: &str = "kendall";
|
||||
const MEAN_DIFFERENCE: &str = "meanDifference";
|
||||
const FEWEST_OBSERVATIONS: usize = 2;
|
||||
const FEWEST_FOR_A_P_VALUE: usize = 4;
|
||||
const SPEARMAN_VARIANCE_INFLATION: f64 = 1.06;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CorrelationStrategy {
|
||||
Pearson,
|
||||
Spearman,
|
||||
Kendall,
|
||||
MeanDifference,
|
||||
}
|
||||
|
||||
impl CorrelationStrategy {
|
||||
pub const ALL: [CorrelationStrategy; 4] = [
|
||||
Self::Pearson,
|
||||
Self::Spearman,
|
||||
Self::Kendall,
|
||||
Self::MeanDifference,
|
||||
];
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pearson => PEARSON,
|
||||
Self::Spearman => SPEARMAN,
|
||||
Self::Kendall => KENDALL,
|
||||
Self::MeanDifference => MEAN_DIFFERENCE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scores(&self) -> SeriesShape {
|
||||
match self {
|
||||
Self::Pearson | Self::Spearman | Self::Kendall => SeriesShape::Continuous,
|
||||
Self::MeanDifference => SeriesShape::Presence,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_score(&self, input: &CorrelationInput) -> bool {
|
||||
self.scores() == input.series()
|
||||
}
|
||||
|
||||
pub fn significance(&self, observations: &[Observation]) -> Option<PValue> {
|
||||
if observations.len() < FEWEST_FOR_A_P_VALUE {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.standard_score(observations)
|
||||
.map(PValue::from_standard_score)
|
||||
}
|
||||
|
||||
fn standard_score(&self, observations: &[Observation]) -> Option<f64> {
|
||||
let (values, moods) = series(observations);
|
||||
let count = observations.len() as f64;
|
||||
|
||||
match self {
|
||||
Self::Pearson => {
|
||||
let scored = pearson(&values, &moods)?;
|
||||
Some(fisher(scored, count, 1.0))
|
||||
}
|
||||
Self::Spearman => {
|
||||
let scored = pearson(&average_ranks(&values), &average_ranks(&moods))?;
|
||||
Some(fisher(scored, count, SPEARMAN_VARIANCE_INFLATION))
|
||||
}
|
||||
Self::Kendall => {
|
||||
let scored = kendall_tau_b(&values, &moods)?;
|
||||
Some(scored * (9.0 * count * (count - 1.0) / (2.0 * (2.0 * count + 5.0))).sqrt())
|
||||
}
|
||||
Self::MeanDifference => welch_standard_score(&values, &moods),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn score(&self, observations: &[Observation]) -> Option<Coefficient> {
|
||||
if observations.len() < FEWEST_OBSERVATIONS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (values, moods) = series(observations);
|
||||
|
||||
let scored = match self {
|
||||
Self::Pearson => pearson(&values, &moods),
|
||||
Self::Spearman => pearson(&average_ranks(&values), &average_ranks(&moods)),
|
||||
Self::Kendall => kendall_tau_b(&values, &moods),
|
||||
Self::MeanDifference => mean_difference(&values, &moods),
|
||||
}?;
|
||||
|
||||
Coefficient::new(scored.clamp(-1.0, 1.0)).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn series(observations: &[Observation]) -> (Vec<f64>, Vec<f64>) {
|
||||
let values = observations.iter().map(Observation::value).collect();
|
||||
let moods = observations
|
||||
.iter()
|
||||
.map(|observation| observation.day_mood().value())
|
||||
.collect();
|
||||
|
||||
(values, moods)
|
||||
}
|
||||
|
||||
fn fisher(scored: f64, count: f64, variance_inflation: f64) -> f64 {
|
||||
scored.atanh() * ((count - 3.0) / variance_inflation).sqrt()
|
||||
}
|
||||
5
crates/domain/src/correlation/family.rs
Normal file
5
crates/domain/src/correlation/family.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Family {
|
||||
Measurements,
|
||||
Activities,
|
||||
}
|
||||
63
crates/domain/src/correlation/kendall.rs
Normal file
63
crates/domain/src/correlation/kendall.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
pub fn kendall_tau_b(left: &[f64], right: &[f64]) -> Option<f64> {
|
||||
let mut concordant = 0i64;
|
||||
let mut discordant = 0i64;
|
||||
|
||||
for first in 0..left.len() {
|
||||
for second in (first + 1)..left.len() {
|
||||
let agreement =
|
||||
direction(left[first] - left[second]) * direction(right[first] - right[second]);
|
||||
|
||||
match agreement {
|
||||
1 => concordant += 1,
|
||||
-1 => discordant += 1,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pairs = pair_count(left.len());
|
||||
let denominator = ((pairs - tied_pairs(left)) * (pairs - tied_pairs(right))).sqrt();
|
||||
|
||||
if denominator == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((concordant - discordant) as f64 / denominator)
|
||||
}
|
||||
|
||||
fn direction(difference: f64) -> i64 {
|
||||
if difference > 0.0 {
|
||||
return 1;
|
||||
}
|
||||
if difference < 0.0 {
|
||||
return -1;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fn pair_count(len: usize) -> f64 {
|
||||
let len = len as f64;
|
||||
|
||||
len * (len - 1.0) / 2.0
|
||||
}
|
||||
|
||||
fn tied_pairs(values: &[f64]) -> f64 {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|left, right| left.total_cmp(right));
|
||||
|
||||
let mut tied = 0.0;
|
||||
let mut start = 0;
|
||||
|
||||
while start < sorted.len() {
|
||||
let mut end = start;
|
||||
while end + 1 < sorted.len() && sorted[end + 1] == sorted[start] {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
tied += pair_count(end - start + 1);
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
tied
|
||||
}
|
||||
33
crates/domain/src/correlation/mean_difference.rs
Normal file
33
crates/domain/src/correlation/mean_difference.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::entry::Mood;
|
||||
|
||||
const PRESENT: f64 = 1.0;
|
||||
|
||||
pub fn mean_difference(values: &[f64], moods: &[f64]) -> Option<f64> {
|
||||
let present: Vec<f64> = pick(values, moods, |value| value == PRESENT);
|
||||
let absent: Vec<f64> = pick(values, moods, |value| value != PRESENT);
|
||||
|
||||
let difference = mean(&present)? - mean(&absent)?;
|
||||
|
||||
Some(difference / mood_span())
|
||||
}
|
||||
|
||||
fn pick(values: &[f64], moods: &[f64], wanted: impl Fn(f64) -> bool) -> Vec<f64> {
|
||||
values
|
||||
.iter()
|
||||
.zip(moods)
|
||||
.filter(|(value, _)| wanted(**value))
|
||||
.map(|(_, mood)| *mood)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean(moods: &[f64]) -> Option<f64> {
|
||||
if moods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(moods.iter().sum::<f64>() / moods.len() as f64)
|
||||
}
|
||||
|
||||
fn mood_span() -> f64 {
|
||||
f64::from(Mood::Rad.value() - Mood::Awful.value())
|
||||
}
|
||||
25
crates/domain/src/correlation/mod.rs
Normal file
25
crates/domain/src/correlation/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
mod adjustment;
|
||||
mod agreement;
|
||||
mod coefficient;
|
||||
mod correlation_input;
|
||||
mod correlation_strategy;
|
||||
mod family;
|
||||
mod kendall;
|
||||
mod mean_difference;
|
||||
mod normal;
|
||||
mod observation;
|
||||
mod p_value;
|
||||
mod pearson;
|
||||
mod ranks;
|
||||
mod series_shape;
|
||||
mod welch;
|
||||
|
||||
pub use adjustment::{Adjustment, Tested};
|
||||
pub use agreement::Agreement;
|
||||
pub use coefficient::Coefficient;
|
||||
pub use correlation_input::CorrelationInput;
|
||||
pub use correlation_strategy::CorrelationStrategy;
|
||||
pub use family::Family;
|
||||
pub use observation::Observation;
|
||||
pub use p_value::PValue;
|
||||
pub use series_shape::SeriesShape;
|
||||
23
crates/domain/src/correlation/normal.rs
Normal file
23
crates/domain/src/correlation/normal.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
const ERF_A1: f64 = 0.254_829_592;
|
||||
const ERF_A2: f64 = -0.284_496_736;
|
||||
const ERF_A3: f64 = 1.421_413_741;
|
||||
const ERF_A4: f64 = -1.453_152_027;
|
||||
const ERF_A5: f64 = 1.061_405_429;
|
||||
const ERF_P: f64 = 0.327_591_1;
|
||||
|
||||
pub fn two_sided_tail(standard_score: f64) -> f64 {
|
||||
let beyond = 1.0 - error_function(standard_score.abs() / std::f64::consts::SQRT_2);
|
||||
|
||||
beyond.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn error_function(x: f64) -> f64 {
|
||||
if x < 0.0 {
|
||||
return -error_function(-x);
|
||||
}
|
||||
|
||||
let t = 1.0 / (1.0 + ERF_P * x);
|
||||
let series = t * (ERF_A1 + t * (ERF_A2 + t * (ERF_A3 + t * (ERF_A4 + t * ERF_A5))));
|
||||
|
||||
1.0 - series * (-x * x).exp()
|
||||
}
|
||||
21
crates/domain/src/correlation/observation.rs
Normal file
21
crates/domain/src/correlation/observation.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::entry::DayMood;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Observation {
|
||||
value: f64,
|
||||
day_mood: DayMood,
|
||||
}
|
||||
|
||||
impl Observation {
|
||||
pub fn new(value: f64, day_mood: DayMood) -> Self {
|
||||
Self { value, day_mood }
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.value
|
||||
}
|
||||
|
||||
pub fn day_mood(&self) -> DayMood {
|
||||
self.day_mood
|
||||
}
|
||||
}
|
||||
29
crates/domain/src/correlation/p_value.rs
Normal file
29
crates/domain/src/correlation/p_value.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::normal::two_sided_tail;
|
||||
|
||||
const CERTAIN: f64 = 1.0;
|
||||
const IMPOSSIBLE: f64 = 0.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct PValue(f64);
|
||||
|
||||
impl PValue {
|
||||
pub fn new(value: f64) -> Result<Self, DomainError> {
|
||||
if !value.is_finite() || !(IMPOSSIBLE..=CERTAIN).contains(&value) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"a p-value must be between {IMPOSSIBLE} and {CERTAIN}, got {value}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn from_standard_score(standard_score: f64) -> Self {
|
||||
Self(two_sided_tail(standard_score))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
20
crates/domain/src/correlation/pearson.rs
Normal file
20
crates/domain/src/correlation/pearson.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
pub fn pearson(left: &[f64], right: &[f64]) -> Option<f64> {
|
||||
let count = left.len() as f64;
|
||||
let left_mean = left.iter().sum::<f64>() / count;
|
||||
let right_mean = right.iter().sum::<f64>() / count;
|
||||
|
||||
let covariance: f64 = left
|
||||
.iter()
|
||||
.zip(right)
|
||||
.map(|(l, r)| (l - left_mean) * (r - right_mean))
|
||||
.sum();
|
||||
|
||||
let left_variance: f64 = left.iter().map(|l| (l - left_mean).powi(2)).sum();
|
||||
let right_variance: f64 = right.iter().map(|r| (r - right_mean).powi(2)).sum();
|
||||
|
||||
if left_variance == 0.0 || right_variance == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(covariance / (left_variance * right_variance).sqrt())
|
||||
}
|
||||
30
crates/domain/src/correlation/ranks.rs
Normal file
30
crates/domain/src/correlation/ranks.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
pub fn average_ranks(values: &[f64]) -> Vec<f64> {
|
||||
let mut order: Vec<usize> = (0..values.len()).collect();
|
||||
order.sort_by(|left, right| values[*left].total_cmp(&values[*right]));
|
||||
|
||||
let mut ranks = vec![0.0; values.len()];
|
||||
let mut start = 0;
|
||||
|
||||
while start < order.len() {
|
||||
let mut end = start;
|
||||
while end + 1 < order.len() && values[order[end + 1]] == values[order[start]] {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let shared = shared_rank(start, end);
|
||||
for position in &order[start..=end] {
|
||||
ranks[*position] = shared;
|
||||
}
|
||||
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
ranks
|
||||
}
|
||||
|
||||
fn shared_rank(start: usize, end: usize) -> f64 {
|
||||
let first = start as f64 + 1.0;
|
||||
let last = end as f64 + 1.0;
|
||||
|
||||
(first + last) / 2.0
|
||||
}
|
||||
5
crates/domain/src/correlation/series_shape.rs
Normal file
5
crates/domain/src/correlation/series_shape.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SeriesShape {
|
||||
Continuous,
|
||||
Presence,
|
||||
}
|
||||
51
crates/domain/src/correlation/welch.rs
Normal file
51
crates/domain/src/correlation/welch.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
const PRESENT: f64 = 1.0;
|
||||
const FEWEST_FOR_A_SPREAD: usize = 2;
|
||||
|
||||
pub fn welch_standard_score(values: &[f64], moods: &[f64]) -> Option<f64> {
|
||||
let present = group(values, moods, |value| value == PRESENT);
|
||||
let absent = group(values, moods, |value| value != PRESENT);
|
||||
|
||||
if present.len() < FEWEST_FOR_A_SPREAD || absent.len() < FEWEST_FOR_A_SPREAD {
|
||||
return None;
|
||||
}
|
||||
|
||||
let spread = (variance(&present)? / present.len() as f64
|
||||
+ variance(&absent)? / absent.len() as f64)
|
||||
.sqrt();
|
||||
|
||||
if spread == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((mean(&present)? - mean(&absent)?) / spread)
|
||||
}
|
||||
|
||||
fn group(values: &[f64], moods: &[f64], wanted: impl Fn(f64) -> bool) -> Vec<f64> {
|
||||
values
|
||||
.iter()
|
||||
.zip(moods)
|
||||
.filter(|(value, _)| wanted(**value))
|
||||
.map(|(_, mood)| *mood)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean(moods: &[f64]) -> Option<f64> {
|
||||
if moods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(moods.iter().sum::<f64>() / moods.len() as f64)
|
||||
}
|
||||
|
||||
fn variance(moods: &[f64]) -> Option<f64> {
|
||||
let average = mean(moods)?;
|
||||
let degrees_of_freedom = moods.len() as f64 - 1.0;
|
||||
|
||||
Some(
|
||||
moods
|
||||
.iter()
|
||||
.map(|mood| (mood - average).powi(2))
|
||||
.sum::<f64>()
|
||||
/ degrees_of_freedom,
|
||||
)
|
||||
}
|
||||
63
crates/domain/src/cycle/cycle_calendar.rs
Normal file
63
crates/domain/src/cycle/cycle_calendar.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use crate::entry::Date;
|
||||
|
||||
use super::{CycleDay, CyclePosition};
|
||||
|
||||
const ASSUMED_CYCLE_LENGTH: i64 = 28;
|
||||
|
||||
pub struct CycleCalendar {
|
||||
starts: Vec<Date>,
|
||||
}
|
||||
|
||||
impl CycleCalendar {
|
||||
pub fn new(starts: Vec<Date>) -> Self {
|
||||
let mut starts = starts;
|
||||
starts.sort();
|
||||
starts.dedup();
|
||||
|
||||
Self { starts }
|
||||
}
|
||||
}
|
||||
|
||||
impl CycleCalendar {
|
||||
pub fn starts(&self) -> &[Date] {
|
||||
&self.starts
|
||||
}
|
||||
|
||||
pub fn position_on(&self, date: &Date) -> Option<CyclePosition> {
|
||||
let start = self.start_of_the_cycle_containing(date)?;
|
||||
let elapsed = date.days_since(start);
|
||||
|
||||
let day = CycleDay::new(u16::try_from(elapsed + 1).ok()?).ok()?;
|
||||
let length = self.length_of_the_cycle_from(start) as f64;
|
||||
|
||||
Some(CyclePosition::new(day, elapsed as f64 / length))
|
||||
}
|
||||
|
||||
pub fn usual_length(&self) -> i64 {
|
||||
let mut observed: Vec<i64> = self
|
||||
.starts
|
||||
.windows(2)
|
||||
.map(|pair| pair[1].days_since(&pair[0]))
|
||||
.collect();
|
||||
|
||||
if observed.is_empty() {
|
||||
return ASSUMED_CYCLE_LENGTH;
|
||||
}
|
||||
|
||||
observed.sort_unstable();
|
||||
|
||||
observed[observed.len() / 2]
|
||||
}
|
||||
|
||||
fn start_of_the_cycle_containing(&self, date: &Date) -> Option<&Date> {
|
||||
self.starts.iter().rev().find(|start| *start <= date)
|
||||
}
|
||||
|
||||
fn length_of_the_cycle_from(&self, start: &Date) -> i64 {
|
||||
self.starts
|
||||
.iter()
|
||||
.find(|next| *next > start)
|
||||
.map(|next| next.days_since(start))
|
||||
.unwrap_or_else(|| self.usual_length())
|
||||
}
|
||||
}
|
||||
23
crates/domain/src/cycle/cycle_day.rs
Normal file
23
crates/domain/src/cycle/cycle_day.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const FIRST_DAY: u16 = 1;
|
||||
const LONGEST_PLAUSIBLE_CYCLE: u16 = 90;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CycleDay(u16);
|
||||
|
||||
impl CycleDay {
|
||||
pub fn new(day: u16) -> Result<Self, DomainError> {
|
||||
if !(FIRST_DAY..=LONGEST_PLAUSIBLE_CYCLE).contains(&day) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"a cycle day must be between {FIRST_DAY} and {LONGEST_PLAUSIBLE_CYCLE}, got {day}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self(day))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
24
crates/domain/src/cycle/cycle_position.rs
Normal file
24
crates/domain/src/cycle/cycle_position.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use super::CycleDay;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CyclePosition {
|
||||
day: CycleDay,
|
||||
progress: f64,
|
||||
}
|
||||
|
||||
impl CyclePosition {
|
||||
pub fn new(day: CycleDay, progress: f64) -> Self {
|
||||
Self {
|
||||
day,
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn day(&self) -> CycleDay {
|
||||
self.day
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> f64 {
|
||||
self.progress
|
||||
}
|
||||
}
|
||||
14
crates/domain/src/cycle/cycle_start_restore.rs
Normal file
14
crates/domain/src/cycle/cycle_start_restore.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::entry::Date;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CycleStartRestore(Date);
|
||||
|
||||
impl CycleStartRestore {
|
||||
pub fn on(date: Date) -> Self {
|
||||
Self(date)
|
||||
}
|
||||
|
||||
pub fn date(&self) -> Date {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/cycle/mod.rs
Normal file
9
crates/domain/src/cycle/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod cycle_calendar;
|
||||
mod cycle_day;
|
||||
mod cycle_position;
|
||||
mod cycle_start_restore;
|
||||
|
||||
pub use cycle_calendar::CycleCalendar;
|
||||
pub use cycle_day::CycleDay;
|
||||
pub use cycle_position::CyclePosition;
|
||||
pub use cycle_start_restore::CycleStartRestore;
|
||||
44
crates/domain/src/dimension/composed_entry.rs
Normal file
44
crates/domain/src/dimension/composed_entry.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::attachment::{PhotoId, VoiceMemoId};
|
||||
use crate::entry::{Content, MoodEntry};
|
||||
use crate::location::Coordinates;
|
||||
use crate::song::Song;
|
||||
use crate::weather::Weather;
|
||||
|
||||
use super::DimensionValue;
|
||||
use super::lookup;
|
||||
|
||||
pub struct ComposedEntry {
|
||||
pub entry: MoodEntry,
|
||||
pub dimensions: Vec<DimensionValue>,
|
||||
}
|
||||
|
||||
impl ComposedEntry {
|
||||
pub fn content(&self) -> Option<&Content> {
|
||||
lookup::content_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn activities(&self) -> &[ActivityId] {
|
||||
lookup::activities_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn photos(&self) -> &[PhotoId] {
|
||||
lookup::photos_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn voice_memos(&self) -> &[VoiceMemoId] {
|
||||
lookup::voice_memos_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Option<&Coordinates> {
|
||||
lookup::location_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn song(&self) -> Option<&Song> {
|
||||
lookup::song_in(&self.dimensions)
|
||||
}
|
||||
|
||||
pub fn weather(&self) -> Option<&Weather> {
|
||||
lookup::weather_in(&self.dimensions)
|
||||
}
|
||||
}
|
||||
10
crates/domain/src/dimension/dimension_kind.rs
Normal file
10
crates/domain/src/dimension/dimension_kind.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DimensionKind {
|
||||
Content,
|
||||
Activities,
|
||||
Photos,
|
||||
VoiceMemos,
|
||||
Location,
|
||||
Song,
|
||||
Weather,
|
||||
}
|
||||
39
crates/domain/src/dimension/dimension_value.rs
Normal file
39
crates/domain/src/dimension/dimension_value.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::attachment::{PhotoId, VoiceMemoId};
|
||||
use crate::entry::Content;
|
||||
use crate::location::Coordinates;
|
||||
use crate::song::Song;
|
||||
use crate::weather::Weather;
|
||||
|
||||
use super::DimensionKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DimensionValue {
|
||||
Content(Content),
|
||||
Activities(Vec<ActivityId>),
|
||||
Photos(Vec<PhotoId>),
|
||||
VoiceMemos(Vec<VoiceMemoId>),
|
||||
Location(Coordinates),
|
||||
Song(Song),
|
||||
Weather(Weather),
|
||||
}
|
||||
|
||||
impl DimensionValue {
|
||||
pub fn activities(mut ids: Vec<ActivityId>) -> Self {
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Self::Activities(ids)
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> DimensionKind {
|
||||
match self {
|
||||
Self::Content(_) => DimensionKind::Content,
|
||||
Self::Activities(_) => DimensionKind::Activities,
|
||||
Self::Photos(_) => DimensionKind::Photos,
|
||||
Self::VoiceMemos(_) => DimensionKind::VoiceMemos,
|
||||
Self::Location(_) => DimensionKind::Location,
|
||||
Self::Song(_) => DimensionKind::Song,
|
||||
Self::Weather(_) => DimensionKind::Weather,
|
||||
}
|
||||
}
|
||||
}
|
||||
61
crates/domain/src/dimension/lookup.rs
Normal file
61
crates/domain/src/dimension/lookup.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::attachment::{PhotoId, VoiceMemoId};
|
||||
use crate::entry::Content;
|
||||
use crate::location::Coordinates;
|
||||
use crate::song::Song;
|
||||
use crate::weather::Weather;
|
||||
|
||||
use super::{DimensionKind, DimensionValue};
|
||||
|
||||
pub fn content_in(values: &[DimensionValue]) -> Option<&Content> {
|
||||
match find(values, DimensionKind::Content) {
|
||||
Some(DimensionValue::Content(content)) => Some(content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activities_in(values: &[DimensionValue]) -> &[ActivityId] {
|
||||
match find(values, DimensionKind::Activities) {
|
||||
Some(DimensionValue::Activities(ids)) => ids,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn photos_in(values: &[DimensionValue]) -> &[PhotoId] {
|
||||
match find(values, DimensionKind::Photos) {
|
||||
Some(DimensionValue::Photos(ids)) => ids,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice_memos_in(values: &[DimensionValue]) -> &[VoiceMemoId] {
|
||||
match find(values, DimensionKind::VoiceMemos) {
|
||||
Some(DimensionValue::VoiceMemos(ids)) => ids,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location_in(values: &[DimensionValue]) -> Option<&Coordinates> {
|
||||
match find(values, DimensionKind::Location) {
|
||||
Some(DimensionValue::Location(coordinates)) => Some(coordinates),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn song_in(values: &[DimensionValue]) -> Option<&Song> {
|
||||
match find(values, DimensionKind::Song) {
|
||||
Some(DimensionValue::Song(song)) => Some(song),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn weather_in(values: &[DimensionValue]) -> Option<&Weather> {
|
||||
match find(values, DimensionKind::Weather) {
|
||||
Some(DimensionValue::Weather(weather)) => Some(weather),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find(values: &[DimensionValue], kind: DimensionKind) -> Option<&DimensionValue> {
|
||||
values.iter().find(|value| value.kind() == kind)
|
||||
}
|
||||
8
crates/domain/src/dimension/mod.rs
Normal file
8
crates/domain/src/dimension/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod composed_entry;
|
||||
mod dimension_kind;
|
||||
mod dimension_value;
|
||||
pub mod lookup;
|
||||
|
||||
pub use composed_entry::ComposedEntry;
|
||||
pub use dimension_kind::DimensionKind;
|
||||
pub use dimension_value::DimensionValue;
|
||||
40
crates/domain/src/entry/date.rs
Normal file
40
crates/domain/src/entry/date.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||
|
||||
use crate::user::Timezone;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Date(NaiveDate);
|
||||
|
||||
impl Date {
|
||||
pub fn from_instant(instant: &DateTime<FixedOffset>, timezone: &Timezone) -> Self {
|
||||
Self(instant.with_timezone(&timezone.resolve()).date_naive())
|
||||
}
|
||||
|
||||
pub fn from_persistence(date: NaiveDate) -> Self {
|
||||
Self(date)
|
||||
}
|
||||
}
|
||||
|
||||
impl Date {
|
||||
pub fn value(&self) -> NaiveDate {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn previous(&self) -> Self {
|
||||
Self(self.0.pred_opt().unwrap_or(self.0))
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Self {
|
||||
Self(self.0.succ_opt().unwrap_or(self.0))
|
||||
}
|
||||
|
||||
pub fn days_since(&self, other: &Date) -> i64 {
|
||||
(self.0 - other.0).num_days()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Date {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
35
crates/domain/src/entry/date_span.rs
Normal file
35
crates/domain/src/entry/date_span.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::Date;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DateSpan {
|
||||
start: Date,
|
||||
end: Date,
|
||||
}
|
||||
|
||||
impl DateSpan {
|
||||
pub fn new(start: Date, end: Date) -> Result<Self, DomainError> {
|
||||
if start > end {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"date span start must be on or before its end".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self { start, end })
|
||||
}
|
||||
}
|
||||
|
||||
impl DateSpan {
|
||||
pub fn start(&self) -> &Date {
|
||||
&self.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> &Date {
|
||||
&self.end
|
||||
}
|
||||
|
||||
pub fn contains(&self, date: &Date) -> bool {
|
||||
self.start <= *date && *date <= self.end
|
||||
}
|
||||
}
|
||||
26
crates/domain/src/entry/day_mood.rs
Normal file
26
crates/domain/src/entry/day_mood.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use super::Mood;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct DayMood(f64);
|
||||
|
||||
impl DayMood {
|
||||
pub fn of(moods: &[Mood]) -> Option<Self> {
|
||||
if moods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sum: u32 = moods.iter().map(|mood| u32::from(mood.value())).sum();
|
||||
|
||||
Some(Self(f64::from(sum) / moods.len() as f64))
|
||||
}
|
||||
}
|
||||
|
||||
impl DayMood {
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn rounded(&self) -> Mood {
|
||||
Mood::try_from(self.0.round() as u8).unwrap_or(Mood::Meh)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
mod content;
|
||||
mod date;
|
||||
mod date_range;
|
||||
mod date_span;
|
||||
mod day_mood;
|
||||
mod mood;
|
||||
mod mood_entry;
|
||||
mod mood_entry_id;
|
||||
|
||||
pub use content::Content;
|
||||
pub use date::Date;
|
||||
pub use date_range::DateRange;
|
||||
pub use date_span::DateSpan;
|
||||
pub use day_mood::DayMood;
|
||||
pub use mood::Mood;
|
||||
pub use mood_entry::{MoodEntry, MoodEntryData};
|
||||
pub use mood_entry_id::MoodEntryId;
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
|
||||
use crate::activity::ActivityId;
|
||||
use crate::attachment::{PhotoId, VoiceMemoId};
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{Content, Mood, MoodEntryId};
|
||||
use super::{Mood, MoodEntryId};
|
||||
|
||||
pub struct MoodEntryData {
|
||||
pub id: MoodEntryId,
|
||||
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 created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -25,10 +19,6 @@ pub struct MoodEntry {
|
||||
user_id: UserId,
|
||||
mood: Mood,
|
||||
logged_at: DateTime<FixedOffset>,
|
||||
activities: Vec<ActivityId>,
|
||||
content: Option<Content>,
|
||||
photos: Vec<PhotoId>,
|
||||
voice_memos: Vec<VoiceMemoId>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -41,10 +31,6 @@ impl MoodEntry {
|
||||
user_id,
|
||||
mood,
|
||||
logged_at,
|
||||
activities: Vec::new(),
|
||||
content: None,
|
||||
photos: Vec::new(),
|
||||
voice_memos: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -56,10 +42,6 @@ impl MoodEntry {
|
||||
user_id: data.user_id,
|
||||
mood: data.mood,
|
||||
logged_at: data.logged_at,
|
||||
activities: data.activities,
|
||||
content: data.content,
|
||||
photos: data.photos,
|
||||
voice_memos: data.voice_memos,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
@@ -83,22 +65,6 @@ impl MoodEntry {
|
||||
&self.logged_at
|
||||
}
|
||||
|
||||
pub fn activities(&self) -> &[ActivityId] {
|
||||
&self.activities
|
||||
}
|
||||
|
||||
pub fn content(&self) -> Option<&Content> {
|
||||
self.content.as_ref()
|
||||
}
|
||||
|
||||
pub fn photos(&self) -> &[PhotoId] {
|
||||
&self.photos
|
||||
}
|
||||
|
||||
pub fn voice_memos(&self) -> &[VoiceMemoId] {
|
||||
&self.voice_memos
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
@@ -119,28 +85,6 @@ impl MoodEntry {
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, content: Option<Content>) {
|
||||
self.content = content;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_activities(&mut self, mut activities: Vec<ActivityId>) {
|
||||
activities.sort();
|
||||
activities.dedup();
|
||||
self.activities = activities;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_photos(&mut self, photos: Vec<PhotoId>) {
|
||||
self.photos = photos;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_voice_memos(&mut self, voice_memos: Vec<VoiceMemoId>) {
|
||||
self.voice_memos = voice_memos;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
101
crates/domain/src/job/job.rs
Normal file
101
crates/domain/src/job/job.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{JobId, JobKind, JobStatus, JobSubject};
|
||||
|
||||
pub struct JobData {
|
||||
pub id: JobId,
|
||||
pub kind: JobKind,
|
||||
pub subject: JobSubject,
|
||||
pub status: JobStatus,
|
||||
pub attempts: u32,
|
||||
pub last_error: Option<String>,
|
||||
pub enqueued_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Job {
|
||||
id: JobId,
|
||||
kind: JobKind,
|
||||
subject: JobSubject,
|
||||
status: JobStatus,
|
||||
attempts: u32,
|
||||
last_error: Option<String>,
|
||||
enqueued_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn pending(kind: JobKind, subject: JobSubject) -> Self {
|
||||
let now = Utc::now();
|
||||
|
||||
Self {
|
||||
id: JobId::generate(),
|
||||
kind,
|
||||
subject,
|
||||
status: JobStatus::Pending,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
enqueued_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: JobData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
kind: data.kind,
|
||||
subject: data.subject,
|
||||
status: data.status,
|
||||
attempts: data.attempts,
|
||||
last_error: data.last_error,
|
||||
enqueued_at: data.enqueued_at,
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn id(&self) -> &JobId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> JobKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> &JobSubject {
|
||||
&self.subject
|
||||
}
|
||||
|
||||
pub fn status(&self) -> JobStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn attempts(&self) -> u32 {
|
||||
self.attempts
|
||||
}
|
||||
|
||||
pub fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
|
||||
pub fn enqueued_at(&self) -> &DateTime<Utc> {
|
||||
&self.enqueued_at
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> {
|
||||
&self.updated_at
|
||||
}
|
||||
|
||||
pub fn is_worth_another_attempt(&self, most_attempts: u32) -> bool {
|
||||
self.attempts < most_attempts
|
||||
}
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn attempted(&mut self) {
|
||||
self.attempts += 1;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
3
crates/domain/src/job/job_id.rs
Normal file
3
crates/domain/src/job/job_id.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
use crate::macros::uuid_id;
|
||||
|
||||
uuid_id!(JobId);
|
||||
27
crates/domain/src/job/job_kind.rs
Normal file
27
crates/domain/src/job/job_kind.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
const BACKFILL_RECORDING_IDENTITY: &str = "backfillRecordingIdentity";
|
||||
const OBSERVE_WEATHER: &str = "observeWeather";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JobKind {
|
||||
BackfillRecordingIdentity,
|
||||
ObserveWeather,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
pub const ALL: [JobKind; 2] = [Self::BackfillRecordingIdentity, Self::ObserveWeather];
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::BackfillRecordingIdentity => BACKFILL_RECORDING_IDENTITY,
|
||||
Self::ObserveWeather => OBSERVE_WEATHER,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
BACKFILL_RECORDING_IDENTITY => Some(Self::BackfillRecordingIdentity),
|
||||
OBSERVE_WEATHER => Some(Self::ObserveWeather),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/domain/src/job/job_status.rs
Normal file
29
crates/domain/src/job/job_status.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
const PENDING: &str = "pending";
|
||||
const RUNNING: &str = "running";
|
||||
const EXHAUSTED: &str = "exhausted";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => PENDING,
|
||||
Self::Running => RUNNING,
|
||||
Self::Exhausted => EXHAUSTED,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
PENDING => Some(Self::Pending),
|
||||
RUNNING => Some(Self::Running),
|
||||
EXHAUSTED => Some(Self::Exhausted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/domain/src/job/job_subject.rs
Normal file
21
crates/domain/src/job/job_subject.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::entry::MoodEntryId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JobSubject {
|
||||
Entry(MoodEntryId),
|
||||
}
|
||||
|
||||
impl JobSubject {
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
Self::Entry(id) => id.value().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
key.parse()
|
||||
.ok()
|
||||
.map(MoodEntryId::from_uuid)
|
||||
.map(Self::Entry)
|
||||
}
|
||||
}
|
||||
11
crates/domain/src/job/mod.rs
Normal file
11
crates/domain/src/job/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod job;
|
||||
mod job_id;
|
||||
mod job_kind;
|
||||
mod job_status;
|
||||
mod job_subject;
|
||||
|
||||
pub use job::{Job, JobData};
|
||||
pub use job_id::JobId;
|
||||
pub use job_kind::JobKind;
|
||||
pub use job_status::JobStatus;
|
||||
pub use job_subject::JobSubject;
|
||||
@@ -1,16 +1,28 @@
|
||||
#![allow(clippy::module_inception)]
|
||||
|
||||
pub mod activity;
|
||||
pub mod api_token;
|
||||
pub mod attachment;
|
||||
pub mod auth;
|
||||
pub mod correlation;
|
||||
pub mod cycle;
|
||||
pub mod dimension;
|
||||
pub mod entry;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod job;
|
||||
pub mod location;
|
||||
pub mod metric;
|
||||
pub mod moon;
|
||||
pub mod ports;
|
||||
pub mod provider;
|
||||
pub mod push;
|
||||
pub mod rejection;
|
||||
pub mod reminder;
|
||||
pub mod services;
|
||||
pub mod song;
|
||||
pub mod user;
|
||||
pub mod weather;
|
||||
|
||||
mod macros;
|
||||
|
||||
|
||||
33
crates/domain/src/location/coordinates.rs
Normal file
33
crates/domain/src/location/coordinates.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::{Latitude, Longitude};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Coordinates {
|
||||
latitude: Latitude,
|
||||
longitude: Longitude,
|
||||
}
|
||||
|
||||
impl Coordinates {
|
||||
pub fn new(latitude: f64, longitude: f64) -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
latitude: Latitude::new(latitude)?,
|
||||
longitude: Longitude::new(longitude)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence(latitude: f64, longitude: f64) -> Self {
|
||||
Self {
|
||||
latitude: Latitude::from_persistence(latitude),
|
||||
longitude: Longitude::from_persistence(longitude),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latitude(&self) -> Latitude {
|
||||
self.latitude
|
||||
}
|
||||
|
||||
pub fn longitude(&self) -> Longitude {
|
||||
self.longitude
|
||||
}
|
||||
}
|
||||
31
crates/domain/src/location/latitude.rs
Normal file
31
crates/domain/src/location/latitude.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const MIN_LATITUDE: f64 = -90.0;
|
||||
const MAX_LATITUDE: f64 = 90.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Latitude(f64);
|
||||
|
||||
impl Latitude {
|
||||
pub fn new(degrees: f64) -> Result<Self, DomainError> {
|
||||
if !degrees.is_finite() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"latitude must be a finite number".into(),
|
||||
));
|
||||
}
|
||||
if !(MIN_LATITUDE..=MAX_LATITUDE).contains(°rees) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"latitude must be between {MIN_LATITUDE} and {MAX_LATITUDE}, got {degrees}"
|
||||
)));
|
||||
}
|
||||
Ok(Self(degrees))
|
||||
}
|
||||
|
||||
pub fn from_persistence(degrees: f64) -> Self {
|
||||
Self(degrees)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
31
crates/domain/src/location/longitude.rs
Normal file
31
crates/domain/src/location/longitude.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const MIN_LONGITUDE: f64 = -180.0;
|
||||
const MAX_LONGITUDE: f64 = 180.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Longitude(f64);
|
||||
|
||||
impl Longitude {
|
||||
pub fn new(degrees: f64) -> Result<Self, DomainError> {
|
||||
if !degrees.is_finite() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"longitude must be a finite number".into(),
|
||||
));
|
||||
}
|
||||
if !(MIN_LONGITUDE..=MAX_LONGITUDE).contains(°rees) {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"longitude must be between {MIN_LONGITUDE} and {MAX_LONGITUDE}, got {degrees}"
|
||||
)));
|
||||
}
|
||||
Ok(Self(degrees))
|
||||
}
|
||||
|
||||
pub fn from_persistence(degrees: f64) -> Self {
|
||||
Self(degrees)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
7
crates/domain/src/location/mod.rs
Normal file
7
crates/domain/src/location/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod coordinates;
|
||||
mod latitude;
|
||||
mod longitude;
|
||||
|
||||
pub use coordinates::Coordinates;
|
||||
pub use latitude::Latitude;
|
||||
pub use longitude::Longitude;
|
||||
@@ -41,3 +41,33 @@ macro_rules! uuid_id {
|
||||
}
|
||||
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
macro_rules! bounded_metric {
|
||||
($name:ident, $inner:ty, $min:expr, $max:expr, $label:literal) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct $name($inner);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: $inner) -> Result<Self, crate::errors::DomainError> {
|
||||
if !($min..=$max).contains(&value) {
|
||||
return Err(crate::errors::DomainError::InvalidInput(format!(
|
||||
concat!($label, " must be between {} and {}, got {}"),
|
||||
$min, $max, value
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn from_persistence(value: $inner) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> $inner {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use bounded_metric;
|
||||
|
||||
12
crates/domain/src/metric/alcoholic_drinks.rs
Normal file
12
crates/domain/src/metric/alcoholic_drinks.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_ALCOHOLIC_DRINKS: u16 = 0;
|
||||
const MAX_ALCOHOLIC_DRINKS: u16 = 60;
|
||||
|
||||
bounded_metric!(
|
||||
AlcoholicDrinks,
|
||||
u16,
|
||||
MIN_ALCOHOLIC_DRINKS,
|
||||
MAX_ALCOHOLIC_DRINKS,
|
||||
"alcoholic drinks"
|
||||
);
|
||||
12
crates/domain/src/metric/awake_minutes.rs
Normal file
12
crates/domain/src/metric/awake_minutes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_AWAKE_MINUTES: u16 = 0;
|
||||
const MAX_AWAKE_MINUTES: u16 = 1_440;
|
||||
|
||||
bounded_metric!(
|
||||
AwakeMinutes,
|
||||
u16,
|
||||
MIN_AWAKE_MINUTES,
|
||||
MAX_AWAKE_MINUTES,
|
||||
"awake minutes"
|
||||
);
|
||||
49
crates/domain/src/metric/daily_metric.rs
Normal file
49
crates/domain/src/metric/daily_metric.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::entry::Date;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{MetricKind, MetricValue, Source};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DailyMetric {
|
||||
user_id: UserId,
|
||||
date: Date,
|
||||
value: MetricValue,
|
||||
source: Source,
|
||||
}
|
||||
|
||||
impl DailyMetric {
|
||||
pub fn new(user_id: UserId, date: Date, value: MetricValue, source: Source) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
date,
|
||||
value,
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DailyMetric {
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn date(&self) -> &Date {
|
||||
&self.date
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &MetricValue {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &Source {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MetricKind {
|
||||
self.value.kind()
|
||||
}
|
||||
|
||||
pub fn supersedes(&self, stored: &DailyMetric) -> bool {
|
||||
stored.source.is_superseded_by(&self.source)
|
||||
}
|
||||
}
|
||||
12
crates/domain/src/metric/exercise_minutes.rs
Normal file
12
crates/domain/src/metric/exercise_minutes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_EXERCISE_MINUTES: u16 = 0;
|
||||
const MAX_EXERCISE_MINUTES: u16 = 1_440;
|
||||
|
||||
bounded_metric!(
|
||||
ExerciseMinutes,
|
||||
u16,
|
||||
MIN_EXERCISE_MINUTES,
|
||||
MAX_EXERCISE_MINUTES,
|
||||
"exercise minutes"
|
||||
);
|
||||
6
crates/domain/src/metric/hrv.rs
Normal file
6
crates/domain/src/metric/hrv.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_HRV: u16 = 1;
|
||||
const MAX_HRV: u16 = 300;
|
||||
|
||||
bounded_metric!(Hrv, u16, MIN_HRV, MAX_HRV, "heart rate variability");
|
||||
60
crates/domain/src/metric/metric_kind.rs
Normal file
60
crates/domain/src/metric/metric_kind.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
const STEPS: &str = "steps";
|
||||
const SLEEP_MINUTES: &str = "sleepMinutes";
|
||||
const AWAKE_MINUTES: &str = "awakeMinutes";
|
||||
const RESTING_HEART_RATE: &str = "restingHeartRate";
|
||||
const HRV: &str = "hrv";
|
||||
const EXERCISE_MINUTES: &str = "exerciseMinutes";
|
||||
const SCREEN_TIME_MINUTES: &str = "screenTimeMinutes";
|
||||
const ALCOHOLIC_DRINKS: &str = "alcoholicDrinks";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum MetricKind {
|
||||
Steps,
|
||||
SleepMinutes,
|
||||
AwakeMinutes,
|
||||
RestingHeartRate,
|
||||
Hrv,
|
||||
ExerciseMinutes,
|
||||
ScreenTimeMinutes,
|
||||
AlcoholicDrinks,
|
||||
}
|
||||
|
||||
impl MetricKind {
|
||||
pub const ALL: [MetricKind; 8] = [
|
||||
Self::Steps,
|
||||
Self::SleepMinutes,
|
||||
Self::AwakeMinutes,
|
||||
Self::RestingHeartRate,
|
||||
Self::Hrv,
|
||||
Self::ExerciseMinutes,
|
||||
Self::ScreenTimeMinutes,
|
||||
Self::AlcoholicDrinks,
|
||||
];
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Steps => STEPS,
|
||||
Self::SleepMinutes => SLEEP_MINUTES,
|
||||
Self::AwakeMinutes => AWAKE_MINUTES,
|
||||
Self::RestingHeartRate => RESTING_HEART_RATE,
|
||||
Self::Hrv => HRV,
|
||||
Self::ExerciseMinutes => EXERCISE_MINUTES,
|
||||
Self::ScreenTimeMinutes => SCREEN_TIME_MINUTES,
|
||||
Self::AlcoholicDrinks => ALCOHOLIC_DRINKS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
STEPS => Some(Self::Steps),
|
||||
SLEEP_MINUTES => Some(Self::SleepMinutes),
|
||||
AWAKE_MINUTES => Some(Self::AwakeMinutes),
|
||||
RESTING_HEART_RATE => Some(Self::RestingHeartRate),
|
||||
HRV => Some(Self::Hrv),
|
||||
EXERCISE_MINUTES => Some(Self::ExerciseMinutes),
|
||||
SCREEN_TIME_MINUTES => Some(Self::ScreenTimeMinutes),
|
||||
ALCOHOLIC_DRINKS => Some(Self::AlcoholicDrinks),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
79
crates/domain/src/metric/metric_value.rs
Normal file
79
crates/domain/src/metric/metric_value.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::{
|
||||
AlcoholicDrinks, AwakeMinutes, ExerciseMinutes, Hrv, MetricKind, RestingHeartRate,
|
||||
ScreenTimeMinutes, SleepMinutes, Steps,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetricValue {
|
||||
Steps(Steps),
|
||||
SleepMinutes(SleepMinutes),
|
||||
AwakeMinutes(AwakeMinutes),
|
||||
RestingHeartRate(RestingHeartRate),
|
||||
Hrv(Hrv),
|
||||
ExerciseMinutes(ExerciseMinutes),
|
||||
ScreenTimeMinutes(ScreenTimeMinutes),
|
||||
AlcoholicDrinks(AlcoholicDrinks),
|
||||
}
|
||||
|
||||
impl MetricValue {
|
||||
pub fn of_kind(kind: MetricKind, count: i64) -> Result<Self, DomainError> {
|
||||
match kind {
|
||||
MetricKind::Steps => Ok(Self::Steps(Steps::new(narrow(count, kind)?)?)),
|
||||
MetricKind::SleepMinutes => {
|
||||
Ok(Self::SleepMinutes(SleepMinutes::new(narrow(count, kind)?)?))
|
||||
}
|
||||
MetricKind::AwakeMinutes => {
|
||||
Ok(Self::AwakeMinutes(AwakeMinutes::new(narrow(count, kind)?)?))
|
||||
}
|
||||
MetricKind::RestingHeartRate => Ok(Self::RestingHeartRate(RestingHeartRate::new(
|
||||
narrow(count, kind)?,
|
||||
)?)),
|
||||
MetricKind::Hrv => Ok(Self::Hrv(Hrv::new(narrow(count, kind)?)?)),
|
||||
MetricKind::ExerciseMinutes => Ok(Self::ExerciseMinutes(ExerciseMinutes::new(
|
||||
narrow(count, kind)?,
|
||||
)?)),
|
||||
MetricKind::ScreenTimeMinutes => Ok(Self::ScreenTimeMinutes(ScreenTimeMinutes::new(
|
||||
narrow(count, kind)?,
|
||||
)?)),
|
||||
MetricKind::AlcoholicDrinks => Ok(Self::AlcoholicDrinks(AlcoholicDrinks::new(
|
||||
narrow(count, kind)?,
|
||||
)?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MetricValue {
|
||||
pub fn kind(&self) -> MetricKind {
|
||||
match self {
|
||||
Self::Steps(_) => MetricKind::Steps,
|
||||
Self::SleepMinutes(_) => MetricKind::SleepMinutes,
|
||||
Self::AwakeMinutes(_) => MetricKind::AwakeMinutes,
|
||||
Self::RestingHeartRate(_) => MetricKind::RestingHeartRate,
|
||||
Self::Hrv(_) => MetricKind::Hrv,
|
||||
Self::ExerciseMinutes(_) => MetricKind::ExerciseMinutes,
|
||||
Self::ScreenTimeMinutes(_) => MetricKind::ScreenTimeMinutes,
|
||||
Self::AlcoholicDrinks(_) => MetricKind::AlcoholicDrinks,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count(&self) -> i64 {
|
||||
match self {
|
||||
Self::Steps(steps) => i64::from(steps.value()),
|
||||
Self::SleepMinutes(minutes) => i64::from(minutes.value()),
|
||||
Self::AwakeMinutes(minutes) => i64::from(minutes.value()),
|
||||
Self::RestingHeartRate(beats) => i64::from(beats.value()),
|
||||
Self::Hrv(milliseconds) => i64::from(milliseconds.value()),
|
||||
Self::ExerciseMinutes(minutes) => i64::from(minutes.value()),
|
||||
Self::ScreenTimeMinutes(minutes) => i64::from(minutes.value()),
|
||||
Self::AlcoholicDrinks(drinks) => i64::from(drinks.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn narrow<T: TryFrom<i64>>(count: i64, kind: MetricKind) -> Result<T, DomainError> {
|
||||
T::try_from(count).map_err(|_| {
|
||||
DomainError::InvalidInput(format!("{} cannot hold the value {count}", kind.name()))
|
||||
})
|
||||
}
|
||||
25
crates/domain/src/metric/mod.rs
Normal file
25
crates/domain/src/metric/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
mod alcoholic_drinks;
|
||||
mod awake_minutes;
|
||||
mod daily_metric;
|
||||
mod exercise_minutes;
|
||||
mod hrv;
|
||||
mod metric_kind;
|
||||
mod metric_value;
|
||||
mod resting_heart_rate;
|
||||
mod screen_time_minutes;
|
||||
mod sleep_minutes;
|
||||
mod source;
|
||||
mod steps;
|
||||
|
||||
pub use alcoholic_drinks::AlcoholicDrinks;
|
||||
pub use awake_minutes::AwakeMinutes;
|
||||
pub use daily_metric::DailyMetric;
|
||||
pub use exercise_minutes::ExerciseMinutes;
|
||||
pub use hrv::Hrv;
|
||||
pub use metric_kind::MetricKind;
|
||||
pub use metric_value::MetricValue;
|
||||
pub use resting_heart_rate::RestingHeartRate;
|
||||
pub use screen_time_minutes::ScreenTimeMinutes;
|
||||
pub use sleep_minutes::SleepMinutes;
|
||||
pub use source::Source;
|
||||
pub use steps::Steps;
|
||||
12
crates/domain/src/metric/resting_heart_rate.rs
Normal file
12
crates/domain/src/metric/resting_heart_rate.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_RESTING_HEART_RATE: u16 = 25;
|
||||
const MAX_RESTING_HEART_RATE: u16 = 120;
|
||||
|
||||
bounded_metric!(
|
||||
RestingHeartRate,
|
||||
u16,
|
||||
MIN_RESTING_HEART_RATE,
|
||||
MAX_RESTING_HEART_RATE,
|
||||
"resting heart rate"
|
||||
);
|
||||
12
crates/domain/src/metric/screen_time_minutes.rs
Normal file
12
crates/domain/src/metric/screen_time_minutes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_SCREEN_TIME_MINUTES: u16 = 0;
|
||||
const MAX_SCREEN_TIME_MINUTES: u16 = 1_440;
|
||||
|
||||
bounded_metric!(
|
||||
ScreenTimeMinutes,
|
||||
u16,
|
||||
MIN_SCREEN_TIME_MINUTES,
|
||||
MAX_SCREEN_TIME_MINUTES,
|
||||
"screen time minutes"
|
||||
);
|
||||
12
crates/domain/src/metric/sleep_minutes.rs
Normal file
12
crates/domain/src/metric/sleep_minutes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_SLEEP_MINUTES: u16 = 0;
|
||||
const MAX_SLEEP_MINUTES: u16 = 1_440;
|
||||
|
||||
bounded_metric!(
|
||||
SleepMinutes,
|
||||
u16,
|
||||
MIN_SLEEP_MINUTES,
|
||||
MAX_SLEEP_MINUTES,
|
||||
"sleep minutes"
|
||||
);
|
||||
24
crates/domain/src/metric/source.rs
Normal file
24
crates/domain/src/metric/source.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::provider::ProviderName;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Manual,
|
||||
Provider(ProviderName),
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn is_manual(&self) -> bool {
|
||||
matches!(self, Self::Manual)
|
||||
}
|
||||
|
||||
pub fn is_superseded_by(&self, incoming: &Source) -> bool {
|
||||
incoming.is_manual() || !self.is_manual()
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> Option<&ProviderName> {
|
||||
match self {
|
||||
Self::Manual => None,
|
||||
Self::Provider(name) => Some(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/domain/src/metric/steps.rs
Normal file
12
crates/domain/src/metric/steps.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::macros::bounded_metric;
|
||||
|
||||
const MIN_STEPS_PER_DAY: u32 = 0;
|
||||
const MAX_STEPS_PER_DAY: u32 = 200_000;
|
||||
|
||||
bounded_metric!(
|
||||
Steps,
|
||||
u32,
|
||||
MIN_STEPS_PER_DAY,
|
||||
MAX_STEPS_PER_DAY,
|
||||
"step count"
|
||||
);
|
||||
3
crates/domain/src/moon/mod.rs
Normal file
3
crates/domain/src/moon/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod moon_phase;
|
||||
|
||||
pub use moon_phase::MoonPhase;
|
||||
46
crates/domain/src/moon/moon_phase.rs
Normal file
46
crates/domain/src/moon/moon_phase.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::entry::Date;
|
||||
|
||||
const SYNODIC_MONTH_IN_DAYS: f64 = 29.530_588_853;
|
||||
const NAMED_PHASES: [&str; 8] = [
|
||||
"New",
|
||||
"Waxing Crescent",
|
||||
"First Quarter",
|
||||
"Waxing Gibbous",
|
||||
"Full",
|
||||
"Waning Gibbous",
|
||||
"Last Quarter",
|
||||
"Waning Crescent",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct MoonPhase(f64);
|
||||
|
||||
impl MoonPhase {
|
||||
pub fn on(date: &Date) -> Self {
|
||||
let days = (date.value() - known_new_moon()).num_days() as f64;
|
||||
let through_the_cycle = days.rem_euclid(SYNODIC_MONTH_IN_DAYS) / SYNODIC_MONTH_IN_DAYS;
|
||||
|
||||
Self(through_the_cycle)
|
||||
}
|
||||
}
|
||||
|
||||
impl MoonPhase {
|
||||
pub fn illumination(&self) -> f64 {
|
||||
let lit = (1.0 - (std::f64::consts::TAU * self.0).cos()) / 2.0;
|
||||
|
||||
lit.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
let segments = NAMED_PHASES.len() as f64;
|
||||
let segment = (self.0 * segments).round() as usize % NAMED_PHASES.len();
|
||||
|
||||
NAMED_PHASES[segment]
|
||||
}
|
||||
}
|
||||
|
||||
fn known_new_moon() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2000, 1, 6).unwrap_or_default()
|
||||
}
|
||||
21
crates/domain/src/ports/api_token.rs
Normal file
21
crates/domain/src/ports/api_token.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::api_token::{ApiToken, ApiTokenId, TokenDigest};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
pub trait ApiTokenSecretPort: Send + Sync {
|
||||
fn mint(&self) -> String;
|
||||
fn digest(&self, secret: &str) -> TokenDigest;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ApiTokenCommandPort: Send + Sync {
|
||||
async fn save(&self, token: &ApiToken) -> Result<(), DomainError>;
|
||||
async fn revoke(&self, user_id: &UserId, id: &ApiTokenId) -> Result<(), DomainError>;
|
||||
async fn mark_used(&self, id: &ApiTokenId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ApiTokenQueryPort: Send + Sync {
|
||||
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError>;
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError>;
|
||||
}
|
||||
24
crates/domain/src/ports/cycle.rs
Normal file
24
crates/domain/src/ports/cycle.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::entry::Date;
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::{UserId, UserPreferences};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CycleStartCommandPort: Send + Sync {
|
||||
async fn record(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError>;
|
||||
async fn forget(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CycleStartQueryPort: Send + Sync {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Date>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UserPreferencesCommandPort: Send + Sync {
|
||||
async fn save(&self, preferences: &UserPreferences) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UserPreferencesQueryPort: Send + Sync {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Option<UserPreferences>, DomainError>;
|
||||
}
|
||||
19
crates/domain/src/ports/dimension.rs
Normal file
19
crates/domain/src/ports/dimension.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dimension::DimensionValue;
|
||||
use crate::entry::MoodEntryId;
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait EntryDimensionPort: Send + Sync {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError>;
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
@@ -1,24 +1,44 @@
|
||||
use crate::activity::Activity;
|
||||
use crate::entry::MoodEntry;
|
||||
use crate::cycle::CycleStartRestore;
|
||||
use crate::dimension::ComposedEntry;
|
||||
use crate::errors::DomainError;
|
||||
use crate::metric::DailyMetric;
|
||||
use crate::reminder::Reminder;
|
||||
use crate::user::UserPreferences;
|
||||
|
||||
pub struct MediaBlob {
|
||||
pub id: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct UserExport {
|
||||
pub entries: Vec<MoodEntry>,
|
||||
pub activities: Vec<Activity>,
|
||||
pub reminders: Vec<Reminder>,
|
||||
pub struct BackupMedia {
|
||||
pub photos: Vec<MediaBlob>,
|
||||
pub voice_memos: Vec<MediaBlob>,
|
||||
}
|
||||
|
||||
pub struct UserBackup {
|
||||
pub entries: Vec<ComposedEntry>,
|
||||
pub metrics: Vec<DailyMetric>,
|
||||
pub cycle_starts: Vec<CycleStartRestore>,
|
||||
pub activities: Vec<Activity>,
|
||||
pub reminders: Vec<Reminder>,
|
||||
pub preferences: UserPreferences,
|
||||
pub media: BackupMedia,
|
||||
}
|
||||
|
||||
pub struct SharedExtract {
|
||||
pub entries: Vec<ComposedEntry>,
|
||||
pub activities: Vec<Activity>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExportPort: Send + Sync {
|
||||
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError>;
|
||||
pub trait BackupWriterPort: Send + Sync {
|
||||
async fn write(&self, backup: &UserBackup) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExtractWriterPort: Send + Sync {
|
||||
async fn write(&self, extract: &SharedExtract) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -26,6 +46,7 @@ pub trait ImportSourcePort: Send + Sync {
|
||||
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedRow {
|
||||
pub mood: u8,
|
||||
pub date: String,
|
||||
|
||||
81
crates/domain/src/ports/job.rs
Normal file
81
crates/domain/src/ports/job.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::job::{Job, JobId, JobKind, JobSubject};
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait JobQueueCommandPort: Send + Sync {
|
||||
async fn enqueue(&self, kind: JobKind, subject: &JobSubject) -> Result<bool, DomainError>;
|
||||
|
||||
async fn claim(&self, kind: JobKind, most: usize) -> Result<Vec<Job>, DomainError>;
|
||||
|
||||
async fn finish(&self, id: &JobId) -> Result<(), DomainError>;
|
||||
|
||||
async fn release(&self, id: &JobId, reason: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn exhaust(&self, id: &JobId, reason: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn reclaim_stalled(&self, stalled_after_seconds: i64) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait JobQueueQueryPort: Send + Sync {
|
||||
async fn find_exhausted(&self, most: usize) -> Result<Vec<Job>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait WeatherLookupPort: Send + Sync {
|
||||
fn provider(&self) -> &crate::provider::ProviderName;
|
||||
|
||||
async fn observed_at(
|
||||
&self,
|
||||
coordinates: &crate::location::Coordinates,
|
||||
instant: &chrono::DateTime<chrono::FixedOffset>,
|
||||
) -> Result<Option<crate::weather::Weather>, DomainError>;
|
||||
}
|
||||
|
||||
pub struct UnwatchedPlace {
|
||||
pub entry_id: crate::entry::MoodEntryId,
|
||||
pub coordinates: crate::location::Coordinates,
|
||||
pub logged_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
}
|
||||
|
||||
impl UnwatchedPlace {
|
||||
pub fn subject(&self) -> JobSubject {
|
||||
JobSubject::Entry(self.entry_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait WeatherBacklogQueryPort: Send + Sync {
|
||||
async fn find_places_without_weather(
|
||||
&self,
|
||||
most: usize,
|
||||
) -> Result<Vec<UnwatchedPlace>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RecordingBackfillQueryPort: Send + Sync {
|
||||
async fn find_songs_without_a_recording(
|
||||
&self,
|
||||
most: usize,
|
||||
) -> Result<Vec<UnidentifiedSong>, DomainError>;
|
||||
|
||||
async fn record_identity(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
recording_id: &crate::song::RecordingId,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub struct UnidentifiedSong {
|
||||
pub entry_id: crate::entry::MoodEntryId,
|
||||
pub user_id: UserId,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
}
|
||||
|
||||
impl UnidentifiedSong {
|
||||
pub fn subject(&self) -> JobSubject {
|
||||
JobSubject::Entry(self.entry_id.clone())
|
||||
}
|
||||
}
|
||||
25
crates/domain/src/ports/metric.rs
Normal file
25
crates/domain/src/ports/metric.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use crate::entry::{Date, DateSpan};
|
||||
use crate::errors::DomainError;
|
||||
use crate::metric::{DailyMetric, MetricKind};
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait DailyMetricCommandPort: Send + Sync {
|
||||
async fn save(&self, metrics: &[DailyMetric]) -> Result<usize, DomainError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
date: &Date,
|
||||
kinds: &[MetricKind],
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait DailyMetricQueryPort: Send + Sync {
|
||||
async fn find_by_span(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
span: &DateSpan,
|
||||
) -> Result<Vec<DailyMetric>, DomainError>;
|
||||
}
|
||||
@@ -1,23 +1,53 @@
|
||||
mod activity;
|
||||
mod api_token;
|
||||
mod auth;
|
||||
mod cascade;
|
||||
mod cycle;
|
||||
mod dimension;
|
||||
mod entry;
|
||||
mod event;
|
||||
mod import_export;
|
||||
mod job;
|
||||
mod media;
|
||||
mod metric;
|
||||
mod music;
|
||||
mod provider;
|
||||
mod push;
|
||||
mod rejection;
|
||||
mod reminder;
|
||||
mod restore;
|
||||
mod user;
|
||||
|
||||
pub use activity::{ActivityCommandPort, ActivityQueryPort};
|
||||
pub use api_token::{ApiTokenCommandPort, ApiTokenQueryPort, ApiTokenSecretPort};
|
||||
pub use auth::{
|
||||
AuthServicePort, PasswordHasherPort, RefreshSessionCommandPort, RefreshSessionQueryPort,
|
||||
};
|
||||
pub use cascade::CascadeDeletePort;
|
||||
pub use cycle::{
|
||||
CycleStartCommandPort, CycleStartQueryPort, UserPreferencesCommandPort,
|
||||
UserPreferencesQueryPort,
|
||||
};
|
||||
pub use dimension::EntryDimensionPort;
|
||||
pub use entry::{MoodEntryCommandPort, MoodEntryQueryPort};
|
||||
pub use event::EventPublisherPort;
|
||||
pub use import_export::{ExportPort, ImportSourcePort, ImportedRow, MediaBlob, UserExport};
|
||||
pub use import_export::{
|
||||
BackupMedia, BackupWriterPort, ExtractWriterPort, ImportSourcePort, ImportedRow, MediaBlob,
|
||||
SharedExtract, UserBackup,
|
||||
};
|
||||
pub use job::{
|
||||
JobQueueCommandPort, JobQueueQueryPort, RecordingBackfillQueryPort, UnidentifiedSong,
|
||||
UnwatchedPlace, WeatherBacklogQueryPort, WeatherLookupPort,
|
||||
};
|
||||
pub use media::{MediaFile, MediaStoragePort};
|
||||
pub use metric::{DailyMetricCommandPort, DailyMetricQueryPort};
|
||||
pub use music::{NowPlayingPort, RecordingLookupPort};
|
||||
pub use provider::{ProviderConnectionCommandPort, ProviderConnectionQueryPort};
|
||||
pub use push::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
|
||||
pub use rejection::{RejectionCommandPort, RejectionQueryPort};
|
||||
pub use reminder::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort};
|
||||
pub use restore::{
|
||||
BackupReaderPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric,
|
||||
RestorableReminder,
|
||||
};
|
||||
pub use user::{UserCommandPort, UserQueryPort};
|
||||
|
||||
18
crates/domain/src/ports/music.rs
Normal file
18
crates/domain/src/ports/music.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::song::{RecordingId, Song};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait NowPlayingPort: Send + Sync {
|
||||
fn provider(&self) -> &str;
|
||||
|
||||
async fn now_playing(&self, credential: &[u8]) -> Result<Option<Song>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RecordingLookupPort: Send + Sync {
|
||||
async fn find_recording(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
) -> Result<Option<RecordingId>, DomainError>;
|
||||
}
|
||||
21
crates/domain/src/ports/provider.rs
Normal file
21
crates/domain/src/ports/provider.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::provider::{ProviderConnection, ProviderName};
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ProviderConnectionCommandPort: Send + Sync {
|
||||
async fn save(&self, connection: &ProviderConnection) -> Result<(), DomainError>;
|
||||
|
||||
async fn delete(&self, user_id: &UserId, provider: &ProviderName) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ProviderConnectionQueryPort: Send + Sync {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ProviderConnection>, DomainError>;
|
||||
|
||||
async fn find_by_user_and_provider(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
provider: &ProviderName,
|
||||
) -> Result<Option<ProviderConnection>, DomainError>;
|
||||
}
|
||||
16
crates/domain/src/ports/rejection.rs
Normal file
16
crates/domain/src/ports/rejection.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::rejection::RejectedMetric;
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RejectionCommandPort: Send + Sync {
|
||||
async fn record(&self, rejections: &[RejectedMetric]) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RejectionQueryPort: Send + Sync {
|
||||
async fn find_recent_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<RejectedMetric>, DomainError>;
|
||||
}
|
||||
42
crates/domain/src/ports/restore.rs
Normal file
42
crates/domain/src/ports/restore.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub struct RestorableEntry {
|
||||
pub mood: u8,
|
||||
pub logged_at: String,
|
||||
pub dimensions: Vec<crate::dimension::DimensionValue>,
|
||||
}
|
||||
|
||||
pub struct RestorableMetric {
|
||||
pub date: String,
|
||||
pub kind: String,
|
||||
pub value: i64,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RestorableActivity {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
pub struct RestorableReminder {
|
||||
pub enabled: bool,
|
||||
pub times: [Option<String>; 7],
|
||||
}
|
||||
|
||||
pub struct RestorableContents {
|
||||
pub entries: Vec<RestorableEntry>,
|
||||
pub metrics: Vec<RestorableMetric>,
|
||||
pub cycle_starts: Vec<String>,
|
||||
pub activities: Vec<RestorableActivity>,
|
||||
pub reminders: Vec<RestorableReminder>,
|
||||
pub tracks_cycle: bool,
|
||||
pub photos: Vec<(String, Vec<u8>)>,
|
||||
pub voice_memos: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait BackupReaderPort: Send + Sync {
|
||||
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError>;
|
||||
}
|
||||
26
crates/domain/src/provider/encrypted_credential.rs
Normal file
26
crates/domain/src/provider/encrypted_credential.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedCredential(Vec<u8>);
|
||||
|
||||
impl EncryptedCredential {
|
||||
pub fn from_persistence(ciphertext: Vec<u8>) -> Self {
|
||||
Self(ciphertext)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EncryptedCredential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "EncryptedCredential(<redacted {} bytes>)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CredentialCipher: Send + Sync {
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError>;
|
||||
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
9
crates/domain/src/provider/mod.rs
Normal file
9
crates/domain/src/provider/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod encrypted_credential;
|
||||
mod provider_connection;
|
||||
mod provider_connection_id;
|
||||
mod provider_name;
|
||||
|
||||
pub use encrypted_credential::{CredentialCipher, EncryptedCredential};
|
||||
pub use provider_connection::{ProviderConnection, ProviderConnectionData};
|
||||
pub use provider_connection_id::ProviderConnectionId;
|
||||
pub use provider_name::ProviderName;
|
||||
82
crates/domain/src/provider/provider_connection.rs
Normal file
82
crates/domain/src/provider/provider_connection.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{EncryptedCredential, ProviderConnectionId, ProviderName};
|
||||
|
||||
pub struct ProviderConnectionData {
|
||||
pub id: ProviderConnectionId,
|
||||
pub user_id: UserId,
|
||||
pub provider: ProviderName,
|
||||
pub credential: EncryptedCredential,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderConnection {
|
||||
id: ProviderConnectionId,
|
||||
user_id: UserId,
|
||||
provider: ProviderName,
|
||||
credential: EncryptedCredential,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ProviderConnection {
|
||||
pub fn new(user_id: UserId, provider: ProviderName, credential: EncryptedCredential) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ProviderConnectionId::generate(),
|
||||
user_id,
|
||||
provider,
|
||||
credential,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: ProviderConnectionData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
user_id: data.user_id,
|
||||
provider: data.provider,
|
||||
credential: data.credential,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConnection {
|
||||
pub fn id(&self) -> &ProviderConnectionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> &ProviderName {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
pub fn credential(&self) -> &EncryptedCredential {
|
||||
&self.credential
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> {
|
||||
&self.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConnection {
|
||||
pub fn replace_credential(&mut self, credential: EncryptedCredential) {
|
||||
self.credential = credential;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/provider/provider_connection_id.rs
Normal file
1
crates/domain/src/provider/provider_connection_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(ProviderConnectionId);
|
||||
34
crates/domain/src/provider/provider_name.rs
Normal file
34
crates/domain/src/provider/provider_name.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProviderName(String);
|
||||
|
||||
impl ProviderName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let normalised = name.into().trim().to_lowercase();
|
||||
|
||||
if normalised.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"provider name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if !normalised
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(DomainError::InvalidInput(
|
||||
"provider name must contain only letters, digits and hyphens".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self(normalised))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/rejection/mod.rs
Normal file
9
crates/domain/src/rejection/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod rejected_metric;
|
||||
mod rejection_detail;
|
||||
mod rejection_id;
|
||||
mod rejection_origin;
|
||||
|
||||
pub use rejected_metric::{RejectedMetric, RejectedMetricData};
|
||||
pub use rejection_detail::RejectionDetail;
|
||||
pub use rejection_id::RejectionId;
|
||||
pub use rejection_origin::RejectionOrigin;
|
||||
79
crates/domain/src/rejection/rejected_metric.rs
Normal file
79
crates/domain/src/rejection/rejected_metric.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{RejectionDetail, RejectionId, RejectionOrigin};
|
||||
|
||||
pub struct RejectedMetricData {
|
||||
pub id: RejectionId,
|
||||
pub user_id: UserId,
|
||||
pub origin: RejectionOrigin,
|
||||
pub detail: RejectionDetail,
|
||||
pub reason: String,
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RejectedMetric {
|
||||
id: RejectionId,
|
||||
user_id: UserId,
|
||||
origin: RejectionOrigin,
|
||||
detail: RejectionDetail,
|
||||
reason: String,
|
||||
recorded_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RejectedMetric {
|
||||
pub fn new(
|
||||
user_id: UserId,
|
||||
origin: RejectionOrigin,
|
||||
detail: RejectionDetail,
|
||||
reason: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: RejectionId::generate(),
|
||||
user_id,
|
||||
origin,
|
||||
detail,
|
||||
reason: reason.into(),
|
||||
recorded_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: RejectedMetricData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
user_id: data.user_id,
|
||||
origin: data.origin,
|
||||
detail: data.detail,
|
||||
reason: data.reason,
|
||||
recorded_at: data.recorded_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RejectedMetric {
|
||||
pub fn id(&self) -> &RejectionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> RejectionOrigin {
|
||||
self.origin
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &RejectionDetail {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> &str {
|
||||
&self.reason
|
||||
}
|
||||
|
||||
pub fn recorded_at(&self) -> &DateTime<Utc> {
|
||||
&self.recorded_at
|
||||
}
|
||||
}
|
||||
44
crates/domain/src/rejection/rejection_detail.rs
Normal file
44
crates/domain/src/rejection/rejection_detail.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::entry::Date;
|
||||
use crate::provider::ProviderName;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RejectionDetail {
|
||||
provider: Option<ProviderName>,
|
||||
date: Option<Date>,
|
||||
kind: String,
|
||||
value: Option<i64>,
|
||||
}
|
||||
|
||||
impl RejectionDetail {
|
||||
pub fn new(
|
||||
provider: Option<ProviderName>,
|
||||
date: Option<Date>,
|
||||
kind: impl Into<String>,
|
||||
value: Option<i64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
date,
|
||||
kind: kind.into(),
|
||||
value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RejectionDetail {
|
||||
pub fn provider(&self) -> Option<&ProviderName> {
|
||||
self.provider.as_ref()
|
||||
}
|
||||
|
||||
pub fn date(&self) -> Option<&Date> {
|
||||
self.date.as_ref()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &str {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<i64> {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
3
crates/domain/src/rejection/rejection_id.rs
Normal file
3
crates/domain/src/rejection/rejection_id.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
use crate::macros::uuid_id;
|
||||
|
||||
uuid_id!(RejectionId);
|
||||
25
crates/domain/src/rejection/rejection_origin.rs
Normal file
25
crates/domain/src/rejection/rejection_origin.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
const IMPORT: &str = "import";
|
||||
const STORED_ROW: &str = "storedRow";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum RejectionOrigin {
|
||||
Import,
|
||||
StoredRow,
|
||||
}
|
||||
|
||||
impl RejectionOrigin {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Import => IMPORT,
|
||||
Self::StoredRow => STORED_ROW,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
IMPORT => Some(Self::Import),
|
||||
STORED_ROW => Some(Self::StoredRow),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{Mood, MoodEntry};
|
||||
use crate::entry::{Date, Mood, MoodEntry};
|
||||
|
||||
pub struct MoodAnalyzerService;
|
||||
|
||||
@@ -32,64 +29,28 @@ impl MoodAnalyzerService {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn current_streak(entries: &[MoodEntry]) -> usize {
|
||||
if entries.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut dates: Vec<_> = entries.iter().map(|e| e.logged_at().date_naive()).collect();
|
||||
#[tracing::instrument(skip(dates), fields(date_count = dates.len()))]
|
||||
pub fn current_streak(dates: &[Date], today: Date) -> usize {
|
||||
let mut dates = dates.to_vec();
|
||||
dates.sort();
|
||||
dates.dedup();
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
let Some(most_recent) = dates.last() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let last_date = *dates.last().unwrap();
|
||||
let diff_to_today = (today - last_date).num_days();
|
||||
if diff_to_today > 1 {
|
||||
if today.days_since(most_recent) > 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut streak = 1;
|
||||
for window in dates.windows(2).rev() {
|
||||
let diff = window[1] - window[0];
|
||||
if diff.num_days() == 1 {
|
||||
streak += 1;
|
||||
} else {
|
||||
if window[1].days_since(&window[0]) != 1 {
|
||||
break;
|
||||
}
|
||||
streak += 1;
|
||||
}
|
||||
|
||||
streak
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn activity_mood_correlation(
|
||||
entries: &[MoodEntry],
|
||||
activity_id: &ActivityId,
|
||||
) -> Option<f64> {
|
||||
let with: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| e.activities().contains(activity_id))
|
||||
.collect();
|
||||
|
||||
let without: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| !e.activities().contains(activity_id))
|
||||
.collect();
|
||||
|
||||
let avg_with = Self::average_mood_from_refs(&with)?;
|
||||
let avg_without = Self::average_mood_from_refs(&without)?;
|
||||
|
||||
Some(avg_with - avg_without)
|
||||
}
|
||||
|
||||
fn average_mood_from_refs(entries: &[&MoodEntry]) -> Option<f64> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sum: u32 = entries.iter().map(|e| e.mood().value() as u32).sum();
|
||||
Some(sum as f64 / entries.len() as f64)
|
||||
}
|
||||
}
|
||||
|
||||
24
crates/domain/src/song/album_name.rs
Normal file
24
crates/domain/src/song/album_name.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AlbumName(String);
|
||||
|
||||
impl AlbumName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = name.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"album name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
24
crates/domain/src/song/artist_name.rs
Normal file
24
crates/domain/src/song/artist_name.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArtistName(String);
|
||||
|
||||
impl ArtistName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = name.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"artist name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
11
crates/domain/src/song/mod.rs
Normal file
11
crates/domain/src/song/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod album_name;
|
||||
mod artist_name;
|
||||
mod recording_id;
|
||||
mod song;
|
||||
mod song_title;
|
||||
|
||||
pub use album_name::AlbumName;
|
||||
pub use artist_name::ArtistName;
|
||||
pub use recording_id::RecordingId;
|
||||
pub use song::Song;
|
||||
pub use song_title::SongTitle;
|
||||
21
crates/domain/src/song/recording_id.rs
Normal file
21
crates/domain/src/song/recording_id.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RecordingId(uuid::Uuid);
|
||||
|
||||
impl RecordingId {
|
||||
pub fn new(id: &str) -> Result<Self, DomainError> {
|
||||
id.trim()
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(Self)
|
||||
.map_err(|_| DomainError::InvalidInput("recording id must be a UUID".into()))
|
||||
}
|
||||
|
||||
pub fn from_uuid(id: uuid::Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
59
crates/domain/src/song/song.rs
Normal file
59
crates/domain/src/song/song.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::{AlbumName, ArtistName, RecordingId, SongTitle};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Song {
|
||||
title: SongTitle,
|
||||
artist: ArtistName,
|
||||
album: Option<AlbumName>,
|
||||
recording_id: Option<RecordingId>,
|
||||
}
|
||||
|
||||
impl Song {
|
||||
pub fn new(
|
||||
title: impl Into<String>,
|
||||
artist: impl Into<String>,
|
||||
album: Option<String>,
|
||||
recording_id: Option<RecordingId>,
|
||||
) -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
title: SongTitle::new(title)?,
|
||||
artist: ArtistName::new(artist)?,
|
||||
album: album.and_then(|name| AlbumName::new(name).ok()),
|
||||
recording_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
title: SongTitle,
|
||||
artist: ArtistName,
|
||||
album: Option<AlbumName>,
|
||||
recording_id: Option<RecordingId>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
recording_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Song {
|
||||
pub fn title(&self) -> &SongTitle {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn artist(&self) -> &ArtistName {
|
||||
&self.artist
|
||||
}
|
||||
|
||||
pub fn album(&self) -> Option<&AlbumName> {
|
||||
self.album.as_ref()
|
||||
}
|
||||
|
||||
pub fn recording_id(&self) -> Option<&RecordingId> {
|
||||
self.recording_id.as_ref()
|
||||
}
|
||||
}
|
||||
24
crates/domain/src/song/song_title.rs
Normal file
24
crates/domain/src/song/song_title.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SongTitle(String);
|
||||
|
||||
impl SongTitle {
|
||||
pub fn new(title: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = title.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"song title cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(title: String) -> Self {
|
||||
Self(title)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
33
crates/domain/src/testing/fakes/api_token_secret.rs
Normal file
33
crates/domain/src/testing/fakes/api_token_secret.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::api_token::TokenDigest;
|
||||
|
||||
pub struct FakeApiTokenSecret {
|
||||
minted: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FakeApiTokenSecret {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
minted: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FakeApiTokenSecret {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ports::ApiTokenSecretPort for FakeApiTokenSecret {
|
||||
fn mint(&self) -> String {
|
||||
let number = self.minted.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
format!("kmood_secret_{number}")
|
||||
}
|
||||
|
||||
fn digest(&self, secret: &str) -> TokenDigest {
|
||||
TokenDigest::from_persistence(format!("digest-of-{secret}"))
|
||||
}
|
||||
}
|
||||
18
crates/domain/src/testing/fakes/cipher.rs
Normal file
18
crates/domain/src/testing/fakes/cipher.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::provider::{CredentialCipher, EncryptedCredential};
|
||||
|
||||
const MASK: u8 = 0x5a;
|
||||
|
||||
pub struct FakeCredentialCipher;
|
||||
|
||||
impl CredentialCipher for FakeCredentialCipher {
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError> {
|
||||
Ok(EncryptedCredential::from_persistence(
|
||||
plaintext.iter().map(|byte| byte ^ MASK).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(credential.value().iter().map(|byte| byte ^ MASK).collect())
|
||||
}
|
||||
}
|
||||
63
crates/domain/src/testing/fakes/dimension_store.rs
Normal file
63
crates/domain/src/testing/fakes/dimension_store.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::dimension::{DimensionKind, DimensionValue};
|
||||
use crate::entry::MoodEntryId;
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub struct InMemoryDimensionStore {
|
||||
kind: DimensionKind,
|
||||
values: RwLock<HashMap<MoodEntryId, DimensionValue>>,
|
||||
}
|
||||
|
||||
impl InMemoryDimensionStore {
|
||||
pub fn new(kind: DimensionKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
values: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forget(&self, entry_id: &MoodEntryId) {
|
||||
self.values.write().unwrap().remove(entry_id);
|
||||
}
|
||||
|
||||
pub fn holds_count(&self) -> usize {
|
||||
self.values.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn holds(&self, entry_id: &MoodEntryId) -> bool {
|
||||
self.values.read().unwrap().contains_key(entry_id)
|
||||
}
|
||||
|
||||
pub fn put(&self, entry_id: &MoodEntryId, value: DimensionValue) {
|
||||
self.values.write().unwrap().insert(entry_id.clone(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::EntryDimensionPort for InMemoryDimensionStore {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
let stored = self.values.read().unwrap();
|
||||
Ok(entry_ids
|
||||
.iter()
|
||||
.filter_map(|id| stored.get(id).map(|value| (id.clone(), value.clone())))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let mut stored = self.values.write().unwrap();
|
||||
match values.iter().find(|value| value.kind() == self.kind) {
|
||||
Some(value) => stored.insert(entry_id.clone(), value.clone()),
|
||||
None => stored.remove(entry_id),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
137
crates/domain/src/testing/fakes/media_storage.rs
Normal file
137
crates/domain/src/testing/fakes/media_storage.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::ports::MediaFile;
|
||||
|
||||
pub struct FakeMediaStorage {
|
||||
photos: RwLock<HashMap<PhotoId, Vec<u8>>>,
|
||||
voice_memos: RwLock<HashMap<VoiceMemoId, Vec<u8>>>,
|
||||
deletions_attempted: RwLock<Vec<String>>,
|
||||
refusing_to_delete: bool,
|
||||
}
|
||||
|
||||
impl FakeMediaStorage {
|
||||
pub fn holding_nothing() -> Self {
|
||||
Self {
|
||||
photos: RwLock::new(HashMap::new()),
|
||||
voice_memos: RwLock::new(HashMap::new()),
|
||||
deletions_attempted: RwLock::new(Vec::new()),
|
||||
refusing_to_delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refusing_to_delete() -> Self {
|
||||
Self {
|
||||
refusing_to_delete: true,
|
||||
..Self::holding_nothing()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put_photo(&self, id: &PhotoId, bytes: &[u8]) {
|
||||
self.photos
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(id.clone(), bytes.to_vec());
|
||||
}
|
||||
|
||||
pub fn put_voice_memo(&self, id: &VoiceMemoId, bytes: &[u8]) {
|
||||
self.voice_memos
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(id.clone(), bytes.to_vec());
|
||||
}
|
||||
|
||||
pub fn holds_photo(&self, id: &PhotoId) -> bool {
|
||||
self.photos.read().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
pub fn holds_voice_memo(&self, id: &VoiceMemoId) -> bool {
|
||||
self.voice_memos.read().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
pub fn blobs_held(&self) -> usize {
|
||||
self.photos.read().unwrap().len() + self.voice_memos.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn deletions_attempted(&self) -> Vec<String> {
|
||||
self.deletions_attempted.read().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FakeMediaStorage {
|
||||
fn default() -> Self {
|
||||
Self::holding_nothing()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::MediaStoragePort for FakeMediaStorage {
|
||||
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError> {
|
||||
let id = PhotoId::generate();
|
||||
self.put_photo(&id, upload.data());
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
|
||||
let id = VoiceMemoId::generate();
|
||||
self.put_voice_memo(&id, upload.data());
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
Ok(self.photos.read().unwrap().get(id).map(|bytes| MediaFile {
|
||||
data: bytes.clone(),
|
||||
content_type: ContentType::from_persistence("image/jpeg".into()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
Ok(self
|
||||
.voice_memos
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|bytes| MediaFile {
|
||||
data: bytes.clone(),
|
||||
content_type: ContentType::from_persistence("audio/webm".into()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError> {
|
||||
self.deletions_attempted
|
||||
.write()
|
||||
.unwrap()
|
||||
.push(id.value().to_string());
|
||||
|
||||
if self.refusing_to_delete {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"the blob store refused to delete this photo".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.photos.write().unwrap().remove(id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError> {
|
||||
self.deletions_attempted
|
||||
.write()
|
||||
.unwrap()
|
||||
.push(id.value().to_string());
|
||||
|
||||
if self.refusing_to_delete {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"the blob store refused to delete this voice memo".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.voice_memos.write().unwrap().remove(id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,31 @@
|
||||
mod api_token_secret;
|
||||
mod cipher;
|
||||
mod dimension_store;
|
||||
mod media_storage;
|
||||
mod music;
|
||||
mod password_hasher;
|
||||
mod refusing;
|
||||
mod store;
|
||||
mod store_activity;
|
||||
mod store_api_token;
|
||||
mod store_auth;
|
||||
mod store_cycle;
|
||||
mod store_entry;
|
||||
mod store_infra;
|
||||
mod store_job;
|
||||
mod store_metric;
|
||||
mod store_provider;
|
||||
mod store_rejection;
|
||||
mod store_reminder;
|
||||
mod store_user;
|
||||
mod weather;
|
||||
|
||||
pub use api_token_secret::FakeApiTokenSecret;
|
||||
pub use cipher::FakeCredentialCipher;
|
||||
pub use dimension_store::InMemoryDimensionStore;
|
||||
pub use media_storage::FakeMediaStorage;
|
||||
pub use music::{FakeNowPlaying, FakeRecordingLookup};
|
||||
pub use password_hasher::FakePasswordHasher;
|
||||
pub use refusing::{OneTokenStore, RefusingApiTokenStore, RefusingRejectionTrace};
|
||||
pub use store::InMemoryStore;
|
||||
pub use weather::FakeWeatherLookup;
|
||||
|
||||
90
crates/domain/src/testing/fakes/music.rs
Normal file
90
crates/domain/src/testing/fakes/music.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::ports::{NowPlayingPort, RecordingLookupPort};
|
||||
use crate::song::{RecordingId, Song};
|
||||
|
||||
pub struct FakeNowPlaying {
|
||||
provider: String,
|
||||
playing: Option<Song>,
|
||||
failing_with: Option<String>,
|
||||
}
|
||||
|
||||
impl FakeNowPlaying {
|
||||
pub fn playing(provider: &str, song: Song) -> Self {
|
||||
Self {
|
||||
provider: provider.to_string(),
|
||||
playing: Some(song),
|
||||
failing_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn silent(provider: &str) -> Self {
|
||||
Self {
|
||||
provider: provider.to_string(),
|
||||
playing: None,
|
||||
failing_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failing(provider: &str, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider: provider.to_string(),
|
||||
playing: None,
|
||||
failing_with: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NowPlayingPort for FakeNowPlaying {
|
||||
fn provider(&self) -> &str {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
async fn now_playing(&self, _credential: &[u8]) -> Result<Option<Song>, DomainError> {
|
||||
if let Some(reason) = &self.failing_with {
|
||||
return Err(DomainError::InvalidInput(reason.clone()));
|
||||
}
|
||||
|
||||
Ok(self.playing.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FakeRecordingLookup {
|
||||
found: Option<RecordingId>,
|
||||
failing_with: Option<String>,
|
||||
}
|
||||
|
||||
impl FakeRecordingLookup {
|
||||
pub fn finding(found: Option<RecordingId>) -> Self {
|
||||
Self {
|
||||
found,
|
||||
failing_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finding_nothing() -> Self {
|
||||
Self::finding(None)
|
||||
}
|
||||
|
||||
pub fn failing(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
found: None,
|
||||
failing_with: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecordingLookupPort for FakeRecordingLookup {
|
||||
async fn find_recording(
|
||||
&self,
|
||||
_title: &str,
|
||||
_artist: &str,
|
||||
) -> Result<Option<RecordingId>, DomainError> {
|
||||
if let Some(reason) = &self.failing_with {
|
||||
return Err(DomainError::InvalidInput(reason.clone()));
|
||||
}
|
||||
|
||||
Ok(self.found.clone())
|
||||
}
|
||||
}
|
||||
55
crates/domain/src/testing/fakes/refusing.rs
Normal file
55
crates/domain/src/testing/fakes/refusing.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use crate::api_token::{ApiToken, ApiTokenId, TokenDigest};
|
||||
use crate::errors::DomainError;
|
||||
use crate::rejection::RejectedMetric;
|
||||
use crate::user::UserId;
|
||||
|
||||
pub struct RefusingRejectionTrace;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::RejectionCommandPort for RefusingRejectionTrace {
|
||||
async fn record(&self, _rejections: &[RejectedMetric]) -> Result<(), DomainError> {
|
||||
Err(DomainError::InvalidInput(
|
||||
"the rejection trace is unwritable".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RefusingApiTokenStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ApiTokenCommandPort for RefusingApiTokenStore {
|
||||
async fn save(&self, _token: &ApiToken) -> Result<(), DomainError> {
|
||||
Err(DomainError::InvalidInput("cannot write a token".into()))
|
||||
}
|
||||
|
||||
async fn revoke(&self, _user_id: &UserId, _id: &ApiTokenId) -> Result<(), DomainError> {
|
||||
Err(DomainError::InvalidInput("cannot revoke a token".into()))
|
||||
}
|
||||
|
||||
async fn mark_used(&self, _id: &ApiTokenId) -> Result<(), DomainError> {
|
||||
Err(DomainError::InvalidInput(
|
||||
"cannot record that a token was used".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OneTokenStore(pub ApiToken);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ApiTokenQueryPort for OneTokenStore {
|
||||
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
|
||||
if self.0.digest() == digest {
|
||||
return Ok(Some(self.0.clone()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> {
|
||||
if self.0.user_id() == user_id {
|
||||
return Ok(vec![self.0.clone()]);
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user