@@ -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"] }
|
||||
|
||||
31
crates/adapters/auth/src/api_token_secret.rs
Normal file
31
crates/adapters/auth/src/api_token_secret.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
62
crates/adapters/auth/tests/api_token_secret_test.rs
Normal file
62
crates/adapters/auth/tests/api_token_secret_test.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
15
crates/adapters/crypto/Cargo.toml
Normal file
15
crates/adapters/crypto/Cargo.toml
Normal 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"] }
|
||||
68
crates/adapters/crypto/src/chacha_cipher.rs
Normal file
68
crates/adapters/crypto/src/chacha_cipher.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
3
crates/adapters/crypto/src/lib.rs
Normal file
3
crates/adapters/crypto/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod chacha_cipher;
|
||||
|
||||
pub use chacha_cipher::ChaChaCredentialCipher;
|
||||
77
crates/adapters/crypto/tests/cipher_test.rs
Normal file
77
crates/adapters/crypto/tests/cipher_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
185
crates/adapters/exporter/src/backup.rs
Normal file
185
crates/adapters/exporter/src/backup.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
71
crates/adapters/exporter/src/extract.rs
Normal file
71
crates/adapters/exporter/src/extract.rs
Normal 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()
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
13
crates/adapters/exporter/src/shared.rs
Normal file
13
crates/adapters/exporter/src/shared.rs
Normal 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}"))
|
||||
}
|
||||
132
crates/adapters/exporter/tests/extract_test.rs
Normal file
132
crates/adapters/exporter/tests/extract_test.rs
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
53
crates/adapters/http-axum/src/extractors/metric_writer.rs
Normal file
53
crates/adapters/http-axum/src/extractors/metric_writer.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
50
crates/adapters/http-axum/src/handlers/correlations.rs
Normal file
50
crates/adapters/http-axum/src/handlers/correlations.rs
Normal 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()))
|
||||
}
|
||||
109
crates/adapters/http-axum/src/handlers/cycle.rs
Normal file
109
crates/adapters/http-axum/src/handlers/cycle.rs
Normal 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()))
|
||||
}
|
||||
117
crates/adapters/http-axum/src/handlers/data.rs
Normal file
117
crates/adapters/http-axum/src/handlers/data.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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?;
|
||||
|
||||
127
crates/adapters/http-axum/src/handlers/metrics.rs
Normal file
127
crates/adapters/http-axum/src/handlers/metrics.rs
Normal 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()))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
127
crates/adapters/http-axum/src/handlers/providers.rs
Normal file
127
crates/adapters/http-axum/src/handlers/providers.rs
Normal 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))
|
||||
})))
|
||||
}
|
||||
80
crates/adapters/http-axum/src/handlers/tokens.rs
Normal file
80
crates/adapters/http-axum/src/handlers/tokens.rs
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
83
crates/adapters/importer/src/backup_reader.rs
Normal file
83
crates/adapters/importer/src/backup_reader.rs
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
99
crates/adapters/importer/src/kmood_backup.rs
Normal file
99
crates/adapters/importer/src/kmood_backup.rs
Normal 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))
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
98
crates/adapters/importer/tests/daylio_test.rs
Normal file
98
crates/adapters/importer/tests/daylio_test.rs
Normal 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"));
|
||||
}
|
||||
17
crates/adapters/music/Cargo.toml
Normal file
17
crates/adapters/music/Cargo.toml
Normal 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"] }
|
||||
5
crates/adapters/music/src/lib.rs
Normal file
5
crates/adapters/music/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod musicbrainz;
|
||||
pub mod subsonic;
|
||||
|
||||
pub use musicbrainz::MusicBrainzRecordingLookup;
|
||||
pub use subsonic::SubsonicNowPlayingAdapter;
|
||||
62
crates/adapters/music/src/musicbrainz/client.rs
Normal file
62
crates/adapters/music/src/musicbrainz/client.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
3
crates/adapters/music/src/musicbrainz/mod.rs
Normal file
3
crates/adapters/music/src/musicbrainz/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod client;
|
||||
|
||||
pub use client::{MusicBrainzRecordingLookup, USER_AGENT, parse_first_recording};
|
||||
76
crates/adapters/music/src/subsonic/client.rs
Normal file
76
crates/adapters/music/src/subsonic/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
43
crates/adapters/music/src/subsonic/credential.rs
Normal file
43
crates/adapters/music/src/subsonic/credential.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
7
crates/adapters/music/src/subsonic/mod.rs
Normal file
7
crates/adapters/music/src/subsonic/mod.rs
Normal 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;
|
||||
62
crates/adapters/music/src/subsonic/response.rs
Normal file
62
crates/adapters/music/src/subsonic/response.rs
Normal 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)?))
|
||||
}
|
||||
30
crates/adapters/music/tests/musicbrainz_test.rs
Normal file
30
crates/adapters/music/tests/musicbrainz_test.rs
Normal 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());
|
||||
}
|
||||
81
crates/adapters/music/tests/subsonic_test.rs
Normal file
81
crates/adapters/music/tests/subsonic_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
12
crates/adapters/sqlite/src/migrations/008_api_tokens.sql
Normal file
12
crates/adapters/sqlite/src/migrations/008_api_tokens.sql
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
13
crates/adapters/sqlite/src/migrations/011_jobs.sql
Normal file
13
crates/adapters/sqlite/src/migrations/011_jobs.sql
Normal 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);
|
||||
@@ -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
|
||||
);
|
||||
80
crates/adapters/sqlite/src/repositories/api_token/command.rs
Normal file
80
crates/adapters/sqlite/src/repositories/api_token/command.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/api_token/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/api_token/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteApiTokenCommandRepository;
|
||||
pub use query::SqliteApiTokenQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/api_token/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/api_token/query.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
45
crates/adapters/sqlite/src/repositories/api_token/rows.rs
Normal file
45
crates/adapters/sqlite/src/repositories/api_token/rows.rs
Normal 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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
3
crates/adapters/sqlite/src/repositories/cycle/mod.rs
Normal file
3
crates/adapters/sqlite/src/repositories/cycle/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod repository;
|
||||
|
||||
pub use repository::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};
|
||||
103
crates/adapters/sqlite/src/repositories/cycle/repository.rs
Normal file
103
crates/adapters/sqlite/src/repositories/cycle/repository.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteDailyMetricCommandRepository;
|
||||
pub use query::SqliteDailyMetricQueryRepository;
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
33
crates/adapters/sqlite/src/repositories/daily_metric/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/daily_metric/rows.rs
Normal 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())
|
||||
}
|
||||
94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal file
94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal file
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal file
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal 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;
|
||||
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal file
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal 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)))
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
78
crates/adapters/sqlite/src/repositories/job/backfill.rs
Normal file
78
crates/adapters/sqlite/src/repositories/job/backfill.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
8
crates/adapters/sqlite/src/repositories/job/mod.rs
Normal file
8
crates/adapters/sqlite/src/repositories/job/mod.rs
Normal 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;
|
||||
160
crates/adapters/sqlite/src/repositories/job/repository.rs
Normal file
160
crates/adapters/sqlite/src/repositories/job/repository.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
41
crates/adapters/sqlite/src/repositories/job/rows.rs
Normal file
41
crates/adapters/sqlite/src/repositories/job/rows.rs
Normal 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()?,
|
||||
}))
|
||||
}
|
||||
58
crates/adapters/sqlite/src/repositories/job/weather.rs
Normal file
58
crates/adapters/sqlite/src/repositories/job/weather.rs
Normal 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()?,
|
||||
})
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteProviderConnectionCommandRepository;
|
||||
pub use query::SqliteProviderConnectionQueryRepository;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())?,
|
||||
},
|
||||
))
|
||||
}
|
||||
4
crates/adapters/sqlite/src/repositories/rejection/mod.rs
Normal file
4
crates/adapters/sqlite/src/repositories/rejection/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod repository;
|
||||
mod rows;
|
||||
|
||||
pub use repository::SqliteRejectionRepository;
|
||||
100
crates/adapters/sqlite/src/repositories/rejection/repository.rs
Normal file
100
crates/adapters/sqlite/src/repositories/rejection/repository.rs
Normal 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
|
||||
}
|
||||
50
crates/adapters/sqlite/src/repositories/rejection/rows.rs
Normal file
50
crates/adapters/sqlite/src/repositories/rejection/rows.rs
Normal 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()?,
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
crates/adapters/sqlite/tests/api_token_test.rs
Normal file
198
crates/adapters/sqlite/tests/api_token_test.rs
Normal 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);
|
||||
}
|
||||
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal 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"));
|
||||
}
|
||||
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal file
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal 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);
|
||||
}
|
||||
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal file
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal 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"]);
|
||||
}
|
||||
213
crates/adapters/sqlite/tests/job_test.rs
Normal file
213
crates/adapters/sqlite/tests/job_test.rs
Normal 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());
|
||||
}
|
||||
142
crates/adapters/sqlite/tests/migration_check.rs
Normal file
142
crates/adapters/sqlite/tests/migration_check.rs
Normal 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
Reference in New Issue
Block a user