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

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

View File

@@ -12,3 +12,9 @@ uuid.workspace = true
serde.workspace = true
jsonwebtoken.workspace = true
argon2.workspace = true
rand.workspace = true
base64.workspace = true
sha2.workspace = true
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -0,0 +1,31 @@
use base64::Engine;
use rand::RngCore;
use sha2::{Digest, Sha256};
use domain::api_token::TokenDigest;
const SECRET_PREFIX: &str = "kmood_";
const SECRET_BYTES: usize = 32;
pub struct ApiTokenSecret;
impl domain::ports::ApiTokenSecretPort for ApiTokenSecret {
fn mint(&self) -> String {
let mut bytes = [0u8; SECRET_BYTES];
rand::rng().fill_bytes(&mut bytes);
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
format!("{SECRET_PREFIX}{body}")
}
fn digest(&self, secret: &str) -> TokenDigest {
let digested = Sha256::digest(secret.as_bytes());
TokenDigest::from_persistence(hex(&digested))
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

View File

@@ -1,5 +1,7 @@
mod api_token_secret;
mod jwt_service;
mod password_hasher;
pub use api_token_secret::ApiTokenSecret;
pub use jwt_service::JwtAuthService;
pub use password_hasher::Argon2PasswordHasher;

View File

@@ -0,0 +1,62 @@
use domain::ports::ApiTokenSecretPort;
use auth::ApiTokenSecret;
const HEX_CHARACTERS_IN_A_SHA256: usize = 64;
#[test]
fn a_minted_secret_announces_itself_so_a_scanner_can_spot_it() {
let minted = ApiTokenSecret.mint();
assert!(minted.starts_with("kmood_"), "got {minted}");
assert!(minted.len() > 40, "a secret needs real entropy: {minted}");
}
#[test]
fn no_two_secrets_are_alike() {
let secrets: Vec<String> = (0..50).map(|_| ApiTokenSecret.mint()).collect();
let mut unique = secrets.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), secrets.len());
}
#[test]
fn the_same_secret_always_digests_the_same_way() {
let secret = ApiTokenSecret.mint();
assert_eq!(
ApiTokenSecret.digest(&secret).value(),
ApiTokenSecret.digest(&secret).value()
);
}
#[test]
fn different_secrets_digest_differently() {
let first = ApiTokenSecret.digest("kmood_one");
let second = ApiTokenSecret.digest("kmood_two");
assert_ne!(first.value(), second.value());
}
#[test]
fn a_digest_gives_nothing_of_the_secret_away() {
let secret = ApiTokenSecret.mint();
let digest = ApiTokenSecret.digest(&secret);
assert_eq!(digest.value().len(), HEX_CHARACTERS_IN_A_SHA256);
assert!(!digest.value().contains(&secret));
assert!(!secret.contains(digest.value()));
assert!(digest.value().chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn the_digest_matches_the_published_value_for_a_known_input() {
let digest = ApiTokenSecret.digest("abc");
assert_eq!(
digest.value(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}

View File

@@ -0,0 +1,15 @@
[package]
name = "crypto"
edition.workspace = true
version.workspace = true
[dependencies]
domain.workspace = true
config.workspace = true
async-trait.workspace = true
base64.workspace = true
chacha20poly1305.workspace = true
rand.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,68 @@
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng, rand_core::RngCore};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use domain::errors::DomainError;
use domain::provider::{CredentialCipher, EncryptedCredential};
const KEY_BYTES: usize = 32;
const NONCE_BYTES: usize = 24;
pub struct ChaChaCredentialCipher {
cipher: XChaCha20Poly1305,
}
impl ChaChaCredentialCipher {
pub fn new(base64_key: &str) -> Result<Self, DomainError> {
let key_bytes = base64::engine::general_purpose::STANDARD
.decode(base64_key.trim())
.map_err(|_| {
DomainError::InvalidInput("credential encryption key must be base64".into())
})?;
if key_bytes.len() != KEY_BYTES {
return Err(DomainError::InvalidInput(format!(
"credential encryption key must decode to {KEY_BYTES} bytes, got {}",
key_bytes.len()
)));
}
Ok(Self {
cipher: XChaCha20Poly1305::new(Key::from_slice(&key_bytes)),
})
}
}
impl CredentialCipher for ChaChaCredentialCipher {
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError> {
let mut nonce_bytes = [0u8; NONCE_BYTES];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext)
.map_err(|_| DomainError::InvalidInput("failed to encrypt credential".into()))?;
let mut sealed = nonce_bytes.to_vec();
sealed.extend_from_slice(&ciphertext);
Ok(EncryptedCredential::from_persistence(sealed))
}
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError> {
let sealed = credential.value();
if sealed.len() <= NONCE_BYTES {
return Err(DomainError::InvalidInput(
"stored credential is truncated".into(),
));
}
let (nonce_bytes, ciphertext) = sealed.split_at(NONCE_BYTES);
self.cipher
.decrypt(XNonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| DomainError::InvalidInput("failed to decrypt credential".into()))
}
}

View File

@@ -0,0 +1,3 @@
mod chacha_cipher;
pub use chacha_cipher::ChaChaCredentialCipher;

View File

@@ -0,0 +1,77 @@
use domain::provider::{CredentialCipher, EncryptedCredential};
use crypto::ChaChaCredentialCipher;
const TEST_KEY: &str = "gXqvVQF3v9pT2mKz8sYbN4jHcR7wLdE1uA0iO5tPxZk=";
fn cipher() -> ChaChaCredentialCipher {
ChaChaCredentialCipher::new(TEST_KEY).unwrap()
}
#[test]
fn a_credential_survives_a_round_trip() {
let cipher = cipher();
let secret = br#"{"url":"https://music.example","username":"gabriel","password":"hunter2"}"#;
let encrypted = cipher.encrypt(secret).unwrap();
let decrypted = cipher.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, secret);
}
#[test]
fn the_plaintext_never_appears_in_the_ciphertext() {
let cipher = cipher();
let encrypted = cipher.encrypt(b"hunter2").unwrap();
assert!(
!encrypted
.value()
.windows(7)
.any(|window| window == b"hunter2")
);
}
#[test]
fn encrypting_the_same_secret_twice_gives_different_ciphertext() {
let cipher = cipher();
let first = cipher.encrypt(b"hunter2").unwrap();
let second = cipher.encrypt(b"hunter2").unwrap();
assert_ne!(first.value(), second.value());
}
#[test]
fn a_tampered_credential_is_rejected_rather_than_silently_decrypted() {
let cipher = cipher();
let encrypted = cipher.encrypt(b"hunter2").unwrap();
let mut tampered = encrypted.value().to_vec();
let last = tampered.len() - 1;
tampered[last] ^= 0xff;
let result = cipher.decrypt(&EncryptedCredential::from_persistence(tampered));
assert!(result.is_err());
}
#[test]
fn a_credential_encrypted_under_another_key_is_rejected() {
let other_key = "AAAAVQF3v9pT2mKz8sYbN4jHcR7wLdE1uA0iO5tPxZk=";
let encrypted = cipher().encrypt(b"hunter2").unwrap();
let result = ChaChaCredentialCipher::new(other_key)
.unwrap()
.decrypt(&encrypted);
assert!(result.is_err());
}
#[test]
fn a_key_that_is_not_32_bytes_is_refused_at_construction() {
assert!(ChaChaCredentialCipher::new("dGhpcyBpcyB0b28gc2hvcnQ=").is_err());
assert!(ChaChaCredentialCipher::new("not-base64!!").is_err());
assert!(ChaChaCredentialCipher::new("").is_err());
}

View File

@@ -5,7 +5,14 @@ version.workspace = true
[dependencies]
domain.workspace = true
api-types.workspace = true
chrono.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
zip.workspace = true
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
chrono.workspace = true

View File

@@ -0,0 +1,185 @@
use std::io::{Cursor, Write};
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
use chrono::Weekday;
use api_types::dimension::DimensionPayload;
use domain::activity::Activity;
use domain::dimension::ComposedEntry;
use domain::errors::DomainError;
use domain::metric::DailyMetric;
use domain::ports::UserBackup;
use domain::reminder::Reminder;
use super::shared::{io_err, json_err, zip_err};
pub const BACKUP_FORMAT_VERSION: u32 = 2;
pub const BACKUP_MANIFEST: &str = "backup.json";
pub struct ZipBackupWriter;
#[async_trait::async_trait]
impl domain::ports::BackupWriterPort for ZipBackupWriter {
async fn write(&self, backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file(BACKUP_MANIFEST, options).map_err(zip_err)?;
zip.write_all(&manifest(backup)?).map_err(io_err)?;
for photo in &backup.media.photos {
zip.start_file(format!("photos/{}", photo.id), options)
.map_err(zip_err)?;
zip.write_all(&photo.data).map_err(io_err)?;
}
for memo in &backup.media.voice_memos {
zip.start_file(format!("voice_memos/{}", memo.id), options)
.map_err(zip_err)?;
zip.write_all(&memo.data).map_err(io_err)?;
}
Ok(zip.finish().map_err(zip_err)?.into_inner())
}
}
fn manifest(backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
let manifest = BackupManifest {
version: BACKUP_FORMAT_VERSION,
entries: backup.entries.iter().map(BackedUpEntry::from).collect(),
metrics: backup.metrics.iter().map(BackedUpMetric::from).collect(),
cycle_starts: backup
.cycle_starts
.iter()
.map(|start| start.date().to_string())
.collect(),
activities: backup
.activities
.iter()
.map(BackedUpActivity::from)
.collect(),
reminders: backup
.reminders
.iter()
.map(BackedUpReminder::from)
.collect(),
tracks_cycle: backup.preferences.tracks_cycle(),
};
serde_json::to_vec_pretty(&manifest).map_err(json_err)
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupManifest {
pub version: u32,
pub entries: Vec<BackedUpEntry>,
pub metrics: Vec<BackedUpMetric>,
pub cycle_starts: Vec<String>,
pub activities: Vec<BackedUpActivity>,
pub reminders: Vec<BackedUpReminder>,
pub tracks_cycle: bool,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackedUpEntry {
pub mood: u8,
pub logged_at: String,
pub dimensions: Vec<DimensionPayload>,
}
impl From<&ComposedEntry> for BackedUpEntry {
fn from(composed: &ComposedEntry) -> Self {
Self {
mood: composed.entry.mood().value(),
logged_at: composed.entry.logged_at().to_rfc3339(),
dimensions: composed
.dimensions
.iter()
.map(DimensionPayload::from)
.collect(),
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackedUpMetric {
pub date: String,
pub kind: String,
pub value: i64,
pub provider: Option<String>,
}
impl From<&DailyMetric> for BackedUpMetric {
fn from(metric: &DailyMetric) -> Self {
Self {
date: metric.date().to_string(),
kind: metric.kind().name().to_string(),
value: metric.value().count(),
provider: metric
.source()
.provider()
.map(|name| name.value().to_string()),
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackedUpActivity {
pub id: String,
pub name: String,
pub category: Option<String>,
pub archived: bool,
}
impl From<&Activity> for BackedUpActivity {
fn from(activity: &Activity) -> Self {
Self {
id: activity.id().value().to_string(),
name: activity.name().value().to_string(),
category: activity.category().map(|c| c.value().to_string()),
archived: activity.is_archived(),
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackedUpReminder {
pub enabled: bool,
pub monday: Option<String>,
pub tuesday: Option<String>,
pub wednesday: Option<String>,
pub thursday: Option<String>,
pub friday: Option<String>,
pub saturday: Option<String>,
pub sunday: Option<String>,
}
impl From<&Reminder> for BackedUpReminder {
fn from(reminder: &Reminder) -> Self {
let at = |day| {
reminder
.schedule()
.time_for(day)
.map(|time| time.format("%H:%M").to_string())
};
Self {
enabled: reminder.is_enabled(),
monday: at(Weekday::Mon),
tuesday: at(Weekday::Tue),
wednesday: at(Weekday::Wed),
thursday: at(Weekday::Thu),
friday: at(Weekday::Fri),
saturday: at(Weekday::Sat),
sunday: at(Weekday::Sun),
}
}
}

View File

@@ -0,0 +1,71 @@
use std::collections::HashMap;
use domain::dimension::ComposedEntry;
use domain::errors::DomainError;
use domain::ports::SharedExtract;
const HEADING: &str = "# Mood journal";
const PREAMBLE: &str = "A shareable extract: mood, what was written, and what was tagged. It deliberately carries \
nothing else — no places, no health readings, no cycle records — and it is not a backup.";
pub struct MarkdownExtractWriter;
#[async_trait::async_trait]
impl domain::ports::ExtractWriterPort for MarkdownExtractWriter {
async fn write(&self, extract: &SharedExtract) -> Result<Vec<u8>, DomainError> {
let names: HashMap<String, String> = extract
.activities
.iter()
.map(|activity| {
(
activity.id().value().to_string(),
activity.name().value().to_string(),
)
})
.collect();
let mut entries: Vec<&ComposedEntry> = extract.entries.iter().collect();
entries.sort_by_key(|composed| *composed.entry.logged_at());
let mut document = format!("{HEADING}\n\n{PREAMBLE}\n");
let mut current_day = String::new();
for composed in entries {
let logged_at = composed.entry.logged_at();
let day = logged_at.format("%A %-d %B %Y").to_string();
if day != current_day {
document.push_str(&format!("\n## {day}\n"));
current_day = day;
}
document.push_str(&format!(
"\n**{}** — {:?}\n",
logged_at.format("%H:%M"),
composed.entry.mood()
));
let tagged = tags(composed, &names);
if !tagged.is_empty() {
document.push_str(&format!("\n_{}_\n", tagged.join(", ")));
}
if let Some(content) = composed.content() {
document.push_str(&format!("\n{}\n", content.value()));
}
}
Ok(document.into_bytes())
}
}
fn tags(composed: &ComposedEntry, names: &HashMap<String, String>) -> Vec<String> {
composed
.activities()
.iter()
.map(|id| {
let id = id.value().to_string();
names.get(&id).cloned().unwrap_or(id)
})
.collect()
}

View File

@@ -1,127 +0,0 @@
use std::io::{Cursor, Write};
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
use domain::errors::DomainError;
use domain::ports::UserExport;
pub struct JsonExportAdapter;
#[async_trait::async_trait]
impl domain::ports::ExportPort for JsonExportAdapter {
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError> {
let buf = Cursor::new(Vec::new());
let mut zip = ZipWriter::new(buf);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let json = build_data_json(data)?;
zip.start_file("data.json", options).map_err(zip_err)?;
zip.write_all(&json).map_err(io_err)?;
for photo in &data.photos {
zip.start_file(format!("photos/{}", photo.id), options)
.map_err(zip_err)?;
zip.write_all(&photo.data).map_err(io_err)?;
}
for memo in &data.voice_memos {
zip.start_file(format!("voice_memos/{}", memo.id), options)
.map_err(zip_err)?;
zip.write_all(&memo.data).map_err(io_err)?;
}
let cursor = zip.finish().map_err(zip_err)?;
Ok(cursor.into_inner())
}
}
fn build_data_json(data: &UserExport) -> Result<Vec<u8>, DomainError> {
let export = ExportData {
version: "1.0",
entries: data.entries.iter().map(EntryExport::from).collect(),
activities: data.activities.iter().map(ActivityExport::from).collect(),
reminder_count: data.reminders.len(),
};
serde_json::to_vec_pretty(&export)
.map_err(|e| DomainError::InvalidInput(format!("json serialization failed: {e}")))
}
fn zip_err(e: zip::result::ZipError) -> DomainError {
DomainError::InvalidInput(format!("zip error: {e}"))
}
fn io_err(e: std::io::Error) -> DomainError {
DomainError::InvalidInput(format!("io error: {e}"))
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ExportData<'a> {
version: &'a str,
entries: Vec<EntryExport>,
activities: Vec<ActivityExport>,
reminder_count: usize,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct EntryExport {
id: String,
mood: u8,
mood_label: String,
logged_at: String,
activities: Vec<String>,
content: Option<String>,
photos: Vec<String>,
voice_memos: Vec<String>,
}
impl From<&domain::entry::MoodEntry> for EntryExport {
fn from(entry: &domain::entry::MoodEntry) -> Self {
Self {
id: entry.id().value().to_string(),
mood: entry.mood().value(),
mood_label: format!("{:?}", entry.mood()),
logged_at: entry.logged_at().to_rfc3339(),
activities: entry
.activities()
.iter()
.map(|a| a.value().to_string())
.collect(),
content: entry.content().map(|c| c.value().to_string()),
photos: entry
.photos()
.iter()
.map(|p| p.value().to_string())
.collect(),
voice_memos: entry
.voice_memos()
.iter()
.map(|v| v.value().to_string())
.collect(),
}
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityExport {
id: String,
name: String,
category: Option<String>,
archived: bool,
}
impl From<&domain::activity::Activity> for ActivityExport {
fn from(activity: &domain::activity::Activity) -> Self {
Self {
id: activity.id().value().to_string(),
name: activity.name().value().to_string(),
category: activity.category().map(|c| c.value().to_string()),
archived: activity.is_archived(),
}
}
}

View File

@@ -1,3 +1,9 @@
mod json_export;
mod backup;
mod extract;
mod shared;
pub use json_export::JsonExportAdapter;
pub use backup::{
BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMetric,
BackedUpReminder, BackupManifest, ZipBackupWriter,
};
pub use extract::MarkdownExtractWriter;

View File

@@ -0,0 +1,13 @@
use domain::errors::DomainError;
pub fn zip_err(error: zip::result::ZipError) -> DomainError {
DomainError::InvalidInput(format!("zip error: {error}"))
}
pub fn io_err(error: std::io::Error) -> DomainError {
DomainError::InvalidInput(format!("io error: {error}"))
}
pub fn json_err(error: serde_json::Error) -> DomainError {
DomainError::InvalidInput(format!("json error: {error}"))
}

View File

@@ -0,0 +1,132 @@
use std::io::Read;
use domain::activity::{Activity, ActivityName};
use domain::dimension::{ComposedEntry, DimensionValue};
use domain::entry::{Content, Mood, MoodEntry};
use domain::location::Coordinates;
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
use domain::ports::{BackupMedia, BackupWriterPort, ExtractWriterPort, SharedExtract, UserBackup};
use domain::song::Song;
use domain::user::{UserId, UserPreferences};
use exporter::{MarkdownExtractWriter, ZipBackupWriter};
fn at(instant: &str) -> chrono::DateTime<chrono::FixedOffset> {
chrono::DateTime::parse_from_rfc3339(instant).unwrap()
}
fn a_revealing_entry(owner: &UserId, exercise: &Activity) -> ComposedEntry {
ComposedEntry {
entry: MoodEntry::new(owner.clone(), Mood::Rad, at("2026-08-20T21:30:00+02:00")),
dimensions: vec![
DimensionValue::Content(Content::new("Long walk by the river").unwrap()),
DimensionValue::activities(vec![exercise.id().clone()]),
DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap()),
DimensionValue::Song(
Song::new("Teardrop", "Massive Attack", Some("Mezzanine".into()), None).unwrap(),
),
],
}
}
async fn extract_of(entries: Vec<ComposedEntry>, activities: Vec<Activity>) -> String {
let bytes = MarkdownExtractWriter
.write(&SharedExtract {
entries,
activities,
})
.await
.unwrap();
String::from_utf8(bytes).unwrap()
}
#[tokio::test]
async fn the_extract_carries_the_journal_a_person_would_want_to_read() {
let owner = UserId::generate();
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
let document = extract_of(vec![a_revealing_entry(&owner, &exercise)], vec![exercise]).await;
assert!(document.contains("Long walk by the river"), "{document}");
assert!(document.contains("Rad"), "{document}");
assert!(
document.contains("long walk"),
"the activity name is missing"
);
assert!(document.contains("Thursday 20 August 2026"), "{document}");
}
#[tokio::test]
async fn the_extract_discloses_no_place_and_no_song() {
let owner = UserId::generate();
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
let document = extract_of(vec![a_revealing_entry(&owner, &exercise)], vec![exercise]).await;
for secret in ["52.2", "21.0", "Massive Attack", "Teardrop", "Mezzanine"] {
assert!(
!document.contains(secret),
"the extract leaked {secret}:\n{document}"
);
}
}
#[tokio::test]
async fn the_extract_says_what_it_is_not() {
let document = extract_of(Vec::new(), Vec::new()).await;
assert!(document.contains("not a backup"), "{document}");
}
#[tokio::test]
async fn a_backup_carries_everything_the_extract_leaves_out() {
let owner = UserId::generate();
let exercise = Activity::new(owner.clone(), ActivityName::new("long walk").unwrap(), None);
let backup = UserBackup {
entries: vec![a_revealing_entry(&owner, &exercise)],
metrics: vec![DailyMetric::new(
owner.clone(),
domain::entry::Date::from_persistence("2026-08-20".parse().unwrap()),
MetricValue::Steps(Steps::new(8_412).unwrap()),
Source::Manual,
)],
cycle_starts: vec![domain::cycle::CycleStartRestore::on(
domain::entry::Date::from_persistence("2026-08-01".parse().unwrap()),
)],
activities: vec![exercise],
reminders: Vec::new(),
preferences: UserPreferences::off_by_default(owner),
media: BackupMedia {
photos: Vec::new(),
voice_memos: Vec::new(),
},
};
let archive = ZipBackupWriter.write(&backup).await.unwrap();
let manifest = manifest_of(&archive);
for expected in [
"Long walk by the river",
"52.2297",
"Massive Attack",
"8412",
"2026-08-01",
"long walk",
] {
assert!(
manifest.contains(expected),
"the backup is missing {expected}"
);
}
}
fn manifest_of(archive: &[u8]) -> String {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
let mut file = zip.by_name("backup.json").expect("a backup has a manifest");
let mut manifest = String::new();
file.read_to_string(&mut manifest).unwrap();
manifest
}

View File

@@ -19,6 +19,12 @@ impl IntoResponse for AuthRejection {
pub struct AuthRejection(String);
impl AuthRejection {
pub fn new(reason: impl Into<String>) -> Self {
Self(reason.into())
}
}
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
@@ -29,7 +35,7 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let token = extract_bearer_token(parts)
let token = bearer_token(parts)
.ok_or_else(|| AuthRejection("missing or invalid authorization header".into()))?;
let user_id = app_state
@@ -42,7 +48,7 @@ where
}
}
fn extract_bearer_token(parts: &Parts) -> Option<String> {
pub fn bearer_token(parts: &Parts) -> Option<String> {
let header = parts.headers.get("authorization")?.to_str().ok()?;
let token = header.strip_prefix("Bearer ")?;
Some(token.to_string())

View File

@@ -0,0 +1,46 @@
use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts;
use domain::provider::ProviderName;
use domain::user::UserId;
use application::api_token::use_cases::authenticate_api_token;
use crate::state::AppState;
use super::authenticated_user::{AuthRejection, bearer_token};
pub struct ImportingProvider {
pub user_id: UserId,
pub provider: ProviderName,
}
impl<S> FromRequestParts<S> for ImportingProvider
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = AuthRejection;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let presented = bearer_token(parts)
.ok_or_else(|| AuthRejection::new("missing or invalid authorization header"))?;
let deps = authenticate_api_token::Deps {
query: app_state.api_token_query,
command: app_state.api_token_command,
secrets: app_state.api_token_secrets,
};
let token = authenticate_api_token::execute(&presented, &deps)
.await
.map_err(|_| AuthRejection::new("importing needs an api token minted in settings"))?;
Ok(Self {
user_id: token.user_id().clone(),
provider: token.name().clone(),
})
}
}

View File

@@ -0,0 +1,53 @@
use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts;
use domain::metric::Source;
use domain::user::UserId;
use application::api_token::use_cases::authenticate_api_token;
use crate::state::AppState;
use super::authenticated_user::{AuthRejection, bearer_token};
pub struct MetricWriter {
pub user_id: UserId,
pub source: Source,
}
impl<S> FromRequestParts<S> for MetricWriter
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = AuthRejection;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let presented = bearer_token(parts)
.ok_or_else(|| AuthRejection::new("missing or invalid authorization header"))?;
if let Ok(user_id) = app_state.auth_service.validate_token(&presented).await {
return Ok(Self {
user_id,
source: Source::Manual,
});
}
let deps = authenticate_api_token::Deps {
query: app_state.api_token_query,
command: app_state.api_token_command,
secrets: app_state.api_token_secrets,
};
let token = authenticate_api_token::execute(&presented, &deps)
.await
.map_err(|_| AuthRejection::new("invalid or expired token"))?;
Ok(Self {
user_id: token.user_id().clone(),
source: Source::Provider(token.name().clone()),
})
}
}

View File

@@ -1,7 +1,11 @@
mod authenticated_user;
pub mod authenticated_user;
mod importing_provider;
mod metric_writer;
mod multipart;
mod path_id;
pub use authenticated_user::AuthenticatedUser;
pub use importing_provider::ImportingProvider;
pub use metric_writer::MetricWriter;
pub use multipart::{extract_file_bytes, extract_media_upload};
pub use path_id::PathId;

View File

@@ -0,0 +1,50 @@
use axum::Json;
use axum::extract::{Query, State};
use api_types::requests::DateSpanParams;
use api_types::responses::CorrelationRowResponse;
use application::correlation::queries::CorrelationQuery;
use application::correlation::use_cases::get_correlations;
use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/correlations", tag = "correlations", security(("bearer" = [])),
description = "Scores every metric kind, every active activity, and the moon as a control \
against the mean mood of each day in the span. Every strategy that fits the \
input is run and all of them are returned; agreement across them is the \
headline, not any single coefficient. Rows come back in a fixed order and are \
never ranked by strength. Below the configured minimum sample size a row \
carries its day count and no coefficient.",
params(DateSpanParams),
responses((status = 200, body = Vec<CorrelationRowResponse>))
)]
pub async fn handle_list(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Query(params): Query<DateSpanParams>,
) -> Result<Json<Vec<CorrelationRowResponse>>, ApiError> {
let span = params.into_span()?;
let deps = get_correlations::Deps {
entries: state.entry_query,
metrics: state.daily_metric_query,
activities: state.activity_query,
cycles: state.cycle_query,
weather_store: state.weather_store,
preferences: state.preferences_query,
users: state.user_query,
};
let query = CorrelationQuery {
user_id,
span,
minimum_sample_size: state.analysis_config.minimum_sample_size,
false_discovery_rate: state.analysis_config.false_discovery_rate,
};
let rows = get_correlations::execute(query, &deps).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}

View File

@@ -0,0 +1,109 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use api_types::mappers::parse_date;
use api_types::requests::SetPreferencesRequest;
use api_types::responses::{CycleViewResponse, PreferencesResponse};
use application::cycle::use_cases::{forget_cycle_start, read_cycle, record_cycle_start};
use application::user::use_cases::set_preferences;
use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/cycle", tag = "cycle", security(("bearer" = [])),
description = "The recorded cycle starts and the cycle day derived for today. Cycle day is \
never stored: correcting a start corrects every day that depended on it. \
Returns nothing at all while cycle tracking is off.",
responses((status = 200, body = CycleViewResponse))
)]
pub async fn handle_read(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<CycleViewResponse>, ApiError> {
let deps = read_cycle::Deps {
query: state.cycle_query,
preferences: state.preferences_query,
users: state.user_query,
};
let view = read_cycle::execute(user_id, &deps).await?;
Ok(Json(view.into()))
}
#[utoipa::path(put, path = "/api/v1/cycle/{date}", tag = "cycle", security(("bearer" = [])),
description = "Records that a cycle began on this date. Recording the same date twice \
records it once.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
responses((status = 204))
)]
pub async fn handle_record(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(date): Path<String>,
) -> Result<StatusCode, ApiError> {
let deps = record_cycle_start::Deps {
command: state.cycle_command,
preferences: state.preferences_query,
};
record_cycle_start::execute(user_id, parse_date(&date)?, &deps).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/api/v1/cycle/{date}", tag = "cycle", security(("bearer" = [])),
description = "Forgets a recorded start. Every day that derived its cycle day from it \
changes at once.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
responses((status = 204))
)]
pub async fn handle_forget(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(date): Path<String>,
) -> Result<StatusCode, ApiError> {
let deps = forget_cycle_start::Deps {
command: state.cycle_command,
};
forget_cycle_start::execute(user_id, parse_date(&date)?, &deps).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(patch, path = "/api/v1/users/me/preferences", tag = "users", security(("bearer" = [])),
description = "Turns optional features on or off. Cycle tracking is off until turned on, \
and turning it off hides the cycle without forgetting what was recorded.",
request_body = SetPreferencesRequest,
responses((status = 200, body = PreferencesResponse))
)]
pub async fn handle_set_preferences(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Json(body): Json<SetPreferencesRequest>,
) -> Result<Json<PreferencesResponse>, ApiError> {
let deps = set_preferences::Deps {
command: state.preferences_command,
query: state.preferences_query,
};
let preferences = set_preferences::execute(user_id, body.tracks_cycle, &deps).await?;
Ok(Json(preferences.into()))
}
#[utoipa::path(get, path = "/api/v1/users/me/preferences", tag = "users", security(("bearer" = [])),
responses((status = 200, body = PreferencesResponse))
)]
pub async fn handle_preferences(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<PreferencesResponse>, ApiError> {
let preferences =
application::user::preferences::preferences_of(&user_id, &state.preferences_query).await?;
Ok(Json(preferences.into()))
}

View File

@@ -0,0 +1,117 @@
use axum::Json;
use axum::extract::{Multipart, State};
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use api_types::responses::RestoreOutcomeResponse;
use application::export::use_cases::{write_backup, write_extract};
use application::restore::commands::RestoreBackupCommand;
use application::restore::use_cases::restore_backup;
use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
use crate::state::AppState;
const BACKUP_FILENAME: &str = "k-mood-complete-backup.zip";
const EXTRACT_FILENAME: &str = "k-mood-shareable-journal.md";
#[utoipa::path(get, path = "/api/v1/data/backup", tag = "data", security(("bearer" = [])),
description = "A complete backup: every entry with every dimension, every daily metric, \
every cycle start, the activity catalogue, reminders, preferences and all \
media. Restores through /data/restore. Keep it private — it holds everything \
the account knows.",
responses((status = 200, description = "A zip archive", content_type = "application/zip"))
)]
pub async fn handle_backup(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let deps = write_backup::Deps {
entries: state.entry_query,
dimensions: state.dimensions,
activities: state.activity_query,
reminders: state.reminder_query,
metrics: state.daily_metric_query,
cycles: state.cycle_query,
preferences: state.preferences_query,
media_storage: state.media_storage,
writer: state.backup_writer,
};
let archive = write_backup::execute(user_id, &deps).await?;
Ok(attachment("application/zip", BACKUP_FILENAME, archive))
}
#[utoipa::path(get, path = "/api/v1/data/extract", tag = "data", security(("bearer" = [])),
description = "A shareable journal: the mood, what was written and what was tagged, as a \
readable markdown document. It carries no places, no health readings, no \
cycle records and no media, and it cannot be restored from. This is the one \
to hand to someone.",
responses((status = 200, description = "A markdown document", content_type = "text/markdown"))
)]
pub async fn handle_extract(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let deps = write_extract::Deps {
entries: state.entry_query,
dimensions: state.dimensions,
activities: state.activity_query,
writer: state.extract_writer,
};
let document = write_extract::execute(user_id, &deps).await?;
Ok(attachment(
"text/markdown; charset=utf-8",
EXTRACT_FILENAME,
document,
))
}
#[utoipa::path(post, path = "/api/v1/data/restore", tag = "data", security(("bearer" = [])),
description = "Restores a complete backup into this account. Existing data is kept: a \
restore adds, it does not replace. Anything in the archive this build cannot \
read is reported rather than silently dropped.",
responses((status = 200, body = RestoreOutcomeResponse))
)]
pub async fn handle_restore(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
multipart: Multipart,
) -> Result<Json<RestoreOutcomeResponse>, ApiError> {
let data = extract_file_bytes(multipart).await?;
let deps = restore_backup::Deps {
reader: state.backup_reader,
entry_command: state.entry_command,
dimensions: state.dimensions,
activity_command: state.activity_command,
activity_query: state.activity_query,
reminder_command: state.reminder_command,
metrics: state.daily_metric_command,
cycles: state.cycle_command,
preferences_command: state.preferences_command,
preferences_query: state.preferences_query,
media_storage: state.media_storage,
};
let outcome = restore_backup::execute(RestoreBackupCommand { user_id, data }, &deps).await?;
Ok(Json(outcome.into()))
}
fn attachment(content_type: &str, filename: &str, body: Vec<u8>) -> impl IntoResponse {
(
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type.to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
body,
)
}

View File

@@ -2,27 +2,43 @@ use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use api_types::mappers::correlation_response;
use api_types::requests::{
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
UpdateEntryRequest,
};
use api_types::responses::{
BulkActionResponse, CalendarDayResponse, CorrelationResponse, EntryResponse, MoodStatsResponse,
BulkActionResponse, CalendarDayResponse, EntryResponse, MoodStatsResponse,
};
use application::entry::composition::EntryComposer;
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery, MoodStatsQuery};
use application::entry::use_cases::{
create_entry, delete_entries_by_date_range, delete_entry, filter_by_activity, filter_by_mood,
get_activity_correlation, get_calendar, get_entry, get_mood_stats, list_entries,
replace_activity, update_entry,
get_calendar, get_entry, get_mood_stats, list_entries, replace_activity, update_entry,
};
use domain::activity::ActivityId;
use domain::entry::MoodEntry;
use domain::entry::{Mood, MoodEntryId};
use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId};
use crate::state::AppState;
async fn compose(
dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>,
entries: Vec<MoodEntry>,
) -> Result<Vec<EntryResponse>, ApiError> {
let composed = EntryComposer::new(dimensions).compose(entries).await?;
Ok(composed.into_iter().map(EntryResponse::from).collect())
}
async fn compose_one(
dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>,
entry: MoodEntry,
) -> Result<EntryResponse, ApiError> {
let mut responses = compose(dimensions, vec![entry]).await?;
Ok(responses.remove(0))
}
#[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
request_body = CreateEntryRequest,
responses((status = 201, body = EntryResponse))
@@ -33,12 +49,17 @@ pub async fn handle_create(
Json(body): Json<CreateEntryRequest>,
) -> Result<(StatusCode, Json<EntryResponse>), ApiError> {
let cmd = body.into_command(user_id, &state.entry_config)?;
let dimensions = state.dimensions.clone();
let deps = create_entry::Deps {
entries: state.entry_command,
dimensions: state.dimensions.clone(),
events: state.event_publisher,
};
let entry = create_entry::execute(cmd, &deps).await?;
Ok((StatusCode::CREATED, Json(EntryResponse::from(entry))))
Ok((
StatusCode::CREATED,
Json(compose_one(dimensions, entry).await?),
))
}
#[utoipa::path(get, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
@@ -50,11 +71,12 @@ pub async fn handle_get(
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(entry_id): PathId<MoodEntryId>,
) -> Result<Json<EntryResponse>, ApiError> {
let dimensions = state.dimensions.clone();
let deps = get_entry::Deps {
query: state.entry_query,
};
let entry = get_entry::execute(entry_id, user_id, &deps).await?;
Ok(Json(EntryResponse::from(entry)))
Ok(Json(compose_one(dimensions, entry).await?))
}
#[utoipa::path(get, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
@@ -67,11 +89,12 @@ pub async fn handle_list(
Query(params): Query<ListEntriesParams>,
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
let query = params.into_query(user_id)?;
let dimensions = state.dimensions.clone();
let deps = list_entries::Deps {
query: state.entry_query,
};
let entries = list_entries::execute(query, &deps).await?;
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
Ok(Json(compose(dimensions, entries).await?))
}
#[utoipa::path(patch, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
@@ -86,14 +109,16 @@ pub async fn handle_update(
Json(body): Json<UpdateEntryRequest>,
) -> Result<Json<EntryResponse>, ApiError> {
let cmd = body.into_command(entry_id, &state.entry_config)?;
let dimensions = state.dimensions.clone();
let deps = update_entry::Deps {
command: state.entry_command,
dimensions: state.dimensions.clone(),
query: state.entry_query,
media_storage: state.media_storage,
events: state.event_publisher,
};
let entry = update_entry::execute(cmd, user_id, &deps).await?;
Ok(Json(EntryResponse::from(entry)))
Ok(Json(compose_one(dimensions, entry).await?))
}
#[utoipa::path(delete, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
@@ -106,6 +131,7 @@ pub async fn handle_delete(
PathId(entry_id): PathId<MoodEntryId>,
) -> Result<StatusCode, ApiError> {
let deps = delete_entry::Deps {
dimensions: state.dimensions.clone(),
command: state.entry_command,
query: state.entry_query,
events: state.event_publisher,
@@ -126,11 +152,12 @@ pub async fn handle_filter_by_mood(
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
let mood = Mood::try_from(mood)?;
let query = FilterByMoodQuery { user_id, mood };
let dimensions = state.dimensions.clone();
let deps = filter_by_mood::Deps {
query: state.entry_query,
};
let entries = filter_by_mood::execute(query, &deps).await?;
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
Ok(Json(compose(dimensions, entries).await?))
}
#[utoipa::path(get, path = "/api/v1/entries/filter/activity/{id}", tag = "entries", security(("bearer" = [])),
@@ -146,11 +173,12 @@ pub async fn handle_filter_by_activity(
user_id,
activity_id,
};
let dimensions = state.dimensions.clone();
let deps = filter_by_activity::Deps {
query: state.entry_query,
};
let entries = filter_by_activity::execute(query, &deps).await?;
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
Ok(Json(compose(dimensions, entries).await?))
}
#[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])),
@@ -172,6 +200,7 @@ pub async fn handle_stats(
};
let query = MoodStatsQuery { user_id, range };
let deps = get_mood_stats::Deps {
users: state.user_query.clone(),
query: state.entry_query,
};
let stats = get_mood_stats::execute(query, &deps).await?;
@@ -189,7 +218,11 @@ pub async fn handle_calendar(
) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> {
let range = params.into_date_range()?;
let deps = get_calendar::Deps {
users: state.user_query.clone(),
query: state.entry_query,
dimensions: state.dimensions.clone(),
cycles: state.cycle_query,
preferences: state.preferences_query,
};
let days = get_calendar::execute(user_id, range, &deps).await?;
Ok(Json(
@@ -197,32 +230,6 @@ pub async fn handle_calendar(
))
}
#[utoipa::path(get, path = "/api/v1/entries/correlation/{id}", tag = "entries", security(("bearer" = [])),
params(("id" = String, Path, description = "Activity ID"), ListEntriesParams),
responses((status = 200, body = CorrelationResponse))
)]
pub async fn handle_activity_correlation(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(activity_id): PathId<ActivityId>,
Query(params): Query<ListEntriesParams>,
) -> Result<Json<CorrelationResponse>, ApiError> {
let range = match (params.from, params.to) {
(Some(from), Some(to)) => {
let from = api_types::mappers::shared::parse_datetime(&from)?;
let to = api_types::mappers::shared::parse_datetime(&to)?;
Some(domain::entry::DateRange::new(from, to)?)
}
_ => None,
};
let deps = get_activity_correlation::Deps {
query: state.entry_query,
};
let correlation =
get_activity_correlation::execute(user_id, activity_id.clone(), range, &deps).await?;
Ok(Json(correlation_response(activity_id, correlation)))
}
#[utoipa::path(delete, path = "/api/v1/entries/bulk/delete", tag = "entries", security(("bearer" = [])),
params(DateRangeParams),
responses((status = 200, body = BulkActionResponse))
@@ -234,6 +241,8 @@ pub async fn handle_delete_by_date_range(
) -> Result<Json<BulkActionResponse>, ApiError> {
let range = params.into_date_range()?;
let deps = delete_entries_by_date_range::Deps {
query: state.entry_query.clone(),
dimensions: state.dimensions.clone(),
cascade: state.cascade,
media_storage: state.media_storage,
};

View File

@@ -1,10 +1,7 @@
use axum::Json;
use axum::extract::{Multipart, State};
use axum::http::header;
use axum::response::IntoResponse;
use api_types::responses::ImportResultResponse;
use application::export::use_cases::export_user_data;
use application::import::commands::ImportCommand;
use application::import::use_cases::import_entries;
@@ -12,34 +9,6 @@ use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/data/export", tag = "data", security(("bearer" = [])),
responses((status = 200, description = "ZIP archive with user data"))
)]
pub async fn handle_export(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let deps = export_user_data::Deps {
entry_query: state.entry_query,
activity_query: state.activity_query,
reminder_query: state.reminder_query,
media_storage: state.media_storage,
exporter: state.export_port.clone(),
};
let data = export_user_data::execute(user_id, &deps).await?;
Ok((
[
(header::CONTENT_TYPE, "application/zip"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"k-mood-export.zip\"",
),
],
data,
))
}
#[utoipa::path(post, path = "/api/v1/data/import", tag = "data", security(("bearer" = [])),
responses((status = 200, body = ImportResultResponse))
)]
@@ -57,6 +26,8 @@ pub async fn handle_import(
entry_query: state.entry_query,
activity_command: state.activity_command,
activity_query: state.activity_query,
dimensions: state.dimensions.clone(),
users: state.user_query,
preset: state.preset_config,
};
let result = import_entries::execute(cmd, &deps).await?;

View File

@@ -0,0 +1,127 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use api_types::mappers::parse_date;
use api_types::requests::{DateSpanParams, ImportDailyMetricsRequest, SetDailyMetricsRequest};
use api_types::responses::{DailyMetricResponse, ImportOutcomeResponse, RejectionResponse};
use application::import::commands::ImportDailyMetricsCommand;
use application::import::use_cases::import_daily_metrics;
use application::metric::commands::SetDailyMetricsCommand;
use application::metric::use_cases::{list_daily_metrics, set_daily_metrics};
use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, ImportingProvider, MetricWriter};
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/metrics", tag = "metrics", security(("bearer" = [])),
params(DateSpanParams),
responses((status = 200, body = Vec<DailyMetricResponse>))
)]
pub async fn handle_list(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Query(params): Query<DateSpanParams>,
) -> Result<Json<Vec<DailyMetricResponse>>, ApiError> {
let span = params.into_span()?;
let deps = list_daily_metrics::Deps {
metrics: state.daily_metric_query,
};
let metrics = list_daily_metrics::execute(user_id, span, &deps).await?;
Ok(Json(metrics.into_iter().map(Into::into).collect()))
}
#[utoipa::path(put, path = "/api/v1/metrics/{date}", tag = "metrics", security(("bearer" = [])),
description = "States the given metrics for one day. A null value clears that kind instead, \
after which a later provider import may report it again. Every kind may appear \
only once per request.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
request_body = SetDailyMetricsRequest,
responses((status = 204))
)]
pub async fn handle_set(
State(state): State<AppState>,
writer: MetricWriter,
Path(date): Path<String>,
Json(body): Json<SetDailyMetricsRequest>,
) -> Result<StatusCode, ApiError> {
let date = parse_date(&date)?;
let changes = body
.metrics
.into_iter()
.map(|payload| payload.into_change())
.collect::<Result<Vec<_>, _>>()?;
let deps = set_daily_metrics::Deps {
metrics: state.daily_metric_command,
users: state.user_query,
};
set_daily_metrics::execute(
SetDailyMetricsCommand {
user_id: writer.user_id,
date,
changes,
source: writer.source,
},
&deps,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/api/v1/metrics/import", tag = "metrics", security(("bearer" = [])),
description = "Accepts a batch of days from an automation, authenticated by an api token and \
nothing else. A payload carrying only some of the eight kinds is normal. Every \
reading is judged on its own: the valid ones are stored and the rest are \
rejected and written to a trace the account holder can read, so one bad value \
never costs a night of good data. Values are never clamped. A reading the user \
has stated by hand is reported as superseding the imported one, which is not a \
rejection. Only a payload carrying more days than the configured limit is \
refused outright.",
request_body = ImportDailyMetricsRequest,
responses((status = 200, body = ImportOutcomeResponse))
)]
pub async fn handle_import(
State(state): State<AppState>,
importer: ImportingProvider,
Json(body): Json<ImportDailyMetricsRequest>,
) -> Result<Json<ImportOutcomeResponse>, ApiError> {
let deps = import_daily_metrics::Deps {
metrics: state.daily_metric_command,
rejections: state.rejection_command,
};
let outcome = import_daily_metrics::execute(
ImportDailyMetricsCommand {
user_id: importer.user_id,
provider: importer.provider,
days: body.into_days(),
maximum_days: state.import_config.maximum_days_per_import,
},
&deps,
)
.await?;
Ok(Json(outcome.into()))
}
#[utoipa::path(get, path = "/api/v1/metrics/rejections", tag = "metrics", security(("bearer" = [])),
description = "Readings that could not be used, most recent first, whether they arrived \
broken from an importer or were stored by an older build and can no longer be \
read. Only the most recent are kept.",
responses((status = 200, body = Vec<RejectionResponse>))
)]
pub async fn handle_rejections(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<Vec<RejectionResponse>>, ApiError> {
let rejections = state.rejection_query.find_recent_by_user(&user_id).await?;
Ok(Json(rejections.into_iter().map(Into::into).collect()))
}

View File

@@ -1,8 +1,14 @@
pub mod activities;
pub mod auth;
pub mod correlations;
pub mod cycle;
pub mod data;
pub mod entries;
pub mod import_export;
pub mod media;
pub mod metrics;
pub mod providers;
pub mod push;
pub mod reminders;
pub mod tokens;
pub mod users;

View File

@@ -0,0 +1,127 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use api_types::dimension::DimensionPayload;
use api_types::requests::ConnectProviderRequest;
use api_types::responses::ProviderConnectionResponse;
use application::provider::commands::ConnectProviderCommand;
use application::provider::use_cases::{
connect_provider, disconnect_provider, get_now_playing, list_connections,
};
use domain::dimension::DimensionValue;
use domain::errors::DomainError;
use domain::provider::ProviderName;
use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser;
use crate::state::AppState;
fn cipher(
state: &AppState,
) -> Result<std::sync::Arc<dyn domain::provider::CredentialCipher>, ApiError> {
state.credential_cipher.clone().ok_or_else(|| {
DomainError::InvalidInput(
"provider connections are unavailable: no credential encryption key is configured"
.into(),
)
.into()
})
}
#[utoipa::path(get, path = "/api/v1/providers", tag = "providers", security(("bearer" = [])),
responses((status = 200, body = Vec<ProviderConnectionResponse>))
)]
pub async fn handle_list(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<Vec<ProviderConnectionResponse>>, ApiError> {
let deps = list_connections::Deps {
query: state.provider_connection_query,
};
let connections = list_connections::execute(user_id, &deps).await?;
Ok(Json(connections.into_iter().map(Into::into).collect()))
}
#[utoipa::path(put, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])),
params(("provider" = String, Path, description = "Provider name")),
request_body = ConnectProviderRequest,
responses((status = 204))
)]
pub async fn handle_connect(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(provider): Path<String>,
Json(body): Json<ConnectProviderRequest>,
) -> Result<StatusCode, ApiError> {
let cipher = cipher(&state)?;
let provider = ProviderName::new(provider)?;
let credential = serde_json::to_vec(&body.credential)
.map_err(|_| DomainError::InvalidInput("credential must be a JSON object".into()))?;
let deps = connect_provider::Deps {
command: state.provider_connection_command,
cipher,
};
connect_provider::execute(
ConnectProviderCommand {
user_id,
provider,
credential,
},
&deps,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])),
params(("provider" = String, Path, description = "Provider name")),
responses((status = 204))
)]
pub async fn handle_disconnect(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(provider): Path<String>,
) -> Result<StatusCode, ApiError> {
let provider = ProviderName::new(provider)?;
let deps = disconnect_provider::Deps {
command: state.provider_connection_command,
};
disconnect_provider::execute(user_id, provider, &deps).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(get, path = "/api/v1/providers/now-playing", tag = "providers", security(("bearer" = [])),
responses((status = 200, body = Option<DimensionPayload>))
)]
pub async fn handle_now_playing(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<Option<DimensionPayload>>, ApiError> {
let cipher = cipher(&state)?;
let now_playing = state.now_playing.clone().ok_or_else(|| -> ApiError {
DomainError::InvalidInput("no music provider is configured".into()).into()
})?;
let deps = get_now_playing::Deps {
query: state.provider_connection_query,
cipher,
now_playing,
recordings: state.recording_lookup,
};
let song = get_now_playing::execute(user_id, &deps).await?;
Ok(Json(song.map(|song| {
DimensionPayload::from(&DimensionValue::Song(song))
})))
}

View File

@@ -0,0 +1,80 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use api_types::requests::MintApiTokenRequest;
use api_types::responses::{ApiTokenResponse, MintedApiTokenResponse};
use application::api_token::commands::MintApiTokenCommand;
use application::api_token::use_cases::{list_api_tokens, mint_api_token, revoke_api_token};
use domain::api_token::ApiTokenId;
use domain::provider::ProviderName;
use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId};
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/tokens", tag = "tokens", security(("bearer" = [])),
description = "Lists this account's api tokens. Values are never returned; only the name, \
when it was minted, and when it was last used.",
responses((status = 200, body = Vec<ApiTokenResponse>))
)]
pub async fn handle_list(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<Vec<ApiTokenResponse>>, ApiError> {
let deps = list_api_tokens::Deps {
query: state.api_token_query,
};
let tokens = list_api_tokens::execute(user_id, &deps).await?;
Ok(Json(tokens.into_iter().map(Into::into).collect()))
}
#[utoipa::path(post, path = "/api/v1/tokens", tag = "tokens", security(("bearer" = [])),
description = "Mints a token for writing daily metrics. The value comes back once and is \
never retrievable again. The name becomes the Provider that the token's \
writes are attributed to, so it must be lowercase letters, digits and hyphens.",
request_body = MintApiTokenRequest,
responses((status = 201, body = MintedApiTokenResponse))
)]
pub async fn handle_mint(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Json(body): Json<MintApiTokenRequest>,
) -> Result<(StatusCode, Json<MintedApiTokenResponse>), ApiError> {
let deps = mint_api_token::Deps {
command: state.api_token_command,
secrets: state.api_token_secrets,
};
let minted = mint_api_token::execute(
MintApiTokenCommand {
user_id,
name: ProviderName::new(body.name)?,
},
&deps,
)
.await?;
Ok((StatusCode::CREATED, Json(minted.into())))
}
#[utoipa::path(delete, path = "/api/v1/tokens/{id}", tag = "tokens", security(("bearer" = [])),
description = "Revokes a token. It stops working at once.",
params(("id" = String, Path, description = "Token ID")),
responses((status = 204))
)]
pub async fn handle_revoke(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(token_id): PathId<ApiTokenId>,
) -> Result<StatusCode, ApiError> {
let deps = revoke_api_token::Deps {
command: state.api_token_command,
};
revoke_api_token::execute(user_id, token_id, &deps).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -97,6 +97,7 @@ pub async fn handle_delete(
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<StatusCode, ApiError> {
let deps = delete_user::Deps {
dimensions: state.dimensions.clone(),
user_query: state.user_query,
entry_query: state.entry_query,
cascade: state.cascade,
@@ -115,6 +116,7 @@ pub async fn handle_clear_data(
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<StatusCode, ApiError> {
let deps = clear_data::Deps {
dimensions: state.dimensions.clone(),
entry_query: state.entry_query,
cascade: state.cascade,
media_storage: state.media_storage,

View File

@@ -10,6 +10,10 @@ use utoipa::{Modify, OpenApi};
),
modifiers(&SecurityAddon),
paths(
crate::handlers::providers::handle_list,
crate::handlers::providers::handle_connect,
crate::handlers::providers::handle_disconnect,
crate::handlers::providers::handle_now_playing,
crate::handlers::auth::handle_login,
crate::handlers::auth::handle_refresh,
crate::handlers::auth::handle_logout,
@@ -22,7 +26,6 @@ use utoipa::{Modify, OpenApi};
crate::handlers::entries::handle_filter_by_activity,
crate::handlers::entries::handle_stats,
crate::handlers::entries::handle_calendar,
crate::handlers::entries::handle_activity_correlation,
crate::handlers::entries::handle_delete_by_date_range,
crate::handlers::entries::handle_replace_activity,
crate::handlers::activities::handle_create,
@@ -50,12 +53,27 @@ use utoipa::{Modify, OpenApi};
crate::handlers::media::handle_serve_voice_memo,
crate::handlers::media::handle_delete_photo,
crate::handlers::media::handle_delete_voice_memo,
crate::handlers::import_export::handle_export,
crate::handlers::data::handle_backup,
crate::handlers::data::handle_extract,
crate::handlers::data::handle_restore,
crate::handlers::import_export::handle_import,
crate::handlers::push::handle_vapid_key,
crate::handlers::push::handle_subscribe,
crate::handlers::push::handle_unsubscribe,
crate::handlers::push::handle_test,
crate::handlers::correlations::handle_list,
crate::handlers::cycle::handle_read,
crate::handlers::cycle::handle_record,
crate::handlers::cycle::handle_forget,
crate::handlers::cycle::handle_preferences,
crate::handlers::cycle::handle_set_preferences,
crate::handlers::tokens::handle_list,
crate::handlers::tokens::handle_mint,
crate::handlers::tokens::handle_revoke,
crate::handlers::metrics::handle_list,
crate::handlers::metrics::handle_import,
crate::handlers::metrics::handle_rejections,
crate::handlers::metrics::handle_set,
),
components(schemas(
api_types::requests::CreateEntryRequest,
@@ -83,9 +101,30 @@ use utoipa::{Modify, OpenApi};
api_types::responses::MoodFrequency,
api_types::responses::CalendarDayResponse,
api_types::responses::BulkActionResponse,
api_types::responses::CorrelationResponse,
api_types::responses::ImportResultResponse,
api_types::responses::RestoreOutcomeResponse,
api_types::responses::MediaIdResponse,
api_types::requests::SetDailyMetricsRequest,
api_types::requests::MetricPayload,
api_types::requests::DateSpanParams,
api_types::responses::DailyMetricResponse,
api_types::requests::MintApiTokenRequest,
api_types::requests::SetPreferencesRequest,
api_types::responses::CycleViewResponse,
api_types::responses::CyclePositionResponse,
api_types::responses::PreferencesResponse,
api_types::requests::ImportDailyMetricsRequest,
api_types::requests::ImportedDayPayload,
api_types::requests::ImportedMetricPayload,
api_types::responses::ImportOutcomeResponse,
api_types::responses::RejectedMetricResponse,
api_types::responses::RejectionResponse,
api_types::responses::ApiTokenResponse,
api_types::responses::MintedApiTokenResponse,
api_types::responses::CorrelationRowResponse,
api_types::responses::CorrelationInputResponse,
api_types::responses::AgreementResponse,
api_types::responses::StrategyScoreResponse,
api_types::requests::PushSubscribeRequest,
api_types::requests::PushUnsubscribeRequest,
)),
@@ -98,6 +137,10 @@ use utoipa::{Modify, OpenApi};
(name = "media", description = "Photo and voice memo storage"),
(name = "data", description = "Import and export"),
(name = "push", description = "Push notifications"),
(name = "metrics", description = "Daily metrics"),
(name = "correlations", description = "Correlation between metrics and mood"),
(name = "tokens", description = "API tokens for headless importers"),
(name = "cycle", description = "Menstrual cycle starts and derived cycle day"),
)
)]
pub struct ApiDoc;

View File

@@ -1,13 +1,16 @@
use axum::extract::DefaultBodyLimit;
use axum::http::HeaderValue;
use axum::routing::{delete, get, patch, post};
use axum::routing::{delete, get, patch, post, put};
use axum::{Json, Router};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use crate::handlers::{activities, auth, entries, import_export, media, push, reminders, users};
use crate::handlers::{
activities, auth, correlations, cycle, data, entries, import_export, media, metrics, providers,
push, reminders, tokens, users,
};
use crate::openapi::ApiDoc;
use crate::state::AppState;
@@ -62,10 +65,50 @@ fn api_routes() -> Router<AppState> {
.nest("/users", user_routes())
.nest("/reminders", reminder_routes())
.nest("/media", media_routes())
.nest("/metrics", metric_routes())
.nest("/correlations", correlation_routes())
.nest("/tokens", token_routes())
.nest("/cycle", cycle_routes())
.nest("/providers", provider_routes())
.nest("/push", push_routes())
.nest("/data", data_routes())
}
fn cycle_routes() -> Router<AppState> {
Router::new().route("/", get(cycle::handle_read)).route(
"/{date}",
put(cycle::handle_record).delete(cycle::handle_forget),
)
}
fn token_routes() -> Router<AppState> {
Router::new()
.route("/", get(tokens::handle_list).post(tokens::handle_mint))
.route("/{id}", delete(tokens::handle_revoke))
}
fn correlation_routes() -> Router<AppState> {
Router::new().route("/", get(correlations::handle_list))
}
fn metric_routes() -> Router<AppState> {
Router::new()
.route("/", get(metrics::handle_list))
.route("/import", post(metrics::handle_import))
.route("/rejections", get(metrics::handle_rejections))
.route("/{date}", put(metrics::handle_set))
}
fn provider_routes() -> Router<AppState> {
Router::new()
.route("/", get(providers::handle_list))
.route("/now-playing", get(providers::handle_now_playing))
.route(
"/{provider}",
put(providers::handle_connect).delete(providers::handle_disconnect),
)
}
fn auth_routes() -> Router<AppState> {
Router::new()
.route("/login", post(auth::handle_login))
@@ -89,10 +132,6 @@ fn entry_routes() -> Router<AppState> {
"/filter/activity/{id}",
get(entries::handle_filter_by_activity),
)
.route(
"/correlation/{id}",
get(entries::handle_activity_correlation),
)
.route("/bulk/delete", delete(entries::handle_delete_by_date_range))
.route(
"/bulk/replace-activity",
@@ -127,6 +166,10 @@ fn user_routes() -> Router<AppState> {
)
.route("/me/password", patch(users::handle_change_password))
.route("/me/data", delete(users::handle_clear_data))
.route(
"/me/preferences",
get(cycle::handle_preferences).patch(cycle::handle_set_preferences),
)
}
fn reminder_routes() -> Router<AppState> {
@@ -167,6 +210,8 @@ fn push_routes() -> Router<AppState> {
fn data_routes() -> Router<AppState> {
Router::new()
.route("/export", get(import_export::handle_export))
.route("/backup", get(data::handle_backup))
.route("/extract", get(data::handle_extract))
.route("/restore", post(data::handle_restore))
.route("/import", post(import_export::handle_import))
}

View File

@@ -1,18 +1,26 @@
use std::sync::Arc;
use config::{AuthConfig, EntryConfig, PresetConfig, PushConfig, ServerConfig};
use config::{
AnalysisConfig, AuthConfig, EntryConfig, ImportConfig, PresetConfig, PushConfig, ServerConfig,
};
use domain::ports::{
ActivityCommandPort, ActivityQueryPort, AuthServicePort, CascadeDeletePort, EventPublisherPort,
ExportPort, ImportSourcePort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
PasswordHasherPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort,
RefreshSessionCommandPort, RefreshSessionQueryPort, ReminderCommandPort, ReminderQueryPort,
ReminderSenderPort, UserCommandPort, UserQueryPort,
ActivityCommandPort, ActivityQueryPort, ApiTokenCommandPort, ApiTokenQueryPort,
ApiTokenSecretPort, AuthServicePort, BackupReaderPort, BackupWriterPort, CascadeDeletePort,
CycleStartCommandPort, CycleStartQueryPort, DailyMetricCommandPort, DailyMetricQueryPort,
EntryDimensionPort, EventPublisherPort, ExtractWriterPort, ImportSourcePort, MediaStoragePort,
MoodEntryCommandPort, MoodEntryQueryPort, PasswordHasherPort, ProviderConnectionCommandPort,
ProviderConnectionQueryPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort,
RefreshSessionCommandPort, RefreshSessionQueryPort, RejectionCommandPort, RejectionQueryPort,
ReminderCommandPort, ReminderQueryPort, ReminderSenderPort, UserCommandPort,
UserPreferencesCommandPort, UserPreferencesQueryPort, UserQueryPort,
};
#[derive(Clone)]
pub struct AppState {
pub entry_command: Arc<dyn MoodEntryCommandPort>,
pub entry_query: Arc<dyn MoodEntryQueryPort>,
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
pub weather_store: Arc<dyn EntryDimensionPort>,
pub activity_command: Arc<dyn ActivityCommandPort>,
pub activity_query: Arc<dyn ActivityQueryPort>,
pub user_command: Arc<dyn UserCommandPort>,
@@ -22,17 +30,37 @@ pub struct AppState {
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
pub refresh_session_query: Arc<dyn RefreshSessionQueryPort>,
pub cascade: Arc<dyn CascadeDeletePort>,
pub daily_metric_command: Arc<dyn DailyMetricCommandPort>,
pub daily_metric_query: Arc<dyn DailyMetricQueryPort>,
pub cycle_command: Arc<dyn CycleStartCommandPort>,
pub cycle_query: Arc<dyn CycleStartQueryPort>,
pub preferences_command: Arc<dyn UserPreferencesCommandPort>,
pub preferences_query: Arc<dyn UserPreferencesQueryPort>,
pub rejection_command: Arc<dyn RejectionCommandPort>,
pub rejection_query: Arc<dyn RejectionQueryPort>,
pub api_token_command: Arc<dyn ApiTokenCommandPort>,
pub api_token_query: Arc<dyn ApiTokenQueryPort>,
pub api_token_secrets: Arc<dyn ApiTokenSecretPort>,
pub auth_service: Arc<dyn AuthServicePort>,
pub password_hasher: Arc<dyn PasswordHasherPort>,
pub event_publisher: Arc<dyn EventPublisherPort>,
pub media_storage: Arc<dyn MediaStoragePort>,
pub export_port: Arc<dyn ExportPort>,
pub backup_writer: Arc<dyn BackupWriterPort>,
pub backup_reader: Arc<dyn BackupReaderPort>,
pub extract_writer: Arc<dyn ExtractWriterPort>,
pub import_source: Arc<dyn ImportSourcePort>,
pub provider_connection_command: Arc<dyn ProviderConnectionCommandPort>,
pub provider_connection_query: Arc<dyn ProviderConnectionQueryPort>,
pub credential_cipher: Option<Arc<dyn domain::provider::CredentialCipher>>,
pub now_playing: Option<Arc<dyn domain::ports::NowPlayingPort>>,
pub recording_lookup: Arc<dyn domain::ports::RecordingLookupPort>,
pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>,
pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>,
pub reminder_sender: Option<Arc<dyn ReminderSenderPort>>,
pub server_config: ServerConfig,
pub entry_config: EntryConfig,
pub analysis_config: AnalysisConfig,
pub import_config: ImportConfig,
pub auth_config: AuthConfig,
pub push_config: PushConfig,
pub preset_config: PresetConfig,

View File

@@ -5,9 +5,14 @@ version.workspace = true
[dependencies]
domain.workspace = true
exporter.workspace = true
api-types.workspace = true
async-trait.workspace = true
csv.workspace = true
serde.workspace = true
serde_json.workspace = true
zip.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,83 @@
use api_types::dimension::DimensionPayload;
use domain::dimension::DimensionValue;
use domain::errors::DomainError;
use domain::ports::{
RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric, RestorableReminder,
};
use super::kmood_backup::KmoodBackupReader;
pub struct KmoodBackupAdapter;
#[async_trait::async_trait]
impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError> {
let read = KmoodBackupReader::read(data)?;
let manifest = read.manifest;
Ok(RestorableContents {
entries: manifest
.entries
.into_iter()
.map(|held| RestorableEntry {
mood: held.mood,
logged_at: held.logged_at,
dimensions: readable_dimensions(held.dimensions),
})
.collect(),
metrics: manifest
.metrics
.into_iter()
.map(|held| RestorableMetric {
date: held.date,
kind: held.kind,
value: held.value,
provider: held.provider,
})
.collect(),
cycle_starts: manifest.cycle_starts,
activities: manifest
.activities
.into_iter()
.map(|held| RestorableActivity {
id: held.id,
name: held.name,
category: held.category,
archived: held.archived,
})
.collect(),
reminders: manifest
.reminders
.into_iter()
.map(|held| RestorableReminder {
enabled: held.enabled,
times: [
held.monday,
held.tuesday,
held.wednesday,
held.thursday,
held.friday,
held.saturday,
held.sunday,
],
})
.collect(),
tracks_cycle: manifest.tracks_cycle,
photos: read.photos.into_iter().collect(),
voice_memos: read.voice_memos.into_iter().collect(),
})
}
}
fn readable_dimensions(payloads: Vec<DimensionPayload>) -> Vec<DimensionValue> {
payloads
.into_iter()
.filter_map(|payload| match payload.into_dimension() {
Ok(dimension) => Some(dimension),
Err(error) => {
tracing::warn!(%error, "a backed-up dimension could not be read");
None
}
})
.collect()
}

View File

@@ -1,60 +1,100 @@
use std::collections::HashMap;
use domain::errors::DomainError;
use domain::ports::ImportedRow;
const DATE: &str = "full_date";
const TIME: &str = "time";
const MOOD: &str = "mood";
const ACTIVITIES: &str = "activities";
const NOTE: &str = "note";
const NOTE_TITLE: &str = "note_title";
pub struct DaylioImportAdapter;
#[async_trait::async_trait]
impl domain::ports::ImportSourcePort for DaylioImportAdapter {
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
let content = std::str::from_utf8(data)
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
.map_err(|error| DomainError::InvalidInput(format!("invalid UTF-8: {error}")))?;
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(content.as_bytes());
let columns = columns_of(&mut reader)?;
let mut rows = Vec::new();
for result in reader.records() {
let record =
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
let record = result
.map_err(|error| DomainError::InvalidInput(format!("CSV parse error: {error}")))?;
let mood_str = record.get(4).unwrap_or("").trim();
let mood = map_daylio_mood(mood_str)?;
let date = record.get(0).unwrap_or("").trim().to_string();
let time = record.get(3).unwrap_or("").trim().to_string();
let activities_str = record.get(5).unwrap_or("").trim();
let activities = if activities_str.is_empty() {
Vec::new()
} else {
activities_str
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
let read = |name: &str| {
columns
.get(name)
.and_then(|at| record.get(*at))
.unwrap_or("")
.trim()
};
let note = record
.get(7)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
rows.push(ImportedRow {
mood,
date,
time,
activities,
note,
mood: map_daylio_mood(read(MOOD))?,
date: read(DATE).to_string(),
time: read(TIME).to_string(),
activities: split_activities(read(ACTIVITIES)),
note: whatever_was_written(read(NOTE_TITLE), read(NOTE)),
});
}
tracing::info!(row_count = rows.len(), "parsed Daylio export");
Ok(rows)
}
}
fn columns_of<R: std::io::Read>(
reader: &mut csv::Reader<R>,
) -> Result<HashMap<String, usize>, DomainError> {
let headers = reader
.headers()
.map_err(|error| DomainError::InvalidInput(format!("CSV has no header row: {error}")))?;
let columns: HashMap<String, usize> = headers
.iter()
.enumerate()
.map(|(at, name)| (name.trim().to_lowercase(), at))
.collect();
for required in [DATE, TIME, MOOD] {
if !columns.contains_key(required) {
return Err(DomainError::InvalidInput(format!(
"this does not look like a Daylio export: no {required} column"
)));
}
}
Ok(columns)
}
fn split_activities(written: &str) -> Vec<String> {
written
.split('|')
.map(|activity| activity.trim().to_string())
.filter(|activity| !activity.is_empty())
.collect()
}
fn whatever_was_written(title: &str, note: &str) -> Option<String> {
let written = [title, note]
.iter()
.filter(|part| !part.is_empty())
.copied()
.collect::<Vec<&str>>()
.join("\n\n");
Some(written).filter(|written| !written.is_empty())
}
fn map_daylio_mood(mood: &str) -> Result<u8, DomainError> {
match mood.to_lowercase().as_str() {
"awful" => Ok(1),

View File

@@ -0,0 +1,99 @@
use std::collections::HashMap;
use std::io::{Cursor, Read};
use zip::ZipArchive;
use domain::errors::DomainError;
use exporter::{BACKUP_MANIFEST, BackupManifest};
pub struct RestorableBackup {
pub manifest: BackupManifest,
pub photos: HashMap<String, Vec<u8>>,
pub voice_memos: HashMap<String, Vec<u8>>,
}
pub struct KmoodBackupReader;
impl KmoodBackupReader {
pub fn read(data: &[u8]) -> Result<RestorableBackup, DomainError> {
let mut archive = ZipArchive::new(Cursor::new(data))
.map_err(|error| DomainError::InvalidInput(format!("not a zip archive: {error}")))?;
let manifest = read_named(&mut archive, BACKUP_MANIFEST)?.ok_or_else(|| {
DomainError::InvalidInput(
"this archive has no backup.json, so it is not a k-mood backup".into(),
)
})?;
let manifest: BackupManifest = serde_json::from_slice(&manifest).map_err(|error| {
DomainError::InvalidInput(format!("this backup cannot be read: {error}"))
})?;
let (photos, voice_memos) = read_media(&mut archive)?;
tracing::info!(
version = manifest.version,
entries = manifest.entries.len(),
metrics = manifest.metrics.len(),
"read a k-mood backup"
);
Ok(RestorableBackup {
manifest,
photos,
voice_memos,
})
}
}
type Media = (HashMap<String, Vec<u8>>, HashMap<String, Vec<u8>>);
fn read_media(archive: &mut ZipArchive<Cursor<&[u8]>>) -> Result<Media, DomainError> {
let mut photos = HashMap::new();
let mut voice_memos = HashMap::new();
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|error| DomainError::InvalidInput(format!("zip read error: {error}")))?;
let name = file.name().to_string();
let destination = match (
name.strip_prefix("photos/"),
name.strip_prefix("voice_memos/"),
) {
(Some(id), _) if !id.is_empty() => (&mut photos, id.to_string()),
(_, Some(id)) if !id.is_empty() => (&mut voice_memos, id.to_string()),
_ => continue,
};
let (into, id) = destination;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|error| {
DomainError::InvalidInput(format!("could not read {name}: {error}"))
})?;
into.insert(id, bytes);
}
Ok((photos, voice_memos))
}
fn read_named(
archive: &mut ZipArchive<Cursor<&[u8]>>,
name: &str,
) -> Result<Option<Vec<u8>>, DomainError> {
let mut file = match archive.by_name(name) {
Ok(file) => file,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(error) => return Err(DomainError::InvalidInput(format!("zip error: {error}"))),
};
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| DomainError::InvalidInput(format!("could not read {name}: {error}")))?;
Ok(Some(bytes))
}

View File

@@ -1,7 +1,11 @@
mod backup_reader;
mod csv_generic;
mod daylio;
mod kmood_backup;
mod kmood_zip;
pub use backup_reader::KmoodBackupAdapter;
pub use csv_generic::{CsvImportAdapter, CsvImportConfig};
pub use daylio::DaylioImportAdapter;
pub use kmood_backup::{KmoodBackupReader, RestorableBackup};
pub use kmood_zip::{KmoodImportEntry, KmoodImportResult, KmoodZipImportAdapter};

View File

@@ -0,0 +1,98 @@
use domain::ports::ImportSourcePort;
use importer::DaylioImportAdapter;
const CURRENT_EXPORT: &str = "full_date,date,weekday,time,mood,activities,scales,note_title,note\n\
2026-08-25,25 Aug,Tuesday,8:00 PM,meh,,,\"\",\"\"\n\
2026-08-24,24 Aug,Monday,8:00 PM,rad,\"friends | walk\",,\"\",\"I KISSED OLA\"\n";
const OLDER_EXPORT_WITHOUT_SCALES: &str = "full_date,date,weekday,time,mood,activities,note_title,note\n\
2026-08-24,24 Aug,Monday,8:00 PM,good,walk,\"A title\",\"A note\"\n";
const COLUMNS_IN_A_DIFFERENT_ORDER: &str = "note,mood,time,full_date,activities\n\
\"reordered\",bad,9:15 PM,2026-08-23,reading\n";
async fn read(csv: &str) -> Vec<domain::ports::ImportedRow> {
DaylioImportAdapter
.read_entries(csv.as_bytes())
.await
.unwrap()
}
#[tokio::test]
async fn the_note_is_read_from_the_note_column_not_the_title() {
let rows = read(CURRENT_EXPORT).await;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].note, None, "an empty note is no note");
assert_eq!(
rows[1].note.as_deref(),
Some("I KISSED OLA"),
"the note column sits after note_title, and it is the one worth keeping"
);
}
#[tokio::test]
async fn a_title_and_a_note_are_both_kept() {
let rows = read(OLDER_EXPORT_WITHOUT_SCALES).await;
assert_eq!(
rows[0].note.as_deref(),
Some("A title\n\nA note"),
"a Daylio note can have a title, and losing either is losing writing"
);
}
#[tokio::test]
async fn an_export_without_the_scales_column_still_reads() {
let rows = read(OLDER_EXPORT_WITHOUT_SCALES).await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].mood, 4);
assert_eq!(rows[0].date, "2026-08-24");
assert_eq!(rows[0].time, "8:00 PM");
assert_eq!(rows[0].activities, ["walk"]);
}
#[tokio::test]
async fn columns_are_found_by_name_rather_than_by_position() {
let rows = read(COLUMNS_IN_A_DIFFERENT_ORDER).await;
assert_eq!(rows[0].mood, 2);
assert_eq!(rows[0].date, "2026-08-23");
assert_eq!(rows[0].time, "9:15 PM");
assert_eq!(rows[0].note.as_deref(), Some("reordered"));
assert_eq!(rows[0].activities, ["reading"]);
}
#[tokio::test]
async fn activities_are_split_on_the_pipe_and_trimmed() {
let rows = read(CURRENT_EXPORT).await;
assert_eq!(rows[1].activities, ["friends", "walk"]);
}
#[tokio::test]
async fn a_file_that_is_not_a_daylio_export_is_refused_by_name() {
let refused = DaylioImportAdapter
.read_entries(b"when,how_i_felt\n2026-08-25,fine\n")
.await
.unwrap_err();
assert!(
refused
.to_string()
.contains("does not look like a Daylio export"),
"a file with none of the columns is refused for that reason, not for a bad mood: {refused}"
);
}
#[tokio::test]
async fn a_mood_daylio_never_writes_is_refused() {
let refused = DaylioImportAdapter
.read_entries(b"full_date,time,mood\n2026-08-25,8:00 PM,ecstatic\n")
.await
.unwrap_err();
assert!(refused.to_string().contains("ecstatic"));
}

View File

@@ -0,0 +1,17 @@
[package]
name = "music"
edition.workspace = true
version.workspace = true
[dependencies]
domain.workspace = true
async-trait.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
md-5.workspace = true
rand.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,5 @@
pub mod musicbrainz;
pub mod subsonic;
pub use musicbrainz::MusicBrainzRecordingLookup;
pub use subsonic::SubsonicNowPlayingAdapter;

View File

@@ -0,0 +1,62 @@
use domain::errors::DomainError;
use domain::ports::RecordingLookupPort;
use domain::song::RecordingId;
const SEARCH_URL: &str = "https://musicbrainz.org/ws/2/recording";
pub const USER_AGENT: &str = concat!("k-mood/", env!("CARGO_PKG_VERSION"));
pub struct MusicBrainzRecordingLookup {
http: reqwest::Client,
}
impl MusicBrainzRecordingLookup {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
#[derive(serde::Deserialize)]
struct SearchResult {
#[serde(default)]
recordings: Vec<Recording>,
}
#[derive(serde::Deserialize)]
struct Recording {
id: String,
}
pub fn parse_first_recording(body: &str) -> Option<RecordingId> {
let result: SearchResult = serde_json::from_str(body).ok()?;
let first = result.recordings.first()?;
RecordingId::new(&first.id).ok()
}
#[async_trait::async_trait]
impl RecordingLookupPort for MusicBrainzRecordingLookup {
async fn find_recording(
&self,
title: &str,
artist: &str,
) -> Result<Option<RecordingId>, DomainError> {
let query = format!(r#"recording:"{title}" AND artist:"{artist}""#);
let response = self
.http
.get(SEARCH_URL)
.query(&[("query", query.as_str()), ("fmt", "json"), ("limit", "1")])
.send()
.await;
let Ok(response) = response else {
tracing::debug!("musicbrainz lookup failed");
return Ok(None);
};
let Ok(body) = response.text().await else {
return Ok(None);
};
Ok(parse_first_recording(&body))
}
}

View File

@@ -0,0 +1,3 @@
mod client;
pub use client::{MusicBrainzRecordingLookup, USER_AGENT, parse_first_recording};

View File

@@ -0,0 +1,76 @@
use md5::{Digest, Md5};
use rand::Rng;
use domain::errors::DomainError;
use domain::ports::NowPlayingPort;
use domain::song::Song;
use super::credential::SubsonicCredential;
use super::response::parse_now_playing;
const API_VERSION: &str = "1.16.1";
const CLIENT_NAME: &str = "k-mood";
const SALT_BYTES: usize = 12;
pub struct SubsonicNowPlayingAdapter {
http: reqwest::Client,
}
impl SubsonicNowPlayingAdapter {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
pub fn auth_token(password: &str, salt: &str) -> String {
let mut hasher = Md5::new();
hasher.update(password.as_bytes());
hasher.update(salt.as_bytes());
format!("{:x}", hasher.finalize())
}
fn random_salt() -> String {
let mut bytes = [0u8; SALT_BYTES];
rand::rng().fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[async_trait::async_trait]
impl NowPlayingPort for SubsonicNowPlayingAdapter {
fn provider(&self) -> &str {
"subsonic"
}
async fn now_playing(&self, credential: &[u8]) -> Result<Option<Song>, DomainError> {
let credential = SubsonicCredential::parse(credential)?;
let salt = random_salt();
let token = auth_token(&credential.password, &salt);
let url = format!("{}/rest/getNowPlaying", credential.base_url());
let response = self
.http
.get(&url)
.query(&[
("u", credential.username.as_str()),
("t", token.as_str()),
("s", salt.as_str()),
("v", API_VERSION),
("c", CLIENT_NAME),
("f", "json"),
])
.send()
.await
.map_err(|e| {
tracing::warn!(%e, "subsonic request failed");
DomainError::InvalidInput("could not reach the music provider".into())
})?;
let body = response.text().await.map_err(|e| {
tracing::warn!(%e, "subsonic response could not be read");
DomainError::InvalidInput("could not read the music provider's response".into())
})?;
parse_now_playing(&body)
}
}

View File

@@ -0,0 +1,43 @@
use domain::errors::DomainError;
#[derive(serde::Deserialize)]
pub struct SubsonicCredential {
pub url: String,
pub username: String,
pub password: String,
}
impl SubsonicCredential {
pub fn parse(bytes: &[u8]) -> Result<Self, DomainError> {
let credential: Self = serde_json::from_slice(bytes).map_err(|_| {
DomainError::InvalidInput(
"subsonic credential must carry a url, username and password".into(),
)
})?;
if credential.url.trim().is_empty()
|| credential.username.trim().is_empty()
|| credential.password.is_empty()
{
return Err(DomainError::InvalidInput(
"subsonic credential must carry a url, username and password".into(),
));
}
Ok(credential)
}
pub fn base_url(&self) -> &str {
self.url.trim().trim_end_matches('/')
}
}
impl std::fmt::Debug for SubsonicCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubsonicCredential")
.field("url", &self.url)
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}

View File

@@ -0,0 +1,7 @@
mod client;
mod credential;
mod response;
pub use client::{SubsonicNowPlayingAdapter, auth_token};
pub use credential::SubsonicCredential;
pub use response::parse_now_playing;

View File

@@ -0,0 +1,62 @@
use domain::errors::DomainError;
use domain::song::Song;
#[derive(serde::Deserialize)]
struct Envelope {
#[serde(rename = "subsonic-response")]
response: SubsonicResponse,
}
#[derive(serde::Deserialize)]
struct SubsonicResponse {
status: String,
#[serde(rename = "nowPlaying")]
now_playing: Option<NowPlaying>,
error: Option<SubsonicError>,
}
#[derive(serde::Deserialize)]
struct SubsonicError {
message: Option<String>,
}
#[derive(serde::Deserialize)]
struct NowPlaying {
#[serde(default)]
entry: Vec<NowPlayingEntry>,
}
#[derive(serde::Deserialize)]
struct NowPlayingEntry {
title: Option<String>,
artist: Option<String>,
album: Option<String>,
}
pub fn parse_now_playing(body: &str) -> Result<Option<Song>, DomainError> {
let envelope: Envelope = serde_json::from_str(body)
.map_err(|_| DomainError::InvalidInput("unrecognised subsonic response".into()))?;
if envelope.response.status != "ok" {
let message = envelope
.response
.error
.and_then(|error| error.message)
.unwrap_or_else(|| "subsonic rejected the request".into());
return Err(DomainError::InvalidInput(message));
}
let Some(entry) = envelope
.response
.now_playing
.and_then(|playing| playing.entry.into_iter().next())
else {
return Ok(None);
};
let (Some(title), Some(artist)) = (entry.title, entry.artist) else {
return Ok(None);
};
Ok(Some(Song::new(title, artist, entry.album, None)?))
}

View File

@@ -0,0 +1,30 @@
use music::musicbrainz::parse_first_recording;
#[test]
fn the_first_recording_is_taken_as_the_match() {
let body = r#"{"recordings":[
{"id":"f5c7e7a2-0000-4000-8000-000000000001","title":"Paranoid Android"},
{"id":"f5c7e7a2-0000-4000-8000-000000000002","title":"Paranoid Android (live)"}
]}"#;
let recording = parse_first_recording(body).unwrap();
assert_eq!(
recording.value().to_string(),
"f5c7e7a2-0000-4000-8000-000000000001"
);
}
#[test]
fn no_match_yields_nothing_rather_than_an_error() {
assert!(parse_first_recording(r#"{"recordings":[]}"#).is_none());
assert!(parse_first_recording(r#"{}"#).is_none());
assert!(parse_first_recording("not json").is_none());
}
#[test]
fn a_recording_id_that_is_not_a_uuid_is_ignored() {
let body = r#"{"recordings":[{"id":"not-a-uuid"}]}"#;
assert!(parse_first_recording(body).is_none());
}

View File

@@ -0,0 +1,81 @@
use music::subsonic::{SubsonicCredential, auth_token, parse_now_playing};
#[test]
fn the_auth_token_is_md5_of_password_and_salt() {
assert_eq!(
auth_token("hunter2", "c19b2d"),
"1b41ecef65ff7799cf7a84cf2d505e08"
);
}
#[test]
fn a_different_salt_produces_a_different_token() {
assert_ne!(auth_token("hunter2", "aaa"), auth_token("hunter2", "bbb"));
}
#[test]
fn a_credential_needs_a_url_username_and_password() {
let valid = br#"{"url":"https://music.example/","username":"gabriel","password":"hunter2"}"#;
let credential = SubsonicCredential::parse(valid).unwrap();
assert_eq!(credential.base_url(), "https://music.example");
assert_eq!(credential.username, "gabriel");
}
#[test]
fn an_incomplete_credential_is_rejected() {
assert!(SubsonicCredential::parse(br#"{"url":"https://music.example"}"#).is_err());
assert!(SubsonicCredential::parse(br#"{"url":"","username":"g","password":"p"}"#).is_err());
assert!(SubsonicCredential::parse(b"not json").is_err());
}
#[test]
fn a_credential_never_reveals_its_password_in_debug_output() {
let credential = SubsonicCredential::parse(
br#"{"url":"https://music.example","username":"gabriel","password":"hunter2"}"#,
)
.unwrap();
assert!(!format!("{credential:?}").contains("hunter2"));
}
#[test]
fn a_playing_track_becomes_a_song() {
let body = r#"{"subsonic-response":{"status":"ok","version":"1.16.1","nowPlaying":{"entry":[
{"title":"Paranoid Android","artist":"Radiohead","album":"OK Computer"}
]}}}"#;
let song = parse_now_playing(body).unwrap().unwrap();
assert_eq!(song.title().value(), "Paranoid Android");
assert_eq!(song.artist().value(), "Radiohead");
assert_eq!(song.album().map(|a| a.value()), Some("OK Computer"));
}
#[test]
fn nothing_playing_is_not_an_error() {
let empty = r#"{"subsonic-response":{"status":"ok","version":"1.16.1"}}"#;
let no_entries = r#"{"subsonic-response":{"status":"ok","nowPlaying":{"entry":[]}}}"#;
assert!(parse_now_playing(empty).unwrap().is_none());
assert!(parse_now_playing(no_entries).unwrap().is_none());
}
#[test]
fn a_failed_call_is_an_error_even_though_subsonic_answers_with_http_200() {
let body = r#"{"subsonic-response":{"status":"failed","version":"1.16.1",
"error":{"code":40,"message":"Wrong username or password."}}}"#;
let result = parse_now_playing(body);
assert!(result.is_err());
}
#[test]
fn a_track_missing_its_artist_is_skipped_rather_than_half_stored() {
let body = r#"{"subsonic-response":{"status":"ok","nowPlaying":{"entry":[
{"title":"Untitled"}
]}}}"#;
assert!(parse_now_playing(body).unwrap().is_none());
}

View File

@@ -11,3 +11,7 @@ sqlx.workspace = true
uuid.workspace = true
chrono.workspace = true
tracing.workspace = true
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -1,16 +1,68 @@
use std::time::Duration;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const MIGRATIONS: &[&str] = &[
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_push_subscriptions.sql"),
const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
const MIGRATIONS: &[(&str, &str)] = &[
("001_initial", include_str!("migrations/001_initial.sql")),
(
"002_push_subscriptions",
include_str!("migrations/002_push_subscriptions.sql"),
),
(
"003_entry_content",
include_str!("migrations/003_entry_content.sql"),
),
(
"004_drop_entry_content_column",
include_str!("migrations/004_drop_entry_content_column.sql"),
),
(
"005_location_and_song",
include_str!("migrations/005_location_and_song.sql"),
),
(
"006_provider_connections",
include_str!("migrations/006_provider_connections.sql"),
),
(
"007_daily_metrics",
include_str!("migrations/007_daily_metrics.sql"),
),
(
"008_api_tokens",
include_str!("migrations/008_api_tokens.sql"),
),
(
"009_metric_rejections",
include_str!("migrations/009_metric_rejections.sql"),
),
(
"010_cycle_and_preferences",
include_str!("migrations/010_cycle_and_preferences.sql"),
),
("011_jobs", include_str!("migrations/011_jobs.sql")),
(
"012_entry_weather",
include_str!("migrations/012_entry_weather.sql"),
),
];
const TAKE_THE_WRITE_LOCK_UP_FRONT: &str = "BEGIN IMMEDIATE";
const SCHEMA_MIGRATIONS_TABLE: &str = "CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY NOT NULL,
applied_at TEXT NOT NULL
)";
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options: SqliteConnectOptions = database_url
.parse::<SqliteConnectOptions>()?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(BUSY_TIMEOUT)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
@@ -22,9 +74,59 @@ pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error>
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
for migration in MIGRATIONS {
sqlx::raw_sql(*migration).execute(pool).await?;
let mut connection = pool.acquire().await?;
sqlx::raw_sql(SCHEMA_MIGRATIONS_TABLE)
.execute(&mut *connection)
.await?;
sqlx::raw_sql(TAKE_THE_WRITE_LOCK_UP_FRONT)
.execute(&mut *connection)
.await?;
match apply_pending(&mut connection).await {
Ok(()) => {
sqlx::raw_sql("COMMIT").execute(&mut *connection).await?;
Ok(())
}
Err(error) => {
let _ = sqlx::raw_sql("ROLLBACK").execute(&mut *connection).await;
Err(error)
}
}
tracing::info!("database migrations completed");
}
async fn apply_pending(connection: &mut sqlx::SqliteConnection) -> Result<(), sqlx::Error> {
for (name, sql) in MIGRATIONS {
if is_applied(connection, name).await? {
continue;
}
sqlx::raw_sql(*sql).execute(&mut *connection).await?;
sqlx::query("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)")
.bind(*name)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *connection)
.await?;
tracing::info!(migration = *name, "applied migration");
}
Ok(())
}
async fn is_applied(
connection: &mut sqlx::SqliteConnection,
name: &str,
) -> Result<bool, sqlx::Error> {
let existing: Option<(String,)> =
sqlx::query_as("SELECT name FROM schema_migrations WHERE name = ?")
.bind(name)
.fetch_optional(&mut *connection)
.await?;
Ok(existing.is_some())
}

View File

@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS entry_content (
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
content TEXT NOT NULL
);

View File

@@ -0,0 +1,4 @@
INSERT OR IGNORE INTO entry_content (entry_id, content)
SELECT id, content FROM mood_entries WHERE content IS NOT NULL;
ALTER TABLE mood_entries DROP COLUMN content;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS entry_location (
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
latitude REAL NOT NULL,
longitude REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS entry_song (
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
recording_id TEXT
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS provider_connections (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
credential BLOB NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (user_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_provider_connections_user_id ON provider_connections(user_id);

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS daily_metrics (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
kind TEXT NOT NULL,
value INTEGER NOT NULL,
provider TEXT,
PRIMARY KEY (user_id, date, kind)
);

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
digest TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS metric_rejections (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
origin TEXT NOT NULL,
provider TEXT,
date TEXT,
kind TEXT NOT NULL,
value INTEGER,
reason TEXT NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metric_rejections_user ON metric_rejections(user_id, recorded_at);

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS cycle_starts (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
PRIMARY KEY (user_id, date)
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tracks_cycle INTEGER NOT NULL DEFAULT 0
);

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY NOT NULL,
kind TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
enqueued_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (kind, subject)
);
CREATE INDEX IF NOT EXISTS idx_jobs_claimable ON jobs(kind, status, enqueued_at);

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS entry_weather (
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
condition TEXT NOT NULL,
temperature REAL NOT NULL,
observed_by TEXT NOT NULL
);

View File

@@ -0,0 +1,80 @@
use sqlx::SqlitePool;
use domain::api_token::{ApiToken, ApiTokenId};
use domain::errors::DomainError;
use domain::user::UserId;
use super::super::shared::db_err;
pub struct SqliteApiTokenCommandRepository {
pool: SqlitePool,
}
impl SqliteApiTokenCommandRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::ApiTokenCommandPort for SqliteApiTokenCommandRepository {
async fn save(&self, token: &ApiToken) -> Result<(), DomainError> {
let taken: Option<(String,)> =
sqlx::query_as("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?")
.bind(token.user_id().value().to_string())
.bind(token.name().value())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
if taken.is_some() {
return Err(DomainError::Conflict(format!(
"a token named {} already exists",
token.name().value()
)));
}
sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(token.id().value().to_string())
.bind(token.user_id().value().to_string())
.bind(token.name().value())
.bind(token.digest().value())
.bind(token.scope().name())
.bind(token.created_at().to_rfc3339())
.bind(token.last_used_at().map(|used| used.to_rfc3339()))
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn revoke(&self, user_id: &UserId, id: &ApiTokenId) -> Result<(), DomainError> {
let removed = sqlx::query("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
.bind(id.value().to_string())
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
if removed.rows_affected() == 0 {
return Err(DomainError::NotFound("api token not found".into()));
}
Ok(())
}
async fn mark_used(&self, id: &ApiTokenId) -> Result<(), DomainError> {
sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
.bind(chrono::Utc::now().to_rfc3339())
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteApiTokenCommandRepository;
pub use query::SqliteApiTokenQueryRepository;

View File

@@ -0,0 +1,47 @@
use sqlx::SqlitePool;
use domain::api_token::{ApiToken, TokenDigest};
use domain::errors::DomainError;
use domain::user::UserId;
use super::super::shared::db_err;
use super::rows::{ApiTokenRow, readable};
pub struct SqliteApiTokenQueryRepository {
pool: SqlitePool,
}
impl SqliteApiTokenQueryRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository {
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
let row: Option<ApiTokenRow> = sqlx::query_as(
"SELECT id, user_id, name, digest, scope, created_at, last_used_at
FROM api_tokens WHERE digest = ?",
)
.bind(digest.value())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.as_ref().and_then(readable))
}
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> {
let rows: Vec<ApiTokenRow> = sqlx::query_as(
"SELECT id, user_id, name, digest, scope, created_at, last_used_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
}

View File

@@ -0,0 +1,45 @@
use domain::api_token::{ApiToken, ApiTokenData, ApiTokenId, TokenDigest, TokenScope};
use domain::provider::ProviderName;
use domain::user::UserId;
#[derive(sqlx::FromRow)]
pub struct ApiTokenRow {
pub id: String,
pub user_id: String,
pub name: String,
pub digest: String,
pub scope: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
pub fn row_to_token(row: &ApiTokenRow) -> Option<ApiToken> {
let last_used_at = match &row.last_used_at {
None => None,
Some(stamp) => Some(stamp.parse().ok()?),
};
Some(ApiToken::from_persistence(ApiTokenData {
id: ApiTokenId::from_uuid(row.id.parse().ok()?),
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
name: ProviderName::from_persistence(row.name.clone()),
digest: TokenDigest::from_persistence(row.digest.clone()),
scope: TokenScope::from_name(&row.scope)?,
created_at: row.created_at.parse().ok()?,
last_used_at,
}))
}
pub fn readable(row: &ApiTokenRow) -> Option<ApiToken> {
let token = row_to_token(row);
if token.is_none() {
tracing::warn!(
token_id = %row.id,
scope = %row.scope,
"skipped a stored api token this build cannot read"
);
}
token
}

View File

@@ -41,6 +41,12 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
.await
.map_err(db_err)?;
sqlx::query("DELETE FROM daily_metrics WHERE user_id = ?")
.bind(&uid)
.execute(&mut *tx)
.await
.map_err(db_err)?;
tx.commit().await.map_err(db_err)?;
Ok(())
}

View File

@@ -0,0 +1,3 @@
mod repository;
pub use repository::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};

View File

@@ -0,0 +1,103 @@
use sqlx::SqlitePool;
use domain::entry::Date;
use domain::errors::DomainError;
use domain::user::{UserId, UserPreferences};
use super::super::shared::db_err;
pub struct SqliteCycleStartRepository {
pool: SqlitePool,
}
impl SqliteCycleStartRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::CycleStartCommandPort for SqliteCycleStartRepository {
async fn record(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO cycle_starts (user_id, date) VALUES (?, ?)
ON CONFLICT(user_id, date) DO NOTHING",
)
.bind(user_id.value().to_string())
.bind(date.to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn forget(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError> {
sqlx::query("DELETE FROM cycle_starts WHERE user_id = ? AND date = ?")
.bind(user_id.value().to_string())
.bind(date.to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
#[async_trait::async_trait]
impl domain::ports::CycleStartQueryPort for SqliteCycleStartRepository {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Date>, DomainError> {
let rows: Vec<(String,)> =
sqlx::query_as("SELECT date FROM cycle_starts WHERE user_id = ? ORDER BY date")
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows
.iter()
.filter_map(|row| row.0.parse().ok().map(Date::from_persistence))
.collect())
}
}
pub struct SqliteUserPreferencesRepository {
pool: SqlitePool,
}
impl SqliteUserPreferencesRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::UserPreferencesCommandPort for SqliteUserPreferencesRepository {
async fn save(&self, preferences: &UserPreferences) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO user_preferences (user_id, tracks_cycle) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET tracks_cycle = excluded.tracks_cycle",
)
.bind(preferences.user_id().value().to_string())
.bind(preferences.tracks_cycle())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
#[async_trait::async_trait]
impl domain::ports::UserPreferencesQueryPort for SqliteUserPreferencesRepository {
async fn find_by_user(&self, user_id: &UserId) -> Result<Option<UserPreferences>, DomainError> {
let row: Option<(bool,)> =
sqlx::query_as("SELECT tracks_cycle FROM user_preferences WHERE user_id = ?")
.bind(user_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.map(|found| UserPreferences::from_persistence(user_id.clone(), found.0)))
}
}

View File

@@ -0,0 +1,99 @@
use sqlx::SqlitePool;
use domain::entry::Date;
use domain::errors::DomainError;
use domain::metric::{DailyMetric, MetricKind};
use domain::user::UserId;
use super::super::shared::db_err;
use super::rows::{DailyMetricRow, provider_column, source_of};
pub struct SqliteDailyMetricCommandRepository {
pool: SqlitePool,
}
impl SqliteDailyMetricCommandRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::DailyMetricCommandPort for SqliteDailyMetricCommandRepository {
async fn save(&self, metrics: &[DailyMetric]) -> Result<usize, DomainError> {
let mut tx = self.pool.begin().await.map_err(db_err)?;
let mut written = 0;
for metric in metrics {
let user_id = metric.user_id().value().to_string();
let date = metric.date().to_string();
let kind = metric.kind().name();
let stored: Option<DailyMetricRow> = sqlx::query_as(
"SELECT user_id, date, kind, value, provider FROM daily_metrics
WHERE user_id = ? AND date = ? AND kind = ?",
)
.bind(&user_id)
.bind(&date)
.bind(kind)
.fetch_optional(&mut *tx)
.await
.map_err(db_err)?;
if let Some(row) = &stored
&& !source_of(row).is_superseded_by(metric.source())
{
continue;
}
sqlx::query(
"INSERT INTO daily_metrics (user_id, date, kind, value, provider)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, date, kind) DO UPDATE SET
value = excluded.value, provider = excluded.provider",
)
.bind(&user_id)
.bind(&date)
.bind(kind)
.bind(metric.value().count())
.bind(provider_column(metric.source()))
.execute(&mut *tx)
.await
.map_err(db_err)?;
written += 1;
}
tx.commit().await.map_err(db_err)?;
Ok(written)
}
async fn delete(
&self,
user_id: &UserId,
date: &Date,
kinds: &[MetricKind],
) -> Result<(), DomainError> {
if kinds.is_empty() {
return Ok(());
}
let placeholders = vec!["?"; kinds.len()].join(",");
let sql = format!(
"DELETE FROM daily_metrics WHERE user_id = ? AND date = ? AND kind IN ({placeholders})"
);
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(user_id.value().to_string())
.bind(date.to_string());
for kind in kinds {
query = query.bind(kind.name());
}
query.execute(&self.pool).await.map_err(db_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteDailyMetricCommandRepository;
pub use query::SqliteDailyMetricQueryRepository;

View File

@@ -0,0 +1,98 @@
use std::sync::Arc;
use sqlx::SqlitePool;
use domain::entry::DateSpan;
use domain::errors::DomainError;
use domain::metric::DailyMetric;
use domain::ports::RejectionCommandPort;
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
use domain::user::UserId;
use super::super::shared::db_err;
use super::rows::{DailyMetricRow, row_to_metric};
pub struct SqliteDailyMetricQueryRepository {
pool: SqlitePool,
rejections: Arc<dyn RejectionCommandPort>,
}
impl SqliteDailyMetricQueryRepository {
pub fn new(pool: SqlitePool, rejections: Arc<dyn RejectionCommandPort>) -> Self {
Self { pool, rejections }
}
}
#[async_trait::async_trait]
impl domain::ports::DailyMetricQueryPort for SqliteDailyMetricQueryRepository {
async fn find_by_span(
&self,
user_id: &UserId,
span: &DateSpan,
) -> Result<Vec<DailyMetric>, DomainError> {
let rows: Vec<DailyMetricRow> = sqlx::query_as(
"SELECT user_id, date, kind, value, provider FROM daily_metrics
WHERE user_id = ? AND date >= ? AND date <= ?
ORDER BY date, kind",
)
.bind(user_id.value().to_string())
.bind(span.start().to_string())
.bind(span.end().to_string())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
let mut readable = Vec::with_capacity(rows.len());
let mut unreadable = Vec::new();
for row in &rows {
match row_to_metric(row) {
Some(metric) => readable.push(metric),
None => unreadable.push(unreadable_row(user_id, row)),
}
}
self.trace(&unreadable).await;
Ok(readable)
}
}
impl SqliteDailyMetricQueryRepository {
async fn trace(&self, unreadable: &[RejectedMetric]) {
if unreadable.is_empty() {
return;
}
tracing::warn!(
count = unreadable.len(),
"skipped stored metrics this build cannot read"
);
if let Err(error) = self.rejections.record(unreadable).await {
tracing::warn!(%error, "could not write to the rejection trace");
}
}
}
fn unreadable_row(user_id: &UserId, row: &DailyMetricRow) -> RejectedMetric {
let date = row
.date
.parse()
.ok()
.map(domain::entry::Date::from_persistence);
RejectedMetric::new(
user_id.clone(),
RejectionOrigin::StoredRow,
RejectionDetail::new(
row.provider
.clone()
.map(domain::provider::ProviderName::from_persistence),
date,
row.kind.clone(),
Some(row.value),
),
"this reading is stored but cannot be read back by this build",
)
}

View File

@@ -0,0 +1,33 @@
use domain::entry::Date;
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source};
use domain::provider::ProviderName;
use domain::user::UserId;
#[derive(sqlx::FromRow)]
pub struct DailyMetricRow {
pub user_id: String,
pub date: String,
pub kind: String,
pub value: i64,
pub provider: Option<String>,
}
pub fn row_to_metric(row: &DailyMetricRow) -> Option<DailyMetric> {
let user_id = row.user_id.parse().ok().map(UserId::from_uuid)?;
let date = row.date.parse().ok().map(Date::from_persistence)?;
let kind = MetricKind::from_name(&row.kind)?;
let value = MetricValue::of_kind(kind, row.value).ok()?;
Some(DailyMetric::new(user_id, date, value, source_of(row)))
}
pub fn source_of(row: &DailyMetricRow) -> Source {
match &row.provider {
None => Source::Manual,
Some(name) => Source::Provider(ProviderName::from_persistence(name.clone())),
}
}
pub fn provider_column(source: &Source) -> Option<&str> {
source.provider().map(|name| name.value())
}

View File

@@ -0,0 +1,94 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::{Content, MoodEntryId};
use domain::errors::DomainError;
use super::super::shared::db_err;
pub struct SqliteContentDimensionRepository {
pool: SqlitePool,
}
impl SqliteContentDimensionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct ContentRow {
entry_id: String,
content: String,
}
#[async_trait::async_trait]
impl domain::ports::EntryDimensionPort for SqliteContentDimensionRepository {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; entry_ids.len()].join(",");
let sql = format!(
"SELECT entry_id, content FROM entry_content WHERE entry_id IN ({placeholders})"
);
let mut query = sqlx::query_as::<_, ContentRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id.value().to_string());
}
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
Ok(rows
.into_iter()
.filter_map(|row| {
let id = row.entry_id.parse().ok()?;
Some((
MoodEntryId::from_uuid(id),
DimensionValue::Content(Content::from_persistence(row.content)),
))
})
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let id = entry_id.value().to_string();
match values
.iter()
.find(|value| value.kind() == DimensionKind::Content)
{
Some(DimensionValue::Content(content)) => {
sqlx::query(
"INSERT INTO entry_content (entry_id, content) VALUES (?, ?)
ON CONFLICT(entry_id) DO UPDATE SET content = excluded.content",
)
.bind(&id)
.bind(content.value())
.execute(&self.pool)
.await
.map_err(db_err)?;
}
_ => {
sqlx::query("DELETE FROM entry_content WHERE entry_id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(db_err)?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,101 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use domain::location::Coordinates;
use super::super::shared::db_err;
pub struct SqliteLocationDimensionRepository {
pool: SqlitePool,
}
impl SqliteLocationDimensionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct LocationRow {
entry_id: String,
latitude: f64,
longitude: f64,
}
#[async_trait::async_trait]
impl domain::ports::EntryDimensionPort for SqliteLocationDimensionRepository {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; entry_ids.len()].join(",");
let sql = format!(
"SELECT entry_id, latitude, longitude FROM entry_location WHERE entry_id IN ({placeholders})"
);
let mut query = sqlx::query_as::<_, LocationRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id.value().to_string());
}
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
Ok(rows
.into_iter()
.filter_map(|row| {
let entry_id = row.entry_id.parse().ok()?;
Some((
MoodEntryId::from_uuid(entry_id),
DimensionValue::Location(Coordinates::from_persistence(
row.latitude,
row.longitude,
)),
))
})
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let id = entry_id.value().to_string();
match values
.iter()
.find(|value| value.kind() == DimensionKind::Location)
{
Some(DimensionValue::Location(coordinates)) => {
sqlx::query(
"INSERT INTO entry_location (entry_id, latitude, longitude) VALUES (?, ?, ?)
ON CONFLICT(entry_id) DO UPDATE SET
latitude = excluded.latitude, longitude = excluded.longitude",
)
.bind(&id)
.bind(coordinates.latitude().value())
.bind(coordinates.longitude().value())
.execute(&self.pool)
.await
.map_err(db_err)?;
}
_ => {
sqlx::query("DELETE FROM entry_location WHERE entry_id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(db_err)?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,11 @@
mod content;
mod location;
mod relation;
mod song;
mod weather;
pub use content::SqliteContentDimensionRepository;
pub use location::SqliteLocationDimensionRepository;
pub use relation::SqliteRelationDimensionRepository;
pub use song::SqliteSongDimensionRepository;
pub use weather::SqliteWeatherDimensionRepository;

View File

@@ -0,0 +1,166 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use uuid::Uuid;
use domain::activity::ActivityId;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use super::super::shared::db_err;
pub struct SqliteRelationDimensionRepository {
pool: SqlitePool,
table: &'static str,
column: &'static str,
kind: DimensionKind,
}
impl SqliteRelationDimensionRepository {
pub fn activities(pool: SqlitePool) -> Self {
Self {
pool,
table: "entry_activities",
column: "activity_id",
kind: DimensionKind::Activities,
}
}
pub fn photos(pool: SqlitePool) -> Self {
Self {
pool,
table: "entry_photos",
column: "photo_id",
kind: DimensionKind::Photos,
}
}
pub fn voice_memos(pool: SqlitePool) -> Self {
Self {
pool,
table: "entry_voice_memos",
column: "voice_memo_id",
kind: DimensionKind::VoiceMemos,
}
}
fn to_value(&self, ids: Vec<Uuid>) -> DimensionValue {
match self.kind {
DimensionKind::Activities => {
DimensionValue::Activities(ids.into_iter().map(ActivityId::from_uuid).collect())
}
DimensionKind::Photos => {
DimensionValue::Photos(ids.into_iter().map(PhotoId::from_uuid).collect())
}
DimensionKind::VoiceMemos => {
DimensionValue::VoiceMemos(ids.into_iter().map(VoiceMemoId::from_uuid).collect())
}
DimensionKind::Content
| DimensionKind::Location
| DimensionKind::Song
| DimensionKind::Weather => {
unreachable!("relation repository serves only id-list dimensions")
}
}
}
}
fn related_ids(value: &DimensionValue) -> Vec<String> {
match value {
DimensionValue::Activities(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
DimensionValue::Photos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
DimensionValue::VoiceMemos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
DimensionValue::Content(_)
| DimensionValue::Location(_)
| DimensionValue::Song(_)
| DimensionValue::Weather(_) => Vec::new(),
}
}
#[derive(sqlx::FromRow)]
struct RelationRow {
entry_id: String,
related_id: String,
}
#[async_trait::async_trait]
impl domain::ports::EntryDimensionPort for SqliteRelationDimensionRepository {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; entry_ids.len()].join(",");
let sql = format!(
"SELECT entry_id, {} AS related_id FROM {} WHERE entry_id IN ({placeholders})",
self.column, self.table
);
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id.value().to_string());
}
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
let mut grouped: HashMap<MoodEntryId, Vec<Uuid>> = HashMap::new();
for row in rows {
let (Ok(entry_id), Ok(related_id)) =
(row.entry_id.parse::<Uuid>(), row.related_id.parse::<Uuid>())
else {
continue;
};
grouped
.entry(MoodEntryId::from_uuid(entry_id))
.or_default()
.push(related_id);
}
Ok(grouped
.into_iter()
.map(|(entry_id, ids)| (entry_id, self.to_value(ids)))
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let id = entry_id.value().to_string();
let mut tx = self.pool.begin().await.map_err(db_err)?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"DELETE FROM {} WHERE entry_id = ?",
self.table
)))
.bind(&id)
.execute(&mut *tx)
.await
.map_err(db_err)?;
if let Some(value) = values.iter().find(|value| value.kind() == self.kind) {
let insert = format!(
"INSERT OR IGNORE INTO {} (entry_id, {}) VALUES (?, ?)",
self.table, self.column
);
for related in related_ids(value) {
sqlx::query(sqlx::AssertSqlSafe(insert.clone()))
.bind(&id)
.bind(related)
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
}
tx.commit().await.map_err(db_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,109 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use domain::song::{AlbumName, ArtistName, RecordingId, Song, SongTitle};
use super::super::shared::db_err;
pub struct SqliteSongDimensionRepository {
pool: SqlitePool,
}
impl SqliteSongDimensionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct SongRow {
entry_id: String,
title: String,
artist: String,
album: Option<String>,
recording_id: Option<String>,
}
#[async_trait::async_trait]
impl domain::ports::EntryDimensionPort for SqliteSongDimensionRepository {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; entry_ids.len()].join(",");
let sql = format!(
"SELECT entry_id, title, artist, album, recording_id FROM entry_song WHERE entry_id IN ({placeholders})"
);
let mut query = sqlx::query_as::<_, SongRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id.value().to_string());
}
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
Ok(rows
.into_iter()
.filter_map(|row| {
let entry_id = row.entry_id.parse().ok()?;
let song = Song::from_persistence(
SongTitle::from_persistence(row.title),
ArtistName::from_persistence(row.artist),
row.album.map(AlbumName::from_persistence),
row.recording_id
.and_then(|id| id.parse().ok())
.map(RecordingId::from_uuid),
);
Some((MoodEntryId::from_uuid(entry_id), DimensionValue::Song(song)))
})
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let id = entry_id.value().to_string();
match values
.iter()
.find(|value| value.kind() == DimensionKind::Song)
{
Some(DimensionValue::Song(song)) => {
sqlx::query(
"INSERT INTO entry_song (entry_id, title, artist, album, recording_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(entry_id) DO UPDATE SET
title = excluded.title, artist = excluded.artist,
album = excluded.album, recording_id = excluded.recording_id",
)
.bind(&id)
.bind(song.title().value())
.bind(song.artist().value())
.bind(song.album().map(|album| album.value().to_string()))
.bind(song.recording_id().map(|id| id.value().to_string()))
.execute(&self.pool)
.await
.map_err(db_err)?;
}
_ => {
sqlx::query("DELETE FROM entry_song WHERE entry_id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(db_err)?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,109 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use domain::provider::ProviderName;
use domain::weather::{Celsius, Condition, Weather};
use super::super::shared::db_err;
pub struct SqliteWeatherDimensionRepository {
pool: SqlitePool,
}
impl SqliteWeatherDimensionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct WeatherRow {
entry_id: String,
condition: String,
temperature: f64,
observed_by: String,
}
#[async_trait::async_trait]
impl domain::ports::EntryDimensionPort for SqliteWeatherDimensionRepository {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; entry_ids.len()].join(",");
let sql = format!(
"SELECT entry_id, condition, temperature, observed_by
FROM entry_weather WHERE entry_id IN ({placeholders})"
);
let mut query = sqlx::query_as::<_, WeatherRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id.value().to_string());
}
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let observed = values
.iter()
.find(|value| value.kind() == DimensionKind::Weather);
let Some(DimensionValue::Weather(weather)) = observed else {
return Ok(());
};
sqlx::query(
"INSERT INTO entry_weather (entry_id, condition, temperature, observed_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(entry_id) DO UPDATE SET
condition = excluded.condition,
temperature = excluded.temperature,
observed_by = excluded.observed_by",
)
.bind(entry_id.value().to_string())
.bind(weather.condition().name())
.bind(weather.temperature().value())
.bind(weather.observed_by().value())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
fn readable(row: &WeatherRow) -> Option<(MoodEntryId, DimensionValue)> {
let entry_id = MoodEntryId::from_uuid(row.entry_id.parse().ok()?);
let condition = Condition::from_name(&row.condition);
if condition.is_none() {
tracing::warn!(
entry_id = %row.entry_id,
condition = %row.condition,
"skipped stored weather this build cannot read"
);
}
let weather = Weather::new(
condition?,
Celsius::from_persistence(row.temperature),
ProviderName::from_persistence(row.observed_by.clone()),
);
Some((entry_id, DimensionValue::Weather(weather)))
}

View File

@@ -15,78 +15,29 @@ impl SqliteEntryCommandRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn save_relations(&self, entry: &MoodEntry) -> Result<(), DomainError> {
let entry_id = entry.id().value().to_string();
sqlx::query("DELETE FROM entry_activities WHERE entry_id = ?")
.bind(&entry_id)
.execute(&self.pool)
.await
.map_err(db_err)?;
for activity_id in entry.activities() {
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
.bind(&entry_id)
.bind(activity_id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
}
sqlx::query("DELETE FROM entry_photos WHERE entry_id = ?")
.bind(&entry_id)
.execute(&self.pool)
.await
.map_err(db_err)?;
for photo_id in entry.photos() {
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
.bind(&entry_id)
.bind(photo_id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
}
sqlx::query("DELETE FROM entry_voice_memos WHERE entry_id = ?")
.bind(&entry_id)
.execute(&self.pool)
.await
.map_err(db_err)?;
for voice_memo_id in entry.voice_memos() {
sqlx::query("INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)")
.bind(&entry_id)
.bind(voice_memo_id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
}
Ok(())
}
}
#[async_trait::async_trait]
impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
"INSERT INTO mood_entries (id, user_id, mood, logged_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
mood = excluded.mood, logged_at = excluded.logged_at,
content = excluded.content, updated_at = excluded.updated_at"
updated_at = excluded.updated_at",
)
.bind(entry.id().value().to_string())
.bind(entry.user_id().value().to_string())
.bind(entry.mood().value() as i32)
.bind(entry.logged_at().to_rfc3339())
.bind(entry.content().map(|c| c.value().to_string()))
.bind(entry.created_at().to_rfc3339())
.bind(entry.updated_at().to_rfc3339())
.execute(&self.pool)
.await
.map_err(db_err)?;
self.save_relations(entry).await
Ok(())
}
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
@@ -96,51 +47,21 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
let entry_id = entry.id().value().to_string();
sqlx::query(
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
"INSERT INTO mood_entries (id, user_id, mood, logged_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
mood = excluded.mood, logged_at = excluded.logged_at,
content = excluded.content, updated_at = excluded.updated_at"
updated_at = excluded.updated_at",
)
.bind(&entry_id)
.bind(entry.user_id().value().to_string())
.bind(entry.mood().value() as i32)
.bind(entry.logged_at().to_rfc3339())
.bind(entry.content().map(|c| c.value().to_string()))
.bind(entry.created_at().to_rfc3339())
.bind(entry.updated_at().to_rfc3339())
.execute(&mut *tx)
.await
.map_err(db_err)?;
for activity_id in entry.activities() {
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
.bind(&entry_id)
.bind(activity_id.value().to_string())
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
for photo_id in entry.photos() {
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
.bind(&entry_id)
.bind(photo_id.value().to_string())
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
for voice_memo_id in entry.voice_memos() {
sqlx::query(
"INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)",
)
.bind(&entry_id)
.bind(voice_memo_id.value().to_string())
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
}
tx.commit().await.map_err(db_err)?;

View File

@@ -1,153 +1,39 @@
use std::collections::HashMap;
use sqlx::SqlitePool;
use domain::activity::ActivityId;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData, MoodEntryId};
use domain::entry::{Mood, MoodEntry, MoodEntryData, MoodEntryId};
use domain::errors::DomainError;
use domain::user::UserId;
use super::super::shared::db_err;
#[derive(sqlx::FromRow)]
pub struct EntryRow {
pub id: String,
pub user_id: String,
pub mood: i32,
pub logged_at: String,
pub content: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(sqlx::FromRow)]
struct RelationRow {
entry_id: String,
related_id: String,
}
pub fn row_to_entry(row: EntryRow) -> Result<MoodEntry, DomainError> {
let parse_failed = || DomainError::InvalidInput("stored entry row is malformed".into());
pub fn row_to_entry(
row: EntryRow,
activity_ids: Vec<String>,
photo_ids: Vec<String>,
voice_memo_ids: Vec<String>,
) -> Result<MoodEntry, DomainError> {
Ok(MoodEntry::from_persistence(MoodEntryData {
id: MoodEntryId::from_uuid(row.id.parse().unwrap()),
user_id: UserId::from_uuid(row.user_id.parse().unwrap()),
id: MoodEntryId::from_uuid(row.id.parse().map_err(|_| parse_failed())?),
user_id: UserId::from_uuid(row.user_id.parse().map_err(|_| parse_failed())?),
mood: Mood::try_from(row.mood as u8)?,
logged_at: row.logged_at.parse().unwrap(),
activities: activity_ids
.into_iter()
.map(|id| ActivityId::from_uuid(id.parse().unwrap()))
.collect(),
content: row.content.map(Content::from_persistence),
photos: photo_ids
.into_iter()
.map(|id| PhotoId::from_uuid(id.parse().unwrap()))
.collect(),
voice_memos: voice_memo_ids
.into_iter()
.map(|id| VoiceMemoId::from_uuid(id.parse().unwrap()))
.collect(),
created_at: row.created_at.parse().unwrap(),
updated_at: row.updated_at.parse().unwrap(),
logged_at: row.logged_at.parse().map_err(|_| parse_failed())?,
created_at: row.created_at.parse().map_err(|_| parse_failed())?,
updated_at: row.updated_at.parse().map_err(|_| parse_failed())?,
}))
}
pub async fn hydrate_single(pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
let entry_id = row.id.clone();
let activities: Vec<RelationRow> = sqlx::query_as(
"SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id = ?",
)
.bind(&entry_id)
.fetch_all(pool)
.await
.map_err(db_err)?;
let photos: Vec<RelationRow> = sqlx::query_as(
"SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id = ?",
)
.bind(&entry_id)
.fetch_all(pool)
.await
.map_err(db_err)?;
let voice_memos: Vec<RelationRow> = sqlx::query_as(
"SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id = ?",
)
.bind(&entry_id)
.fetch_all(pool)
.await
.map_err(db_err)?;
row_to_entry(
row,
activities.into_iter().map(|r| r.related_id).collect(),
photos.into_iter().map(|r| r.related_id).collect(),
voice_memos.into_iter().map(|r| r.related_id).collect(),
)
pub async fn hydrate_single(_pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
row_to_entry(row)
}
pub async fn hydrate_batch(
pool: &SqlitePool,
_pool: &SqlitePool,
rows: Vec<EntryRow>,
) -> Result<Vec<MoodEntry>, DomainError> {
if rows.is_empty() {
return Ok(Vec::new());
}
let entry_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let placeholders = vec!["?"; entry_ids.len()].join(",");
let activities = batch_load(
pool,
&format!("SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id IN ({placeholders})"),
&entry_ids,
).await?;
let photos = batch_load(
pool,
&format!("SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id IN ({placeholders})"),
&entry_ids,
).await?;
let voice_memos = batch_load(
pool,
&format!("SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id IN ({placeholders})"),
&entry_ids,
).await?;
let mut entries = Vec::with_capacity(rows.len());
for row in rows {
let id = row.id.clone();
entries.push(row_to_entry(
row,
activities.get(&id).cloned().unwrap_or_default(),
photos.get(&id).cloned().unwrap_or_default(),
voice_memos.get(&id).cloned().unwrap_or_default(),
)?);
}
Ok(entries)
}
async fn batch_load(
pool: &SqlitePool,
sql: &str,
entry_ids: &[String],
) -> Result<HashMap<String, Vec<String>>, DomainError> {
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
for id in entry_ids {
query = query.bind(id);
}
let rows = query.fetch_all(pool).await.map_err(db_err)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows {
map.entry(row.entry_id).or_default().push(row.related_id);
}
Ok(map)
rows.into_iter().map(row_to_entry).collect()
}

View File

@@ -0,0 +1,78 @@
use sqlx::SqlitePool;
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use domain::ports::UnidentifiedSong;
use domain::song::RecordingId;
use domain::user::UserId;
use super::super::shared::db_err;
pub struct SqliteRecordingBackfillRepository {
pool: SqlitePool,
}
impl SqliteRecordingBackfillRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct UnidentifiedSongRow {
entry_id: String,
user_id: String,
title: String,
artist: String,
}
#[async_trait::async_trait]
impl domain::ports::RecordingBackfillQueryPort for SqliteRecordingBackfillRepository {
async fn find_songs_without_a_recording(
&self,
most: usize,
) -> Result<Vec<UnidentifiedSong>, DomainError> {
let rows: Vec<UnidentifiedSongRow> = sqlx::query_as(
"SELECT s.entry_id, e.user_id, s.title, s.artist
FROM entry_song s
JOIN mood_entries e ON e.id = s.entry_id
WHERE s.recording_id IS NULL
ORDER BY e.logged_at DESC
LIMIT ?",
)
.bind(most_as_limit(most))
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
async fn record_identity(
&self,
entry_id: &MoodEntryId,
recording_id: &RecordingId,
) -> Result<(), DomainError> {
sqlx::query("UPDATE entry_song SET recording_id = ? WHERE entry_id = ?")
.bind(recording_id.value().to_string())
.bind(entry_id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
fn most_as_limit(most: usize) -> i64 {
i64::try_from(most).unwrap_or(i64::MAX)
}
fn readable(row: &UnidentifiedSongRow) -> Option<UnidentifiedSong> {
Some(UnidentifiedSong {
entry_id: MoodEntryId::from_uuid(row.entry_id.parse().ok()?),
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
title: row.title.clone(),
artist: row.artist.clone(),
})
}

View File

@@ -0,0 +1,8 @@
mod backfill;
mod repository;
mod rows;
mod weather;
pub use backfill::SqliteRecordingBackfillRepository;
pub use repository::SqliteJobQueueRepository;
pub use weather::SqliteWeatherBacklogRepository;

View File

@@ -0,0 +1,160 @@
use sqlx::SqlitePool;
use domain::errors::DomainError;
use domain::job::{Job, JobId, JobKind, JobStatus, JobSubject};
use super::super::shared::db_err;
use super::rows::{JobRow, readable};
const COLUMNS: &str = "id, kind, subject, status, attempts, last_error, enqueued_at, updated_at";
pub struct SqliteJobQueueRepository {
pool: SqlitePool,
}
impl SqliteJobQueueRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::JobQueueCommandPort for SqliteJobQueueRepository {
async fn enqueue(&self, kind: JobKind, subject: &JobSubject) -> Result<bool, DomainError> {
let job = Job::pending(kind, subject.clone());
let written = sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, ?, ?, ?, 0, NULL, ?, ?)
ON CONFLICT(kind, subject) DO NOTHING",
)
.bind(job.id().value().to_string())
.bind(kind.name())
.bind(subject.key())
.bind(JobStatus::Pending.name())
.bind(job.enqueued_at().to_rfc3339())
.bind(job.updated_at().to_rfc3339())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(written.rows_affected() > 0)
}
async fn claim(&self, kind: JobKind, most: usize) -> Result<Vec<Job>, DomainError> {
let sql = format!(
"UPDATE jobs SET status = ?, updated_at = ?
WHERE id IN (
SELECT id FROM jobs WHERE kind = ? AND status = ?
ORDER BY enqueued_at LIMIT ?
)
RETURNING {COLUMNS}"
);
let rows: Vec<JobRow> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
.bind(JobStatus::Running.name())
.bind(chrono::Utc::now().to_rfc3339())
.bind(kind.name())
.bind(JobStatus::Pending.name())
.bind(most as i64)
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
let mut claimed = Vec::with_capacity(rows.len());
for row in &rows {
match readable(row) {
Some(job) => claimed.push(job),
None => self.abandon_unreadable(row).await?,
}
}
Ok(claimed)
}
async fn finish(&self, id: &JobId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM jobs WHERE id = ?")
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn release(&self, id: &JobId, reason: &str) -> Result<(), DomainError> {
self.settle(id, JobStatus::Pending, reason).await
}
async fn exhaust(&self, id: &JobId, reason: &str) -> Result<(), DomainError> {
self.settle(id, JobStatus::Exhausted, reason).await
}
async fn reclaim_stalled(&self, stalled_after_seconds: i64) -> Result<u64, DomainError> {
let stalled_before =
chrono::Utc::now() - chrono::Duration::seconds(stalled_after_seconds.max(0));
let reclaimed = sqlx::query(
"UPDATE jobs SET status = ?, updated_at = ?
WHERE status = ? AND updated_at <= ?",
)
.bind(JobStatus::Pending.name())
.bind(chrono::Utc::now().to_rfc3339())
.bind(JobStatus::Running.name())
.bind(stalled_before.to_rfc3339())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(reclaimed.rows_affected())
}
}
impl SqliteJobQueueRepository {
async fn abandon_unreadable(&self, row: &JobRow) -> Result<(), DomainError> {
sqlx::query("UPDATE jobs SET status = ?, last_error = ?, updated_at = ? WHERE id = ?")
.bind(JobStatus::Exhausted.name())
.bind("this job is stored in a shape this build cannot read")
.bind(chrono::Utc::now().to_rfc3339())
.bind(&row.id)
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn settle(&self, id: &JobId, status: JobStatus, reason: &str) -> Result<(), DomainError> {
sqlx::query(
"UPDATE jobs SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
WHERE id = ?",
)
.bind(status.name())
.bind(reason)
.bind(chrono::Utc::now().to_rfc3339())
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
#[async_trait::async_trait]
impl domain::ports::JobQueueQueryPort for SqliteJobQueueRepository {
async fn find_exhausted(&self, most: usize) -> Result<Vec<Job>, DomainError> {
let sql =
format!("SELECT {COLUMNS} FROM jobs WHERE status = ? ORDER BY updated_at DESC LIMIT ?");
let rows: Vec<JobRow> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
.bind(JobStatus::Exhausted.name())
.bind(most as i64)
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
}

View File

@@ -0,0 +1,41 @@
use domain::job::{Job, JobData, JobId, JobKind, JobStatus, JobSubject};
#[derive(sqlx::FromRow)]
pub struct JobRow {
pub id: String,
pub kind: String,
pub subject: String,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub enqueued_at: String,
pub updated_at: String,
}
pub fn readable(row: &JobRow) -> Option<Job> {
let job = row_to_job(row);
if job.is_none() {
tracing::warn!(
job_id = %row.id,
kind = %row.kind,
status = %row.status,
"skipped a stored job this build cannot read"
);
}
job
}
fn row_to_job(row: &JobRow) -> Option<Job> {
Some(Job::from_persistence(JobData {
id: JobId::from_uuid(row.id.parse().ok()?),
kind: JobKind::from_name(&row.kind)?,
subject: JobSubject::from_key(&row.subject)?,
status: JobStatus::from_name(&row.status)?,
attempts: u32::try_from(row.attempts).ok()?,
last_error: row.last_error.clone(),
enqueued_at: row.enqueued_at.parse().ok()?,
updated_at: row.updated_at.parse().ok()?,
}))
}

View File

@@ -0,0 +1,58 @@
use sqlx::SqlitePool;
use domain::entry::MoodEntryId;
use domain::errors::DomainError;
use domain::location::Coordinates;
use domain::ports::UnwatchedPlace;
use super::super::shared::db_err;
pub struct SqliteWeatherBacklogRepository {
pool: SqlitePool,
}
impl SqliteWeatherBacklogRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct UnwatchedPlaceRow {
entry_id: String,
latitude: f64,
longitude: f64,
logged_at: String,
}
#[async_trait::async_trait]
impl domain::ports::WeatherBacklogQueryPort for SqliteWeatherBacklogRepository {
async fn find_places_without_weather(
&self,
most: usize,
) -> Result<Vec<UnwatchedPlace>, DomainError> {
let rows: Vec<UnwatchedPlaceRow> = sqlx::query_as(
"SELECT l.entry_id, l.latitude, l.longitude, e.logged_at
FROM entry_location l
JOIN mood_entries e ON e.id = l.entry_id
LEFT JOIN entry_weather w ON w.entry_id = l.entry_id
WHERE w.entry_id IS NULL
ORDER BY e.logged_at DESC
LIMIT ?",
)
.bind(i64::try_from(most).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
}
fn readable(row: &UnwatchedPlaceRow) -> Option<UnwatchedPlace> {
Some(UnwatchedPlace {
entry_id: MoodEntryId::from_uuid(row.entry_id.parse().ok()?),
coordinates: Coordinates::from_persistence(row.latitude, row.longitude),
logged_at: row.logged_at.parse().ok()?,
})
}

View File

@@ -1,21 +1,43 @@
pub mod shared;
mod activity;
mod api_token;
mod cascade;
mod cycle;
mod daily_metric;
mod dimension;
mod entry;
mod job;
mod provider_connection;
mod push_subscription;
mod refresh_session;
mod rejection;
mod reminder;
mod user;
pub use activity::{SqliteActivityCommandRepository, SqliteActivityQueryRepository};
pub use api_token::{SqliteApiTokenCommandRepository, SqliteApiTokenQueryRepository};
pub use cascade::SqliteCascadeDeleteRepository;
pub use cycle::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};
pub use daily_metric::{SqliteDailyMetricCommandRepository, SqliteDailyMetricQueryRepository};
pub use dimension::{
SqliteContentDimensionRepository, SqliteLocationDimensionRepository,
SqliteRelationDimensionRepository, SqliteSongDimensionRepository,
SqliteWeatherDimensionRepository,
};
pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
pub use job::{
SqliteJobQueueRepository, SqliteRecordingBackfillRepository, SqliteWeatherBacklogRepository,
};
pub use provider_connection::{
SqliteProviderConnectionCommandRepository, SqliteProviderConnectionQueryRepository,
};
pub use push_subscription::{
SqlitePushSubscriptionCommandRepository, SqlitePushSubscriptionQueryRepository,
};
pub use refresh_session::{
SqliteRefreshSessionCommandRepository, SqliteRefreshSessionQueryRepository,
};
pub use rejection::SqliteRejectionRepository;
pub use reminder::{SqliteReminderCommandRepository, SqliteReminderQueryRepository};
pub use user::{SqliteUserCommandRepository, SqliteUserQueryRepository};

View File

@@ -0,0 +1,51 @@
use sqlx::SqlitePool;
use domain::errors::DomainError;
use domain::provider::{ProviderConnection, ProviderName};
use domain::user::UserId;
use super::super::shared::db_err;
pub struct SqliteProviderConnectionCommandRepository {
pool: SqlitePool,
}
impl SqliteProviderConnectionCommandRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::ProviderConnectionCommandPort for SqliteProviderConnectionCommandRepository {
async fn save(&self, connection: &ProviderConnection) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO provider_connections (id, user_id, provider, credential, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, provider) DO UPDATE SET
credential = excluded.credential, updated_at = excluded.updated_at",
)
.bind(connection.id().value().to_string())
.bind(connection.user_id().value().to_string())
.bind(connection.provider().value())
.bind(connection.credential().value())
.bind(connection.created_at().to_rfc3339())
.bind(connection.updated_at().to_rfc3339())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn delete(&self, user_id: &UserId, provider: &ProviderName) -> Result<(), DomainError> {
sqlx::query("DELETE FROM provider_connections WHERE user_id = ? AND provider = ?")
.bind(user_id.value().to_string())
.bind(provider.value())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteProviderConnectionCommandRepository;
pub use query::SqliteProviderConnectionQueryRepository;

View File

@@ -0,0 +1,50 @@
use sqlx::SqlitePool;
use domain::errors::DomainError;
use domain::provider::{ProviderConnection, ProviderName};
use domain::user::UserId;
use super::super::shared::db_err;
use super::rows::{ProviderConnectionRow, row_to_connection};
pub struct SqliteProviderConnectionQueryRepository {
pool: SqlitePool,
}
impl SqliteProviderConnectionQueryRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::ProviderConnectionQueryPort for SqliteProviderConnectionQueryRepository {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ProviderConnection>, DomainError> {
let rows = sqlx::query_as::<_, ProviderConnectionRow>(
"SELECT * FROM provider_connections WHERE user_id = ? ORDER BY provider",
)
.bind(user_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
rows.into_iter().map(row_to_connection).collect()
}
async fn find_by_user_and_provider(
&self,
user_id: &UserId,
provider: &ProviderName,
) -> Result<Option<ProviderConnection>, DomainError> {
let row = sqlx::query_as::<_, ProviderConnectionRow>(
"SELECT * FROM provider_connections WHERE user_id = ? AND provider = ?",
)
.bind(user_id.value().to_string())
.bind(provider.value())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
row.map(row_to_connection).transpose()
}
}

View File

@@ -0,0 +1,31 @@
use domain::errors::DomainError;
use domain::provider::{
EncryptedCredential, ProviderConnection, ProviderConnectionData, ProviderConnectionId,
ProviderName,
};
use domain::user::UserId;
#[derive(sqlx::FromRow)]
pub struct ProviderConnectionRow {
pub id: String,
pub user_id: String,
pub provider: String,
pub credential: Vec<u8>,
pub created_at: String,
pub updated_at: String,
}
pub fn row_to_connection(row: ProviderConnectionRow) -> Result<ProviderConnection, DomainError> {
let malformed = || DomainError::InvalidInput("stored provider connection is malformed".into());
Ok(ProviderConnection::from_persistence(
ProviderConnectionData {
id: ProviderConnectionId::from_uuid(row.id.parse().map_err(|_| malformed())?),
user_id: UserId::from_uuid(row.user_id.parse().map_err(|_| malformed())?),
provider: ProviderName::from_persistence(row.provider),
credential: EncryptedCredential::from_persistence(row.credential),
created_at: row.created_at.parse().map_err(|_| malformed())?,
updated_at: row.updated_at.parse().map_err(|_| malformed())?,
},
))
}

View File

@@ -0,0 +1,4 @@
mod repository;
mod rows;
pub use repository::SqliteRejectionRepository;

View File

@@ -0,0 +1,100 @@
use sqlx::SqlitePool;
use domain::errors::DomainError;
use domain::rejection::RejectedMetric;
use domain::user::UserId;
use super::super::shared::db_err;
use super::rows::{RejectionRow, readable};
pub struct SqliteRejectionRepository {
pool: SqlitePool,
kept_per_user: usize,
}
impl SqliteRejectionRepository {
pub fn new(pool: SqlitePool, kept_per_user: usize) -> Self {
Self {
pool,
kept_per_user,
}
}
}
#[async_trait::async_trait]
impl domain::ports::RejectionCommandPort for SqliteRejectionRepository {
async fn record(&self, rejections: &[RejectedMetric]) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await.map_err(db_err)?;
for rejected in rejections {
sqlx::query(
"INSERT INTO metric_rejections
(id, user_id, origin, provider, date, kind, value, reason, recorded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(rejected.id().value().to_string())
.bind(rejected.user_id().value().to_string())
.bind(rejected.origin().name())
.bind(rejected.detail().provider().map(|name| name.value()))
.bind(rejected.detail().date().map(|date| date.to_string()))
.bind(rejected.detail().kind())
.bind(rejected.detail().value())
.bind(rejected.reason())
.bind(rejected.recorded_at().to_rfc3339())
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
for owner in owners(rejections) {
sqlx::query(
"DELETE FROM metric_rejections WHERE user_id = ? AND id NOT IN (
SELECT id FROM metric_rejections WHERE user_id = ?
ORDER BY recorded_at DESC, id DESC LIMIT ?
)",
)
.bind(&owner)
.bind(&owner)
.bind(self.kept_per_user as i64)
.execute(&mut *tx)
.await
.map_err(db_err)?;
}
tx.commit().await.map_err(db_err)?;
Ok(())
}
}
#[async_trait::async_trait]
impl domain::ports::RejectionQueryPort for SqliteRejectionRepository {
async fn find_recent_by_user(
&self,
user_id: &UserId,
) -> Result<Vec<RejectedMetric>, DomainError> {
let rows: Vec<RejectionRow> = sqlx::query_as(
"SELECT id, user_id, origin, provider, date, kind, value, reason, recorded_at
FROM metric_rejections WHERE user_id = ?
ORDER BY recorded_at DESC, id DESC LIMIT ?",
)
.bind(user_id.value().to_string())
.bind(self.kept_per_user as i64)
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
}
fn owners(rejections: &[RejectedMetric]) -> Vec<String> {
let mut owners: Vec<String> = rejections
.iter()
.map(|rejected| rejected.user_id().value().to_string())
.collect();
owners.sort();
owners.dedup();
owners
}

View File

@@ -0,0 +1,50 @@
use domain::entry::Date;
use domain::provider::ProviderName;
use domain::rejection::{
RejectedMetric, RejectedMetricData, RejectionDetail, RejectionId, RejectionOrigin,
};
use domain::user::UserId;
#[derive(sqlx::FromRow)]
pub struct RejectionRow {
pub id: String,
pub user_id: String,
pub origin: String,
pub provider: Option<String>,
pub date: Option<String>,
pub kind: String,
pub value: Option<i64>,
pub reason: String,
pub recorded_at: String,
}
pub fn readable(row: &RejectionRow) -> Option<RejectedMetric> {
let origin = RejectionOrigin::from_name(&row.origin);
if origin.is_none() {
tracing::warn!(
rejection_id = %row.id,
origin = %row.origin,
"skipped a stored rejection this build cannot read"
);
}
let date = match &row.date {
None => None,
Some(day) => Some(Date::from_persistence(day.parse().ok()?)),
};
Some(RejectedMetric::from_persistence(RejectedMetricData {
id: RejectionId::from_uuid(row.id.parse().ok()?),
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
origin: origin?,
detail: RejectionDetail::new(
row.provider.clone().map(ProviderName::from_persistence),
date,
row.kind.clone(),
row.value,
),
reason: row.reason.clone(),
recorded_at: row.recorded_at.parse().ok()?,
}))
}

View File

@@ -24,7 +24,7 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.map(UserRow::into_domain))
row.map(UserRow::into_domain).transpose()
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
@@ -33,7 +33,7 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.map(UserRow::into_domain))
row.map(UserRow::into_domain).transpose()
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -42,6 +42,6 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.map(UserRow::into_domain))
row.map(UserRow::into_domain).transpose()
}
}

View File

@@ -1,3 +1,4 @@
use domain::errors::DomainError;
use domain::user::{
DisplayName, Email, PasswordHash, Timezone, User, UserData, UserId, UserRole, Username,
};
@@ -16,22 +17,37 @@ pub struct UserRow {
}
impl UserRow {
pub fn into_domain(self) -> User {
pub fn into_domain(self) -> Result<User, DomainError> {
let malformed = || DomainError::InvalidInput("stored user row is malformed".into());
let role = match self.role.as_str() {
"Admin" => UserRole::Admin,
_ => UserRole::User,
};
User::from_persistence(UserData {
id: UserId::from_uuid(self.id.parse().unwrap()),
Ok(User::from_persistence(UserData {
id: UserId::from_uuid(self.id.parse().map_err(|_| malformed())?),
username: Username::from_persistence(self.username),
email: Email::from_persistence(self.email),
password_hash: PasswordHash::new(self.password_hash),
display_name: self.display_name.map(DisplayName::from_persistence),
timezone: self.timezone.map(Timezone::from_persistence),
timezone: self.timezone.as_deref().and_then(resolve_timezone),
role,
created_at: self.created_at.parse().unwrap(),
updated_at: self.updated_at.parse().unwrap(),
})
created_at: self.created_at.parse().map_err(|_| malformed())?,
updated_at: self.updated_at.parse().map_err(|_| malformed())?,
}))
}
}
fn resolve_timezone(stored: &str) -> Option<Timezone> {
match Timezone::from_persistence(stored) {
Ok(timezone) => Some(timezone),
Err(_) => {
tracing::warn!(
timezone = stored,
"stored timezone is not in the IANA database, treating it as unset"
);
None
}
}
}

View File

@@ -0,0 +1,198 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::api_token::{ApiToken, TokenDigest};
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, CascadeDeletePort, UserCommandPort};
use domain::provider::ProviderName;
use domain::testing::test_user;
use domain::user::{User, UserId};
use sqlite::repositories::{
SqliteApiTokenCommandRepository, SqliteApiTokenQueryRepository, SqliteCascadeDeleteRepository,
SqliteUserCommandRepository,
};
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user)
}
fn a_token(owner: &UserId, name: &str, digest: &str) -> ApiToken {
ApiToken::new(
owner.clone(),
ProviderName::new(name).unwrap(),
TokenDigest::from_persistence(digest.into()),
)
}
#[tokio::test]
async fn a_token_is_found_by_the_digest_of_its_secret() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "iphone-shortcuts", "abc123");
SqliteApiTokenCommandRepository::new(pool.clone())
.save(&token)
.await
.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.expect("the token should be found");
assert_eq!(found.id(), token.id());
assert_eq!(found.name().value(), "iphone-shortcuts");
assert!(found.last_used_at().is_none());
}
#[tokio::test]
async fn a_digest_nobody_stored_finds_nothing() {
let (pool, _) = a_pool_with_a_user().await;
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("nothing".into()))
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn two_tokens_of_one_account_cannot_share_a_name() {
let (pool, user) = a_pool_with_a_user().await;
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens
.save(&a_token(user.id(), "iphone-shortcuts", "first"))
.await
.unwrap();
let again = tokens
.save(&a_token(user.id(), "iphone-shortcuts", "second"))
.await;
let refusal = again
.expect_err("a duplicate name must be refused")
.to_string();
assert!(
refusal.contains("already exists"),
"the refusal should say what is wrong, got: {refusal}"
);
}
#[tokio::test]
async fn using_a_token_is_recorded_against_it() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
tokens.mark_used(token.id()).await.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.unwrap();
assert!(found.last_used_at().is_some());
}
#[tokio::test]
async fn revoking_a_token_removes_it_for_good() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
tokens.revoke(user.id(), token.id()).await.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn a_token_belonging_to_someone_else_cannot_be_revoked() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
let attempt = tokens.revoke(&UserId::generate(), token.id()).await;
assert!(attempt.is_err());
assert!(
SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn a_row_with_a_scope_this_build_does_not_know_authenticates_nothing() {
let (pool, user) = a_pool_with_a_user().await;
sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user.id().value().to_string())
.bind("legacy")
.bind("abc123")
.bind("readEverything")
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap();
assert!(
found.is_none(),
"an unreadable scope must not grant anything"
);
}
#[tokio::test]
async fn deleting_an_account_takes_its_tokens_with_it() {
let (pool, user) = a_pool_with_a_user().await;
SqliteApiTokenCommandRepository::new(pool.clone())
.save(&a_token(user.id(), "tasker", "abc123"))
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM api_tokens")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining.0, 0);
}

View File

@@ -0,0 +1,285 @@
use sqlx::SqlitePool;
use domain::dimension::DimensionValue;
use domain::entry::{Content, Mood, MoodEntry, MoodEntryId};
use domain::location::Coordinates;
use domain::ports::{EntryDimensionPort, MoodEntryCommandPort, UserCommandPort};
use domain::song::Song;
use domain::testing::test_user;
use domain::user::{User, UserId};
use sqlite::repositories::{
SqliteContentDimensionRepository, SqliteEntryCommandRepository,
SqliteLocationDimensionRepository, SqliteSongDimensionRepository, SqliteUserCommandRepository,
};
const EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY: [&str; 4] = [
"entry_content",
"entry_location",
"entry_song",
"entry_activities",
];
async fn a_file() -> String {
let name = format!("k-mood-cascade-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
async fn an_entry_with_every_dimension(pool: &SqlitePool) -> (User, MoodEntryId) {
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
let entry = MoodEntry::new(
user.id().clone(),
Mood::Good,
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
SqliteContentDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Content(Content::new("a note").unwrap())],
)
.await
.unwrap();
SqliteLocationDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Location(
Coordinates::new(52.2297, 21.0122).unwrap(),
)],
)
.await
.unwrap();
SqliteSongDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Song(
Song::new("Teardrop", "Massive Attack", None, None).unwrap(),
)],
)
.await
.unwrap();
(user, entry.id().clone())
}
async fn rows_in(pool: &SqlitePool, table: &str) -> i64 {
let counted: (i64,) =
sqlx::query_as(sqlx::AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}")))
.fetch_one(pool)
.await
.unwrap();
counted.0
}
async fn dimension_rows(pool: &SqlitePool) -> i64 {
let mut total = 0;
for table in EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY {
total += rows_in(pool, table).await;
}
total
}
#[tokio::test]
async fn foreign_keys_are_switched_on_for_every_connection_the_pool_hands_out() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
for _ in 0..5 {
let on: (i64,) = sqlx::query_as("PRAGMA foreign_keys")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
on.0, 1,
"sqlite ignores ON DELETE CASCADE silently when foreign keys are off"
);
}
remove(&path);
}
#[tokio::test]
async fn deleting_an_entry_really_does_remove_its_dimension_rows() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (_, entry_id) = an_entry_with_every_dimension(&pool).await;
assert!(
dimension_rows(&pool).await >= 3,
"the dimensions were not stored"
);
SqliteEntryCommandRepository::new(pool.clone())
.delete(&entry_id)
.await
.unwrap();
assert_eq!(
dimension_rows(&pool).await,
0,
"ON DELETE CASCADE did not fire"
);
remove(&path);
}
#[tokio::test]
async fn a_dimension_row_cannot_be_written_for_an_entry_that_does_not_exist() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let refused = SqliteContentDimensionRepository::new(pool.clone())
.save(
&MoodEntryId::generate(),
&[DimensionValue::Content(Content::new("orphan").unwrap())],
)
.await;
assert!(
refused.is_err(),
"a foreign key that is not enforced is not a foreign key"
);
remove(&path);
}
#[tokio::test]
async fn deleting_an_account_removes_everything_that_hangs_off_it() {
use domain::ports::CascadeDeletePort;
use sqlite::repositories::SqliteCascadeDeleteRepository;
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (user, _) = an_entry_with_every_dimension(&pool).await;
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
assert_eq!(rows_in(&pool, "users").await, 0);
assert_eq!(rows_in(&pool, "mood_entries").await, 0);
assert_eq!(
dimension_rows(&pool).await,
0,
"the cascade must reach through the entry to its dimensions"
);
remove(&path);
}
#[tokio::test]
async fn the_oldest_tables_do_not_cascade_from_users_which_is_why_they_are_deleted_by_hand() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (user, _) = an_entry_with_every_dimension(&pool).await;
let refused = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user.id().value().to_string())
.execute(&pool)
.await;
assert!(
refused.is_err(),
"mood_entries references users without ON DELETE CASCADE, so the repository must \
delete the older tables itself. If this now succeeds, the schema gained a cascade \
and those manual deletes are redundant."
);
remove(&path);
}
#[tokio::test]
async fn every_table_added_since_does_cascade_from_users() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let cascading = [
"provider_connections",
"daily_metrics",
"api_tokens",
"metric_rejections",
"cycle_starts",
"user_preferences",
];
for table in cascading {
let sql: (String,) =
sqlx::query_as("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.fetch_one(&pool)
.await
.unwrap();
assert!(
sql.0.contains("REFERENCES users(id) ON DELETE CASCADE"),
"{table} should be removed by the database when its account goes"
);
}
remove(&path);
}
#[tokio::test]
async fn an_entry_cannot_belong_to_an_account_that_does_not_exist() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let orphan = MoodEntry::new(
UserId::generate(),
Mood::Good,
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
);
let refused = SqliteEntryCommandRepository::new(pool.clone())
.save(&orphan)
.await;
assert!(refused.is_err(), "an entry with no owner should be refused");
remove(&path);
}
fn remove(path: &str) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}

View File

@@ -0,0 +1,179 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::Date;
use domain::ports::{
CascadeDeletePort, CycleStartCommandPort, CycleStartQueryPort, UserCommandPort,
UserPreferencesCommandPort, UserPreferencesQueryPort,
};
use domain::testing::test_user;
use domain::user::{User, UserId, UserPreferences};
use sqlite::repositories::{
SqliteCascadeDeleteRepository, SqliteCycleStartRepository, SqliteUserCommandRepository,
SqliteUserPreferencesRepository,
};
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user)
}
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
#[tokio::test]
async fn recording_the_same_start_twice_leaves_one_row() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
assert_eq!(starts.find_by_user(user.id()).await.unwrap().len(), 1);
}
#[tokio::test]
async fn starts_come_back_oldest_first() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
for day in ["2026-02-26", "2026-01-01", "2026-01-29"] {
starts.record(user.id(), &on(day)).await.unwrap();
}
let found: Vec<String> = starts
.find_by_user(user.id())
.await
.unwrap()
.iter()
.map(|date| date.to_string())
.collect();
assert_eq!(found, ["2026-01-01", "2026-01-29", "2026-02-26"]);
}
#[tokio::test]
async fn forgetting_a_start_removes_only_that_one() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
starts.record(user.id(), &on("2026-01-29")).await.unwrap();
starts.forget(user.id(), &on("2026-01-01")).await.unwrap();
let found: Vec<String> = starts
.find_by_user(user.id())
.await
.unwrap()
.iter()
.map(|date| date.to_string())
.collect();
assert_eq!(found, ["2026-01-29"]);
}
#[tokio::test]
async fn one_accounts_starts_are_not_anothers() {
let (pool, mine) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(mine.id(), &on("2026-01-01")).await.unwrap();
assert!(
starts
.find_by_user(&UserId::generate())
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn an_account_with_no_stored_preference_has_none_to_read() {
let (pool, user) = a_pool_with_a_user().await;
let found = SqliteUserPreferencesRepository::new(pool.clone())
.find_by_user(user.id())
.await
.unwrap();
assert!(
found.is_none(),
"the default belongs to the domain, not the row"
);
}
#[tokio::test]
async fn a_preference_survives_being_written_twice() {
let (pool, user) = a_pool_with_a_user().await;
let preferences = SqliteUserPreferencesRepository::new(pool.clone());
let mut held = UserPreferences::off_by_default(user.id().clone());
held.track_cycle(true);
preferences.save(&held).await.unwrap();
held.track_cycle(false);
preferences.save(&held).await.unwrap();
let found = preferences.find_by_user(user.id()).await.unwrap().unwrap();
assert!(!found.tracks_cycle());
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
}
#[tokio::test]
async fn deleting_an_account_takes_its_cycle_and_preferences_with_it() {
let (pool, user) = a_pool_with_a_user().await;
SqliteCycleStartRepository::new(pool.clone())
.record(user.id(), &on("2026-01-01"))
.await
.unwrap();
let mut held = UserPreferences::off_by_default(user.id().clone());
held.track_cycle(true);
SqliteUserPreferencesRepository::new(pool.clone())
.save(&held)
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
let starts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
.fetch_one(&pool)
.await
.unwrap();
let preferences: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(starts.0, 0);
assert_eq!(preferences.0, 0);
}

View File

@@ -0,0 +1,445 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::{Date, DateSpan};
use domain::metric::{DailyMetric, Hrv, MetricKind, MetricValue, Source, Steps};
use domain::ports::{
CascadeDeletePort, DailyMetricCommandPort, DailyMetricQueryPort, UserCommandPort,
};
use domain::provider::ProviderName;
use domain::testing::test_user;
use domain::user::UserId;
use sqlite::repositories::{
SqliteCascadeDeleteRepository, SqliteDailyMetricCommandRepository,
SqliteDailyMetricQueryRepository, SqliteRejectionRepository, SqliteUserCommandRepository,
};
const REJECTIONS_KEPT: usize = 200;
fn a_trace(pool: sqlx::SqlitePool) -> std::sync::Arc<dyn domain::ports::RejectionCommandPort> {
std::sync::Arc::new(SqliteRejectionRepository::new(pool, REJECTIONS_KEPT))
}
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, UserId) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user.id().clone())
}
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
fn steps(count: u32) -> MetricValue {
MetricValue::Steps(Steps::new(count).unwrap())
}
fn from_provider() -> Source {
Source::Provider(ProviderName::new("healthkit").unwrap())
}
async fn stored_for(pool: &sqlx::SqlitePool, user_id: &UserId, day: &str) -> Vec<DailyMetric> {
let span = DateSpan::new(on(day), on(day)).unwrap();
SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(user_id, &span)
.await
.unwrap()
}
#[tokio::test]
async fn restating_a_date_replaces_the_row_rather_than_adding_one() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for count in [8_000, 8_412] {
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(count),
Source::Manual,
)])
.await
.unwrap();
}
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
}
#[tokio::test]
async fn an_import_leaves_a_count_the_user_stated_alone() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
assert_eq!(stored[0].source(), &Source::Manual);
}
#[tokio::test]
async fn a_count_the_user_states_replaces_what_a_provider_reported() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored[0].value(), &steps(8_412));
assert_eq!(stored[0].source(), &Source::Manual);
}
#[tokio::test]
async fn a_provider_is_remembered_as_the_source() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored[0].source(), &from_provider());
}
#[tokio::test]
async fn a_row_of_a_kind_this_build_does_not_know_is_skipped_and_its_neighbours_survive() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind("telepathy")
.bind(42)
.execute(&pool)
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
}
#[tokio::test]
async fn a_value_outside_its_range_is_skipped_and_its_neighbours_survive() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-19"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind("steps")
.bind(900_000)
.execute(&pool)
.await
.unwrap();
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].date(), &on("2026-08-19"));
}
#[tokio::test]
async fn days_outside_the_span_are_not_returned() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for day in ["2026-07-31", "2026-08-01", "2026-08-31", "2026-09-01"] {
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on(day),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
}
let span = DateSpan::new(on("2026-08-01"), on("2026-08-31")).unwrap();
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
let days: Vec<String> = stored
.iter()
.map(|metric| metric.date().to_string())
.collect();
assert_eq!(days, ["2026-08-01", "2026-08-31"]);
}
#[tokio::test]
async fn clearing_a_users_data_removes_their_days() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_all_user_data(&user_id)
.await
.unwrap();
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
}
#[tokio::test]
async fn deleting_an_account_removes_its_days() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(&user_id)
.await
.unwrap();
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM daily_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining.0, 0);
}
#[tokio::test]
async fn clearing_a_kind_deletes_only_that_row() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[
DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
),
DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
MetricValue::Hrv(Hrv::new(61).unwrap()),
Source::Manual,
),
DailyMetric::new(
user_id.clone(),
on("2026-08-21"),
steps(9_000),
Source::Manual,
),
])
.await
.unwrap();
metrics
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
let remaining = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].kind(), MetricKind::Hrv);
assert_eq!(stored_for(&pool, &user_id, "2026-08-21").await.len(), 1);
}
#[tokio::test]
async fn clearing_a_kind_that_was_never_stored_is_not_an_error() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
}
#[tokio::test]
async fn clearing_a_kind_leaves_another_accounts_day_alone() {
let (pool, mine) = a_pool_with_a_user().await;
let theirs = test_user("bob");
SqliteUserCommandRepository::new(pool.clone())
.save(&theirs)
.await
.unwrap();
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for owner in [&mine, theirs.id()] {
metrics
.save(&[DailyMetric::new(
owner.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
}
metrics
.delete(&mine, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
assert!(stored_for(&pool, &mine, "2026-08-20").await.is_empty());
assert_eq!(
stored_for(&pool, theirs.id(), "2026-08-20").await.len(),
1,
"the other account's day was cleared too"
);
}
#[tokio::test]
async fn a_stored_row_this_build_cannot_read_lands_in_the_rejection_trace() {
use domain::ports::RejectionQueryPort;
use domain::rejection::RejectionOrigin;
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-19"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
for (kind, value) in [("telepathy", 42), ("steps", 900_000)] {
sqlx::query(
"INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)",
)
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind(kind)
.bind(value)
.execute(&pool)
.await
.unwrap();
}
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
let readable = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
let trace = SqliteRejectionRepository::new(pool.clone(), REJECTIONS_KEPT)
.find_recent_by_user(&user_id)
.await
.unwrap();
assert_eq!(readable.len(), 1, "the good row still comes back");
assert_eq!(trace.len(), 2, "both unreadable rows are recorded");
assert!(
trace
.iter()
.all(|entry| entry.origin() == RejectionOrigin::StoredRow)
);
let mut kinds: Vec<&str> = trace.iter().map(|entry| entry.detail().kind()).collect();
kinds.sort_unstable();
assert_eq!(kinds, ["steps", "telepathy"]);
}

View File

@@ -0,0 +1,213 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::MoodEntryId;
use domain::job::{JobKind, JobStatus, JobSubject};
use domain::ports::{JobQueueCommandPort, JobQueueQueryPort};
use sqlite::repositories::SqliteJobQueueRepository;
const KIND: JobKind = JobKind::BackfillRecordingIdentity;
async fn a_queue() -> (sqlx::SqlitePool, SqliteJobQueueRepository) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
(pool.clone(), SqliteJobQueueRepository::new(pool))
}
fn about(entry_id: &MoodEntryId) -> JobSubject {
JobSubject::Entry(entry_id.clone())
}
async fn status_of(pool: &sqlx::SqlitePool) -> Vec<(String, i64, Option<String>)> {
sqlx::query_as("SELECT status, attempts, last_error FROM jobs ORDER BY enqueued_at")
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn the_same_work_cannot_be_queued_twice() {
let (pool, queue) = a_queue().await;
let subject = about(&MoodEntryId::generate());
assert!(queue.enqueue(KIND, &subject).await.unwrap());
assert!(!queue.enqueue(KIND, &subject).await.unwrap());
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
}
#[tokio::test]
async fn claiming_marks_a_job_as_running_so_another_worker_leaves_it_alone() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 10).await.unwrap();
let claimed_again = queue.claim(KIND, 10).await.unwrap();
assert_eq!(claimed.len(), 1);
assert!(
claimed_again.is_empty(),
"a running job is not claimed twice"
);
assert_eq!(status_of(&pool).await[0].0, JobStatus::Running.name());
}
#[tokio::test]
async fn claiming_is_bounded_and_takes_the_oldest_first() {
let (_, queue) = a_queue().await;
for _ in 0..5 {
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
}
let claimed = queue.claim(KIND, 2).await.unwrap();
assert_eq!(claimed.len(), 2);
}
#[tokio::test]
async fn finishing_a_job_removes_it() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue.finish(claimed[0].id()).await.unwrap();
assert!(status_of(&pool).await.is_empty());
}
#[tokio::test]
async fn releasing_a_job_counts_the_attempt_and_keeps_the_reason() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue
.release(claimed[0].id(), "musicbrainz timed out")
.await
.unwrap();
let stored = status_of(&pool).await;
assert_eq!(stored[0].0, JobStatus::Pending.name());
assert_eq!(stored[0].1, 1);
assert_eq!(stored[0].2.as_deref(), Some("musicbrainz timed out"));
assert_eq!(
queue.claim(KIND, 1).await.unwrap().len(),
1,
"and it is claimable again"
);
}
#[tokio::test]
async fn an_exhausted_job_is_never_claimed_but_can_still_be_seen() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue.exhaust(claimed[0].id(), "gave up").await.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
let visible = queue.find_exhausted(10).await.unwrap();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].last_error(), Some("gave up"));
}
#[tokio::test]
async fn a_job_left_running_by_a_dead_worker_becomes_claimable_again() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
queue.claim(KIND, 1).await.unwrap();
let reclaimed = queue.reclaim_stalled(0).await.unwrap();
assert_eq!(reclaimed, 1);
assert_eq!(queue.claim(KIND, 1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_job_still_being_worked_on_is_not_reclaimed() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
queue.claim(KIND, 1).await.unwrap();
let reclaimed = queue.reclaim_stalled(300).await.unwrap();
assert_eq!(reclaimed, 0, "five minutes have not passed");
}
#[tokio::test]
async fn a_row_of_a_kind_this_build_does_not_know_is_never_claimed() {
let (pool, queue) = a_queue().await;
sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, 'summonRain', ?, 'pending', 0, NULL, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
}
#[tokio::test]
async fn a_job_this_build_cannot_read_is_given_up_on_rather_than_claimed_forever() {
let (pool, queue) = a_queue().await;
sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, 'backfillRecordingIdentity', ?, 'pending', 0, NULL, 'the day before yesterday', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
let stored = status_of(&pool).await;
assert_eq!(
stored[0].0,
JobStatus::Exhausted.name(),
"an unreadable job must stop churning through claim and reclaim"
);
assert!(stored[0].2.is_some(), "and must say why it was given up on");
assert_eq!(queue.reclaim_stalled(0).await.unwrap(), 0);
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
}

View File

@@ -0,0 +1,142 @@
use sqlx::sqlite::SqlitePoolOptions;
async fn fresh_pool() -> sqlx::SqlitePool {
SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
}
async fn column_names(pool: &sqlx::SqlitePool, table: &str) -> Vec<String> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info(?) ORDER BY cid")
.bind(table)
.fetch_all(pool)
.await
.unwrap();
rows.into_iter().map(|row| row.0).collect()
}
async fn applied_count(pool: &sqlx::SqlitePool) -> i64 {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM schema_migrations")
.fetch_one(pool)
.await
.unwrap();
row.0
}
#[tokio::test]
async fn every_migration_applies_exactly_once_however_often_the_server_restarts() {
let pool = fresh_pool().await;
sqlite::run_migrations(&pool).await.unwrap();
let after_first_boot = applied_count(&pool).await;
sqlite::run_migrations(&pool).await.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let after_further_boots = applied_count(&pool).await;
assert!(after_first_boot > 0, "no migrations were applied at all");
assert_eq!(after_first_boot, after_further_boots);
}
#[tokio::test]
async fn mood_entries_no_longer_carries_content() {
let pool = fresh_pool().await;
sqlite::run_migrations(&pool).await.unwrap();
assert_eq!(
column_names(&pool, "mood_entries").await,
[
"id",
"user_id",
"mood",
"logged_at",
"created_at",
"updated_at"
]
);
assert_eq!(
column_names(&pool, "entry_content").await,
["entry_id", "content"]
);
}
fn a_shared_file() -> String {
let name = format!("k-mood-migrating-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
fn forget(path: &str) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}
#[tokio::test]
async fn two_processes_starting_together_both_migrate_successfully() {
let path = a_shared_file();
let url = format!("sqlite://{path}");
let server = sqlite::create_pool(&url).await.unwrap();
let worker = sqlite::create_pool(&url).await.unwrap();
let (migrating_server, migrating_worker) = tokio::join!(
tokio::spawn({
let pool = server.clone();
async move { sqlite::run_migrations(&pool).await }
}),
tokio::spawn({
let pool = worker.clone();
async move { sqlite::run_migrations(&pool).await }
}),
);
let by_the_server = migrating_server.unwrap();
let by_the_worker = migrating_worker.unwrap();
assert!(
by_the_server.is_ok(),
"the server could not start alongside the worker: {by_the_server:?}"
);
assert!(
by_the_worker.is_ok(),
"the worker could not start alongside the server: {by_the_worker:?}"
);
let applied = applied_count(&server).await;
let names: Vec<(String,)> = sqlx::query_as("SELECT name FROM schema_migrations")
.fetch_all(&server)
.await
.unwrap();
assert_eq!(
applied as usize,
names.len(),
"no migration is recorded twice"
);
assert!(applied > 0);
forget(&path);
}
#[tokio::test]
async fn a_process_joining_a_migrated_database_applies_nothing() {
let path = a_shared_file();
let url = format!("sqlite://{path}");
let first = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&first).await.unwrap();
let already_applied = applied_count(&first).await;
let second = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&second).await.unwrap();
assert_eq!(applied_count(&second).await, already_applied);
forget(&path);
}

Some files were not shown because too many files have changed in this diff Show More