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

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

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ config.toml
.DS_Store
spa/node_modules/
spa/dist/
.claude/

View File

@@ -215,20 +215,15 @@ Constants are only for true invariants that never change. Tunable values belong
## Comments
Zero comments unless explaining **why** something non-obvious is done.
No comments. Not even to explain why — a comment is noise that drifts out of date while the code moves on.
```rust
// YES — explains a non-obvious constraint
// BLAKE3 hash, not SHA-256, because iroh uses BLAKE3 for content addressing
let hash = blake3::hash(&audio_data);
When something non-obvious needs saying, put it somewhere that cannot rot silently:
// NO — restates what the code does
// Create a new track with the given metadata
let track = Track::new(title, artist_id, duration, hash);
- **A name.** Rename the function, the variable, or the type until the reason is visible in the code.
- **A test.** A constraint worth a comment is worth a test that fails when someone breaks it. `media_is_resolved_before_the_cascade_runs` outlives any note explaining why the order matters.
- **An ADR.** Architectural reasoning belongs in `docs/adr/`, where it is versioned and discoverable.
// NO — references the task/ticket
// Added for thesis requirement §3.2
```
This applies to doc comments too.
## Dependencies
@@ -240,24 +235,19 @@ let track = Track::new(title, artist_id, duration, hash);
- Adapters: free to pull in platform crates (`axum`, `sqlx`, `iroh`, `nats`, etc.)
- Bootstrap: wiring only
## Generics Over Trait Objects
## Trait Objects for Ports
Use generics (static dispatch) for ports, not `dyn Trait`:
Ports are `dyn` behind `Arc`. Static dispatch is not worth the ergonomic cost here — use cases hold their dependencies as trait objects:
```rust
// YES — zero-cost, monomorphized
pub struct StreamTrackUseCase<T: P2pTransportPort, R: TrackRepositoryPort> {
transport: T,
repository: R,
}
// NO — heap allocation, dynamic dispatch overhead
pub struct StreamTrackUseCase {
transport: Box<dyn P2pTransportPort>,
repository: Box<dyn TrackRepositoryPort>,
pub struct Deps {
pub entries: Arc<dyn MoodEntryCommandPort>,
pub events: Arc<dyn EventPublisherPort>,
}
```
Prefer a closed enum over `dyn` only where exhaustiveness is the point — when adding a variant must force every consumer to handle it (`MetricKind`, `CorrelationStrategy`). That is a decision about compiler-enforced coverage, not about dispatch cost.
## Testing
### Coverage Targets
@@ -283,6 +273,16 @@ crates/domain/
└── track_test.rs
```
### Fakes Must Not Be Kinder Than Production
A fake that is more forgiving than the real adapter turns a test into a lie: it passes whichever way the code is written, which is worse than having no test. Two rules follow.
**A fake must reproduce the constraints the database enforces.** SQLite removes dimension rows through `ON DELETE CASCADE`, so `InMemoryStore`'s cascade clears the dimension stores registered with it via `cascades_to`. Without that, the ordering requirement in `delete_entries_by_date_range` — resolve media *before* the cascade, or every blob is orphaned — could not be tested at all.
**A fake must be able to fail.** Every port whose failure is meant to degrade rather than propagate needs a fake that refuses: `FakeMediaStorage::refusing_to_delete`, `FakeNowPlaying::failing`, `FakeRecordingLookup::failing`, `FakeWeatherLookup::failing`, `RefusingRejectionTrace`, `RefusingApiTokenStore`. A best-effort path with no failing test is a path that has never run.
When a claim cannot be observed through the fake, move the test to where the real thing runs rather than asserting it against the fake's own behaviour.
### Test Naming
Tests read as specifications:

View File

@@ -7,9 +7,13 @@ A personal mood tracking journal. Users log how they feel throughout the day, ta
### Core
**MoodEntry**:
A single mood record — the aggregate root. Every MoodEntry has exactly one Mood and belongs to exactly one User. May optionally include Activities, Content, photos, and voice memos. Multiple MoodEntries per day are allowed.
A single mood record — the aggregate root. Carries exactly one Mood, the instant it was logged, and nothing else. Belongs to exactly one User. Everything else attaches as EntryDimensions. Multiple MoodEntries per Date are allowed.
_Avoid_: Log, journal entry, record, mood log
**EntryDimension**:
An optional aspect of a MoodEntry, stored independently of it — Content, Activities, photos, voice memos, weather, location, song. Each kind is self-contained: it owns its own type, its own storage, and its own validation, and knows nothing about the others. A MoodEntry is complete without any of them.
_Avoid_: Facet, attribute, extra, metadata, attachment
**Mood**:
One of five discrete states representing how the user feels, mapped to a 15 ordinal scale: Awful (1), Bad (2), Meh (3), Good (4), Rad (5). The ordering is a domain truth — Rad is better than Good. Required on every MoodEntry.
_Avoid_: Feeling, emotion, state, score
@@ -29,11 +33,131 @@ _Avoid_: Group, section, type
### People
**User**:
A registered account identified by username and email. Has a role (Admin or User) and an optional timezone for analytics display. Owns their own Activity catalog, MoodEntries, and Reminders.
A registered account identified by username and email. Has a role (Admin or User) and a Timezone. Owns their own Activity catalog, MoodEntries, and Reminders.
_Avoid_: Account, member, profile
**Timezone**:
The IANA zone a User lives their days in. It is what turns an instant into a Date, so anything day-shaped — the calendar, a streak, a DailyMetric — is unanswerable without it. Clients set it from the platform; a User who somehow has none is told to set one rather than being given a silently wrong answer.
_Avoid_: TZ, offset, locale, region
### Scheduling
**Reminder**:
A per-user notification schedule. Each Reminder defines an `Option<Time>` for each day of the week — `Some(20:00)` means remind at 8 PM, `None` means skip that day. Can be enabled or disabled. A User can have multiple Reminders (e.g., one for morning check-in, one for evening). The domain defines when to remind; clients decide the message and delivery mechanism.
_Avoid_: Notification, alert, alarm, push
### Measurement
**DailyMetric**:
A single measured or self-reported value for one User on one Date — a MetricKind plus its value. Not an aggregate; uniqueness of (User, Date, MetricKind) is a structural guarantee, not an invariant. Independent of MoodEntry: a Date may carry DailyMetrics with no MoodEntries and vice versa.
_Avoid_: Health data, daily record, day log, stat, data point
**Source**:
Where a DailyMetric's value came from — either Manual, meaning the User stated it, or a Provider, meaning something else did. A DailyMetric sourced Manual is never overwritten by an import; a manual write always supersedes a Provider's.
_Avoid_: Origin, author, integration
**Provider**:
An external system that supplies data. Identified only by name — the domain knows a Provider exists and what it claimed, never how it is reached, authenticated, or parsed. Which Providers exist is a deployment concern, not a domain one.
_Avoid_: Integration, service, source, connector
**ApiToken**:
A named, revocable, non-expiring credential a User mints for an automation that cannot hold a session. Scoped to writing DailyMetrics and nothing else — it cannot read entries or touch the account. Its name is the Provider its writes are attributed to, so two tokens of one account cannot share a name. The value is shown once at mint time and held only as a digest.
_Avoid_: API key, secret, personal access token, integration key
**ProviderConnection**:
A User's standing authorization to a Provider, held as an opaque credential the domain never inspects. Each User supplies their own, and holds at most one per Provider — connecting again replaces it. The domain knows only that a User is or is not connected to a named Provider.
_Avoid_: Account link, integration, credentials, connection
**MetricKind**:
The closed set of things a DailyMetric can measure: steps, sleep minutes, awake minutes, resting heart rate, HRV, exercise minutes, screen time minutes, alcoholic drinks. Each MetricKind owns its unit and its valid range — the unit is never carried alongside the value. Membership is earned: a MetricKind must have real day-to-day variance, be measured well enough to trust, and not restate one already present. Extending the set is the only way to support a new measurement.
_Avoid_: Metric type, field, measurement name
**Date**:
The calendar day a DailyMetric belongs to, resolved by converting an instant into the User's Timezone. The single day boundary in the system — MoodEntries group onto the same Date for calendar and analytics.
_Avoid_: Day, logged date, local date
**DateSpan**:
A run of consecutive Dates, inclusive of both ends. What day-keyed data is read by, and distinct from the range of instants that MoodEntries are deleted by — a span of days has no time of day, so nothing about it depends on an offset.
_Avoid_: Date range, period, window, interval
**RejectedMetric**:
A reading that could not be used, kept where the account holder can read it — what arrived, from which Provider, on which Date, and why it was refused. Receives both readings that arrive broken from an importer and stored rows a later build can no longer read. Nothing in it was stored as a DailyMetric; it is a record of absence, not of data. Only the most recent are kept.
_Avoid_: Error log, failed import, invalid metric, audit trail
**CompleteBackup**:
Everything an account knows, written so that nothing is lost if it has to be restored — every MoodEntry with every EntryDimension, every DailyMetric, every CycleStart, the Activity catalogue, Reminders, UserPreferences and all media. Restores through its own path, never through the importer that reads foreign formats. Carries no credentials and no RejectedMetrics.
_Avoid_: Export, dump, archive, snapshot
**ShareableExtract**:
A readable document of mood, Content and Activities, for handing to another person. Carries nothing else — no Location, no Song, no DailyMetric, no CycleStart, no media — and cannot be restored from. Distinct from a CompleteBackup by intent, not by configuration: a single artifact with options would produce files that look like backups and are not.
_Avoid_: Partial export, filtered export, share link
### Ambient
**Weather**:
The conditions at the place and instant a MoodEntry was logged — a Condition and a temperature in celsius — expressed in one canonical vocabulary so that any two MoodEntries' Weather are comparable. Always attributed to a Provider, never to the User: Weather is observed, not stated, so a User editing an entry cannot remove it and absence from an edit is not a request to delete it. Resolved after the entry is saved, never during it.
_Avoid_: Forecast, conditions, temperature
**Condition**:
The closed vocabulary Weather is expressed in — clear, cloudy, fog, drizzle, rain, snow, thunderstorm. The domain's words, not a Provider's: every Provider's own codes are mapped onto these at the boundary, because correlating across inconsistent labels means nothing. Deliberately coarse; a Provider distinguishing light from heavy rain reports rain.
_Avoid_: Weather code, description, icon, summary
**Location**:
The coordinates a MoodEntry was logged at. Supplied by the client, optional, and the prerequisite for Weather.
_Avoid_: GPS, position, place, geo
**CycleStart**:
A Date on which a User's menstrual period began. Recorded once per cycle, never per day. Cycle day for any Date is derived from the most recent preceding CycleStart and is never stored — correcting a CycleStart therefore corrects every Date that depends on it. Recorded only while the User has cycle tracking on; turning it off hides every derived cycle day without forgetting what was recorded.
_Avoid_: Period, cycle day, menstrual day
**CyclePosition**:
Where a Date sits in its cycle — the cycle day, counting from one, and the progress through the cycle from 0 at its start to 1 at its end. Both are derived. Progress is measured against the next CycleStart where one exists and against the median observed length where the cycle is still running, so a late cycle sits at its end rather than beyond it. A Date more than ninety days after the last CycleStart has no position: that is a missed record, not a long cycle.
_Avoid_: Cycle phase, day number, cycle stage
**UserPreferences**:
The optional features a User has turned on. Separate from User, which carries identity and credentials — a preference is not a fact about who someone is. Absent until something is turned on, and every preference is off when absent.
_Avoid_: Settings, options, config, flags
**Song**:
What a User was listening to when a MoodEntry was logged, held as its own title, artist, and album rather than as a pointer elsewhere — a MoodEntry stays readable with nothing else reachable. Carries an external recording identity when one is known, which is what makes two MoodEntries about the same recording comparable. Captured at the moment of logging only; a MoodEntry never acquires a Song later.
_Avoid_: Track, music, now playing, recording
**MoonPhase**:
The lunar phase on a given Date. Derived from the Date on read and never stored — it is a function of the calendar, not an observation, so there is nothing to persist and no value that can go stale.
_Avoid_: Lunar phase, moon
### Background work
**Job**:
A record that some background work is wanted, carrying its kind, the thing it is about, its status, how many times it has been attempted and why the last attempt failed. Never the source of truth: no Job may be enqueued unless a query over stored data can independently rediscover the same work, so losing one costs promptness and never data. A Job that has used every attempt stops being retried and stays visible instead of vanishing.
_Avoid_: Task, message, event, queue item
**Sweep**:
The query that rediscovers work from stored data — "songs with no recording identity" — and enqueues whatever it finds. What makes the queue safe to lose. Every Job kind owes one, and work that cannot be expressed as such a query does not belong on the queue.
_Avoid_: Scan, reconciler, cron, catch-up
### Analysis
**DayMood**:
The mean of every MoodEntry mood on one Date. The single mood value used wherever a Date needs one — analysis and calendar alike. A Date with no MoodEntries has no DayMood.
_Avoid_: Dominant mood, average mood, daily mood, overall mood
**CorrelationInput**:
Anything a CorrelationStrategy can score against DayMood — every MetricKind, every Activity, plus MoonPhase, which is not a MetricKind because it is never stored. Each input's series has one shape: a MetricKind and MoonPhase are continuous, an Activity is presence, and a Strategy scores one shape, so an incompatible pairing cannot be constructed. Extending the set is deliberate: each input added costs an expected false positive across the whole result set.
_Avoid_: Variable, factor, feature, predictor
**Agreement**:
How many of the Strategies that can score an input point the same way, out of how many apply. The headline for a result, in place of any single coefficient. An Activity has one applicable Strategy, so its agreement is one of one — which is a statement about the measurement, not a claim of corroboration.
_Avoid_: Consensus, confidence, score, robustness
**CorrelationStrategy**:
A named method for scoring the relationship between a MetricKind and DayMood — Pearson, Spearman, Kendall, mean difference. Several are computed over the same data and every one returns the same shape, so they are directly comparable. Agreement among Strategies is the signal; no single Strategy's value is authoritative.
_Avoid_: Algorithm, correlation method, analyzer, engine
**Adjustment**:
A correction applied across a whole set of CorrelationStrategy results to account for how many were tested at once. Operates on a set, never on a single result, and is therefore never itself a CorrelationStrategy. It marks results; it never hides them. Benjamini-Hochberg, controlling false discovery rate, at a configured threshold. A marked result held up once the number of comparisons in its Family was accounted for — a second axis of robustness beside Agreement, never a verdict.
_Avoid_: Correction, p-value adjustment, filter, significance
**Family**:
The set of results one Adjustment is computed over. A Family is a question, not a screen: MetricKinds form one, Activities another, because how many Activities a User keeps has nothing to do with whether their sleep tracks their mood. Adjustment is computed per Family within a single CorrelationStrategy.
_Avoid_: Group, test set, batch, comparison set

407
Cargo.lock generated
View File

@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aead"
version = "0.6.1"
@@ -15,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout",
"inout 0.2.2",
]
[[package]]
@@ -24,7 +34,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
@@ -35,9 +45,9 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead",
"aead 0.6.1",
"aes",
"cipher",
"cipher 0.5.2",
"ctr",
"ctutils",
"ghash",
@@ -92,6 +102,7 @@ dependencies = [
"config",
"domain",
"serde",
"serde_json",
"thiserror 2.0.20",
"utoipa",
"uuid",
@@ -106,6 +117,8 @@ dependencies = [
"chrono-tz",
"config",
"domain",
"exporter",
"importer",
"thiserror 2.0.20",
"tokio",
"tracing",
@@ -150,6 +163,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "atomic"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
dependencies = [
"bytemuck",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -162,11 +184,14 @@ version = "0.1.0"
dependencies = [
"argon2",
"async-trait",
"base64 0.23.1",
"chrono",
"config",
"domain",
"jsonwebtoken",
"rand 0.9.5",
"serde",
"sha2 0.10.9",
"uuid",
]
@@ -353,12 +378,42 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "bootstrap"
version = "0.1.0"
dependencies = [
"application",
"auth",
"config",
"crypto",
"domain",
"event-publisher",
"exporter",
"http-axum",
"importer",
"music",
"reqwest",
"sqlite",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
"weather",
"web-push-adapter",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -395,6 +450,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.1"
@@ -406,6 +472,19 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead 0.5.2",
"chacha20 0.9.1",
"cipher 0.4.4",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -428,6 +507,18 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
"serde",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
"zeroize",
]
[[package]]
@@ -438,7 +529,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"inout",
"inout 0.2.2",
]
[[package]]
@@ -481,8 +572,10 @@ dependencies = [
name = "config"
version = "0.1.0"
dependencies = [
"figment",
"serde",
"thiserror 2.0.20",
"tracing",
]
[[package]]
@@ -598,6 +691,19 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.23.1",
"chacha20poly1305",
"config",
"domain",
"rand 0.9.5",
"tokio",
]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -617,6 +723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -664,7 +771,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher",
"cipher 0.5.2",
]
[[package]]
@@ -765,6 +872,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"chrono-tz",
"email_address",
"serde",
"thiserror 2.0.20",
@@ -924,13 +1032,22 @@ dependencies = [
name = "exporter"
version = "0.1.0"
dependencies = [
"api-types",
"async-trait",
"chrono",
"domain",
"serde",
"serde_json",
"tokio",
"zip",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "ff"
version = "0.13.1"
@@ -941,6 +1058,22 @@ dependencies = [
"subtle",
]
[[package]]
name = "figment"
version = "0.10.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
dependencies = [
"atomic",
"parking_lot",
"pear",
"serde",
"tempfile",
"toml",
"uncased",
"version_check",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
@@ -1117,6 +1250,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -1126,7 +1271,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1558,11 +1703,14 @@ dependencies = [
name = "importer"
version = "0.1.0"
dependencies = [
"api-types",
"async-trait",
"csv",
"domain",
"exporter",
"serde",
"serde_json",
"tokio",
"tracing",
"zip",
]
@@ -1579,6 +1727,21 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inlinable_string"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.2"
@@ -1779,6 +1942,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -1821,6 +1990,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "md-5"
version = "0.11.0"
@@ -1918,6 +2097,21 @@ dependencies = [
"version_check",
]
[[package]]
name = "music"
version = "0.1.0"
dependencies = [
"async-trait",
"domain",
"md-5 0.10.6",
"rand 0.9.5",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -2021,7 +2215,7 @@ dependencies = [
"humantime",
"hyper",
"itertools",
"md-5",
"md-5 0.11.0",
"nix",
"parking_lot",
"percent-encoding",
@@ -2048,6 +2242,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.81"
@@ -2155,6 +2355,29 @@ dependencies = [
"subtle",
]
[[package]]
name = "pear"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467"
dependencies = [
"inlinable_string",
"pear_codegen",
"yansi",
]
[[package]]
name = "pear_codegen"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147"
dependencies = [
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pem"
version = "0.8.3"
@@ -2252,6 +2475,17 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.3"
@@ -2260,7 +2494,7 @@ checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
dependencies = [
"cpubits",
"cpufeatures 0.3.0",
"universal-hash",
"universal-hash 0.6.1",
]
[[package]]
@@ -2305,6 +2539,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"version_check",
"yansi",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -2381,6 +2628,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -2394,17 +2647,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"chacha20 0.10.1",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -2419,6 +2682,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -2428,6 +2701,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -2489,6 +2771,7 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@@ -2500,12 +2783,16 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -2580,6 +2867,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
@@ -2827,12 +3127,16 @@ dependencies = [
"application",
"auth",
"axum",
"bootstrap",
"config",
"crypto",
"domain",
"event-publisher",
"exporter",
"http-axum",
"importer",
"music",
"reqwest",
"serde",
"serde_json",
"sqlite",
@@ -3048,6 +3352,7 @@ dependencies = [
"config",
"domain",
"sqlx",
"tokio",
"tracing",
"uuid",
]
@@ -3185,7 +3490,7 @@ dependencies = [
"hmac 0.13.0",
"itoa",
"log",
"md-5",
"md-5 0.11.0",
"memchr",
"rand 0.10.2",
"serde",
@@ -3342,6 +3647,19 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3701,6 +4019,15 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uncased"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
dependencies = [
"version_check",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -3740,6 +4067,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "universal-hash"
version = "0.6.1"
@@ -3872,6 +4209,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasix"
version = "0.13.2"
@@ -3949,6 +4295,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "weather"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"domain",
"reqwest",
"serde",
"tokio",
"tracing",
]
[[package]]
name = "web-push"
version = "0.11.0"
@@ -4177,12 +4536,36 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "worker"
version = "0.1.0"
dependencies = [
"application",
"bootstrap",
"config",
"domain",
"tokio",
"tracing",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
version = "0.8.3"

View File

@@ -11,11 +11,16 @@ members = [
"crates/adapters/storage",
"crates/adapters/event-publisher",
"crates/adapters/importer",
"crates/adapters/crypto",
"crates/adapters/music",
"crates/adapters/exporter",
"crates/adapters/weather",
"crates/adapters/web-push",
"crates/bootstrap",
"crates/server",
"crates/worker",
]
default-members = ["crates/server"]
default-members = ["crates/server", "crates/worker"]
[workspace.package]
edition = "2024"
@@ -32,8 +37,12 @@ auth = { path = "crates/adapters/auth" }
storage = { path = "crates/adapters/storage" }
event-publisher = { path = "crates/adapters/event-publisher" }
importer = { path = "crates/adapters/importer" }
crypto = { path = "crates/adapters/crypto" }
music = { path = "crates/adapters/music" }
exporter = { path = "crates/adapters/exporter" }
weather = { path = "crates/adapters/weather" }
web-push-adapter = { path = "crates/adapters/web-push" }
bootstrap = { path = "crates/bootstrap" }
csv = "1"
zip = { version = "8", default-features = false, features = ["deflate"] }
@@ -42,6 +51,7 @@ async-trait = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
figment = { version = "0.10", features = ["toml", "env"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
tracing = "0.1"
@@ -50,13 +60,17 @@ axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.7", features = ["cors", "trace", "fs"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
email_address = "0.2"
chrono-tz = "0.10"
chrono-tz = { version = "0.10", features = ["serde"] }
jsonwebtoken = { version = "11", features = ["aws_lc_rs"] }
argon2 = { version = "0.5", features = ["std"] }
object_store = { version = "0.14", features = ["aws"] }
bytes = "1"
base64 = "0.23"
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
chacha20poly1305 = "0.10"
md-5 = "0.10"
sha2 = "0.10"
rand = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "charset", "json", "query"] }
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }

View File

@@ -12,16 +12,20 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
RUN cargo build --release --bin k-mood
RUN cargo build --release --bin k-mood --bin k-mood-worker
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates wget libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/k-mood /usr/local/bin/k-mood
COPY --from=builder /app/target/release/k-mood-worker /usr/local/bin/k-mood-worker
COPY --from=frontend /app/spa/dist /spa/dist
RUN mkdir -p /data
VOLUME /data
WORKDIR /data
ENV KMOOD_STORAGE__DATA_DIR=/data
ENV KMOOD_SERVER__SPA_DIR=/spa/dist
EXPOSE 3000
ENTRYPOINT ["k-mood"]

View File

@@ -1,25 +1,51 @@
.PHONY: build dev check check-all test fmt run clean fix spa
.PHONY: build dev dev-server dev-worker check check-all test fmt run run-server run-worker clean fix spa
SERVER := k-mood
WORKER := k-mood-worker
build: spa
cargo build --release
dev:
RUST_LOG=debug cargo run
spa:
cd spa && bun install && bun run build
# Runs the server with the worker beside it, which is how k-mood is meant to run:
# reminders, session cleanup and enrichment all live in the worker. Stopping the
# server stops both.
run: build
@./target/release/$(WORKER) & \
WORKER_PID=$$!; \
trap "kill $$WORKER_PID 2>/dev/null || true" EXIT INT TERM; \
./target/release/$(SERVER)
run-server: build
./target/release/$(SERVER)
run-worker: build
./target/release/$(WORKER)
dev:
@cargo build --bin $(SERVER) --bin $(WORKER)
@RUST_LOG=debug ./target/debug/$(WORKER) & \
WORKER_PID=$$!; \
trap "kill $$WORKER_PID 2>/dev/null || true" EXIT INT TERM; \
RUST_LOG=debug ./target/debug/$(SERVER)
dev-server:
RUST_LOG=debug cargo run --bin $(SERVER)
dev-worker:
RUST_LOG=debug cargo run --bin $(WORKER)
# Lints tests and benches as well as the crates themselves: a warning in test
# code is still a warning, and half the codebase is tests.
check:
cargo fmt --all -- --check
cargo clippy -- -D warnings
cargo test
cd spa && bun run check && bun run typecheck
check-all:
cargo fmt --all -- --check
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cd spa && bun run check && bun run typecheck
cd spa && bun run check && bun run lint && bun run typecheck
check-all: check
test:
cargo test --workspace
@@ -28,12 +54,9 @@ fmt:
cargo fmt --all
cd spa && bun run format
run: spa
cargo run --release
fix:
cargo fmt --all
cargo clippy --fix --allow-dirty --allow-staged
cargo clippy --fix --workspace --all-targets --allow-dirty --allow-staged
clean:
cargo clean

106
README.md
View File

@@ -1,14 +1,19 @@
# K-Mood
Self-hosted mood tracking journal. Log your mood, activities, photos, and voice memos. Track trends, streaks, and correlations over time.
Self-hosted mood tracking journal. Log how you feel, then find out what actually moves it.
## Features
- **Mood tracking** with 5 discrete states (Awful, Bad, Meh, Good, Rad)
- **Activities** with custom categories
- **Rich entries** with markdown notes, photos, and voice memos
- **Analytics** including mood trends, streaks, distribution, activity correlations, and calendar heatmap
- **Import/Export** with Daylio CSV preset, generic CSV wizard, and full ZIP backup
- **Rich entries** with markdown notes, photos, voice memos, location, and what you were listening to
- **Daily metrics** — steps, sleep, resting heart rate, HRV, exercise, screen time, alcohol — entered by hand or imported from a phone
- **Correlation** between anything measured and your mood, with several methods run side by side; agreement between them is the signal, not any single number
- **Weather** resolved from an entry's location, and cycle tracking if you want it, both correlated like anything else
- **Trends** including streaks, distribution, and a calendar heatmap
- **Importer tokens** so a Shortcut or a cron script can send metrics without your password
- **Import** from Daylio CSV or any CSV with a mapping wizard
- **Two exports** that cannot be confused: a complete backup for keeping, and a readable journal for sharing
- **Multi-user** with JWT authentication and per-user data isolation
- **PWA** installable on mobile and desktop
- **Self-hosted** with SQLite and local or S3 media storage
@@ -18,15 +23,23 @@ Self-hosted mood tracking journal. Log your mood, activities, photos, and voice
### Docker
```bash
docker run -d \
docker run -d --name k-mood \
-p 3000:3000 \
-v k-mood-data:/data \
-e KMOOD_AUTH__JWT_SECRET=your-secret-here \
ghcr.io/gabrielkaszewski/k-mood:latest
docker run -d --name k-mood-worker \
-v k-mood-data:/data \
-e KMOOD_AUTH__JWT_SECRET=your-secret-here \
--entrypoint k-mood-worker \
ghcr.io/gabrielkaszewski/k-mood:latest
```
Open `http://localhost:3000`, register an account, and start logging.
The second container is the worker. Everything that happens on a schedule lives there — sending reminders, clearing expired sessions, resolving weather from an entry's location, and filling in the identity of songs logged while an upstream was unavailable. Run the server alone and none of that happens; nothing else breaks.
### Docker Compose
```yaml
@@ -40,41 +53,76 @@ services:
environment:
- KMOOD_AUTH__JWT_SECRET=your-secret-here
k-mood-worker:
image: ghcr.io/gabrielkaszewski/k-mood:latest
entrypoint: ["k-mood-worker"]
volumes:
- k-mood-data:/data
environment:
- KMOOD_AUTH__JWT_SECRET=your-secret-here
depends_on:
- k-mood
volumes:
k-mood-data:
```
### From Source
Requires Rust 1.85+ and Bun.
Requires Rust 1.88+ (the code uses let-chains, stable since 1.88) and Bun.
```bash
# Build frontend
cd spa && bun install && bun run build && cd ..
# Build and run
cargo run --release
make run
```
That builds the frontend, then starts the server with the worker beside it. Stopping the server stops both. `make run-server` and `make run-worker` start one at a time, `make dev` is the same pair with debug logging, and `make check` runs everything CI would.
## Configuration
Copy `config.example.toml` to `config.toml` and adjust as needed.
Three layers, each overriding the one before it: compiled-in defaults, then `config.toml`, then the environment. Copy `config.example.toml` to `config.toml` and adjust as needed — or set nothing at all and run on the defaults.
| Section | Key | Default | Description |
|---------|-----|---------|-------------|
| `server` | `host` | `0.0.0.0` | Bind address |
| `server` | `port` | `3000` | HTTP port |
| `server.cors` | `allow_any_origin` | `true` | CORS policy |
| `auth` | `jwt_secret` | `change-me-in-production` | JWT signing key |
| `auth` | `jwt_secret` | none | JWT signing key. Required — the server refuses to start without it |
| `auth` | `allow_registration` | `true` | Enable new user registration |
| `storage` | `data_dir` | `./data` | SQLite and media storage path |
| `storage.media` | `backend` | `local` | `local` or `s3` |
| `analysis` | `minimum_sample_size` | `30` | Days needed before any correlation is shown |
| `analysis` | `false_discovery_rate` | `0.10` | How often a marked correlation is expected to be a fluke |
| `import` | `maximum_days_per_import` | `90` | Longest health import accepted in one request |
| `import` | `rejections_kept` | `200` | Unusable readings kept per account, newest first |
| `worker` | `look_up_weather` | `true` | Set `false` and no coordinates leave the machine |
| `worker` | `most_attempts` | `5` | Failures before a job stops retrying and stays visible |
| `worker` | `sweep_seconds` | `900` | How often stranded work is rediscovered |
Every key has a default, so a section you leave out simply uses them. The full set with comments is in `config.example.toml`.
### From the environment
Every key has an environment twin: the prefix `KMOOD_`, a double underscore for each section you descend, and the key spelled as it is in the file.
```bash
KMOOD_SERVER__PORT=8080
KMOOD_AUTH__JWT_SECRET=...
KMOOD_STORAGE__DATA_DIR=/var/lib/kmood
KMOOD_SERVER__CORS__ALLOW_ANY_ORIGIN=false
KMOOD_WORKER__LOOK_UP_WEATHER=false
```
A single underscore stays inside a key name, so `data_dir` is written `DATA_DIR`; a double underscore descends a section. Lists are arrays — `KMOOD_SERVER__CORS__ALLOWED_ORIGINS=["https://a.example","https://b.example"]` — and the media backend switches the same way, with `KMOOD_STORAGE__MEDIA__BACKEND=s3` beside `KMOOD_STORAGE__MEDIA__BUCKET` and `KMOOD_STORAGE__MEDIA__REGION`.
Secrets belong here rather than in a file: `auth.jwt_secret`, `push.vapid_private_key`, `provider.encryption_key`, and the S3 access and secret keys.
`KMOOD_CONFIG_FILE` names a file other than `config.toml`. Having no `config.toml` is fine, since the defaults are a complete configuration — but a file you name that does not exist is an error, as are a malformed file and a value of the wrong type. The reasoning is in [ADR 0013](docs/adr/0013-configuration-layers-defaults-file-environment.md).
## Push Notifications
K-Mood supports Web Push notifications (works on iOS 16.4+ when added to Home Screen, Android, and desktop browsers). No Firebase or third-party service required.
**1. Generate a VAPID private key** (base64url-encoded, 32 bytes):
**1. Generate a VAPID private key** (32 bytes, base64url or standard base64):
```bash
python3 -c "
@@ -87,6 +135,8 @@ print(base64.urlsafe_b64encode(key[7:39]).rstrip(b'=').decode())
"
```
Standard base64 is accepted too: `+` and `/` are rewritten and any padding is stripped before use, so a key copied from a tool that emits the standard alphabet works unchanged.
**2. Add to `config.toml`:**
```toml
@@ -96,9 +146,17 @@ vapid_private_key = "<output from step 1>"
vapid_subject = "mailto:you@example.com"
```
Or keep the key out of the file entirely:
```bash
KMOOD_PUSH__ENABLED=true
KMOOD_PUSH__VAPID_PRIVATE_KEY=<output from step 1>
KMOOD_PUSH__VAPID_SUBJECT=mailto:you@example.com
```
**3. Enable in the app:** Go to Settings and tap "Enable" under Notifications. Use "Send test notification" to verify it works.
The server checks reminders every 60 seconds and sends push notifications to all subscribed devices for users with due reminders. Users must set a timezone in their profile for reminders to fire.
The **worker** checks reminders every 60 seconds and sends push notifications to all subscribed devices for users with due reminders. Users must set a timezone in their profile for reminders to fire — and the worker must be running, or nothing is sent.
## Architecture
@@ -109,19 +167,29 @@ crates/
domain/ Pure domain logic, entities, value objects, ports
application/ Use cases as free-standing functions
api-types/ Request/response DTOs and Zod-like validation
config/ Configuration types and defaults
config/ Configuration types, defaults, and the file/environment layering
adapters/
http-axum/ REST API (axum) + SPA serving
sqlite/ SQLite persistence (sqlx)
auth/ JWT + Argon2 authentication
storage/ Media storage (local filesystem / S3)
event-publisher/ Domain event bus (tokio mpsc)
importer/ Daylio CSV + generic import parsing
exporter/ ZIP export with media
server/ Composition root, startup, graceful shutdown
importer/ Daylio CSV, generic CSV, and backup reading
exporter/ Complete backup (ZIP) and shareable journal (Markdown)
music/ Subsonic now-playing, MusicBrainz recording lookup
weather/ Open-Meteo lookup, WMO codes mapped to our own vocabulary
crypto/ Provider credential encryption
web-push/ Reminder delivery
bootstrap/ Shared wiring, so both binaries build one object graph
server/ The HTTP binary (k-mood)
worker/ The background binary (k-mood-worker)
spa/ React 19 SPA (TanStack Router, shadcn/ui, Tailwind v4)
```
Two processes, one SQLite file. The server only serves; everything on a timer — reminders, session cleanup, enrichment — is the worker's. Background work is queued but the queue is deliberately losable: nothing is enqueued that a query over stored data cannot rediscover, so a lost job costs promptness and never data.
Decisions with reasoning worth keeping are in `docs/adr/`, and the domain vocabulary is in `CONTEXT.md`.
## API
Interactive API docs are available at `/docs` (Scalar UI) when the server is running. The OpenAPI spec is at `/openapi.json`.

View File

@@ -19,3 +19,22 @@ jwt_secret = "dev-secret-do-not-use-in-prod"
access_token_ttl_seconds = 900
refresh_token_ttl_seconds = 2592000
allow_registration = true
[analysis]
minimum_sample_size = 30
false_discovery_rate = 0.10
[import]
maximum_days_per_import = 90
rejections_kept = 200
[worker]
poll_seconds = 15
sweep_seconds = 900
session_cleanup_seconds = 3600
reminder_seconds = 60
jobs_per_poll = 20
enqueued_per_sweep = 200
most_attempts = 5
stalled_after_seconds = 300
look_up_weather = true

View File

@@ -1,3 +1,8 @@
# Every key here has an environment twin: prefix KMOOD_, a double underscore for
# each section, the key as it is spelled below. KMOOD_SERVER__PORT sets server.port,
# KMOOD_SERVER__CORS__ALLOW_ANY_ORIGIN sets server.cors.allow_any_origin. The
# environment wins over this file, so secrets can stay out of it entirely.
[server]
host = "0.0.0.0"
port = 3000
@@ -40,3 +45,30 @@ allow_registration = true
# enabled = true
# vapid_private_key = "<base64url-encoded 32-byte EC private key>"
# vapid_subject = "mailto:you@example.com"
[provider]
# Base64-encoded 32-byte key that encrypts every user's music-service credential
# at rest. Generate with: openssl rand -base64 32
# Absent: provider connections are unavailable and the server logs this at startup.
# Malformed: the server refuses to start rather than storing credentials the
# operator believes are encrypted.
# encryption_key = "<base64url-encoded 32-byte key>"
[analysis]
minimum_sample_size = 30
false_discovery_rate = 0.10
[import]
maximum_days_per_import = 90
rejections_kept = 200
[worker]
poll_seconds = 15
sweep_seconds = 900
session_cleanup_seconds = 3600
reminder_seconds = 60
jobs_per_poll = 20
enqueued_per_sweep = 200
most_attempts = 5
stalled_after_seconds = 300
look_up_weather = true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,68 @@
use std::time::Duration;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const MIGRATIONS: &[&str] = &[
include_str!("migrations/001_initial.sql"),
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?;
}
tracing::info!("database migrations completed");
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)
}
}
}
async fn apply_pending(connection: &mut sqlx::SqliteConnection) -> Result<(), sqlx::Error> {
for (name, sql) in MIGRATIONS {
if is_applied(connection, name).await? {
continue;
}
sqlx::raw_sql(*sql).execute(&mut *connection).await?;
sqlx::query("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)")
.bind(*name)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *connection)
.await?;
tracing::info!(migration = *name, "applied migration");
}
Ok(())
}
async fn is_applied(
connection: &mut sqlx::SqliteConnection,
name: &str,
) -> Result<bool, sqlx::Error> {
let existing: Option<(String,)> =
sqlx::query_as("SELECT name FROM schema_migrations WHERE name = ?")
.bind(name)
.fetch_optional(&mut *connection)
.await?;
Ok(existing.is_some())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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