spa hardening, offline logging, rate limit fixes

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

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

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

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

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

View File

@@ -23,7 +23,7 @@ Optional markdown text attached to a MoodEntry. Serves both quick annotations ("
_Avoid_: Note, quick note, description, journal entry _Avoid_: Note, quick note, description, journal entry
**Activity**: **Activity**:
A named item from a user's personal catalog, tagged onto MoodEntries. Covers heterogeneous concepts (social contexts like "friends", health actions like "exercise", sleep indicators like "good sleep") under one umbrella term. Each Activity belongs to exactly one User. Activities are archivable — archived Activities remain on historical entries but cannot be tagged onto new ones. A named item from a user's personal catalog, tagged onto MoodEntries. Covers heterogeneous concepts (social contexts like "friends", health actions like "exercise", sleep indicators like "good sleep") under one umbrella term. Each Activity belongs to exactly one User. Activities are archivable — an archived Activity stays on every entry already wearing it, and those entries stay editable, but it cannot be added to an entry that does not already carry it. Unarchiving makes it taggable again.
_Avoid_: Tag, label, habit, tracker _Avoid_: Tag, label, habit, tracker
**Category**: **Category**:
@@ -43,9 +43,13 @@ _Avoid_: TZ, offset, locale, region
### Scheduling ### Scheduling
**Reminder**: **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. 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). Remembers the last DueOccurrence it sent, so a schedule is answered once however often it is asked. The domain defines when to remind; clients decide the message and delivery mechanism.
_Avoid_: Notification, alert, alarm, push _Avoid_: Notification, alert, alarm, push
**DueOccurrence**:
One scheduled firing of one Reminder, identified by the instant its wall clock time resolves to in the User's Timezone. What a Reminder records as sent — not the moment of sending, so asking again about the same occurrence answers no. Reached only once its instant has passed and while it is still inside a configured grace, so a worker that starts late does not deliver a morning that is over.
_Avoid_: Trigger, firing, due time, schedule slot
### Measurement ### Measurement
**DailyMetric**: **DailyMetric**:
@@ -61,9 +65,13 @@ An external system that supplies data. Identified only by name — the domain kn
_Avoid_: Integration, service, source, connector _Avoid_: Integration, service, source, connector
**ApiToken**: **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. A named, revocable, non-expiring credential a User mints for a client that cannot hold a session. Grants the TokenScopes it was minted with and nothing else, and never the account itself — no token can change a password, mint another token, or restore a backup. Its name is the Provider its metric 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 _Avoid_: API key, secret, personal access token, integration key
**TokenScope**:
One named thing an ApiToken may do — read the journal, write the journal, write DailyMetrics, read the profile. Named after what a client does, not after the routes it calls, so a route's requirement is a statement about intent. A token holds a non-empty set of them; a name this build does not know is refused rather than ignored, because a typo must never mint a narrower token than the one asked for.
_Avoid_: Permission, grant, role, capability
**ProviderConnection**: **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. 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 _Avoid_: Account link, integration, credentials, connection
@@ -76,6 +84,14 @@ _Avoid_: Metric type, field, measurement name
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. 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 _Avoid_: Day, logged date, local date
**EntrySelection**:
What a client is asking to read, as one value: the account, and optionally a range of instants, a Mood, an Activity, and an instant to return only what changed after. Combining narrowings is the point — every filter is the same kind of thing, so a page of one mood within one month is a selection rather than an endpoint nobody wrote. Counting and reading use the same selection, so a total can never describe a different question from the page beneath it.
_Avoid_: Filter, criteria, search, query params
**Page**:
One window onto an EntrySelection, with the total for the whole selection beside it. The total is what makes paging honest: without it a client can only fetch until a short page arrives and hope. A page larger than the server serves cannot be constructed, so an unbounded read is not something a client can ask for by accident.
_Avoid_: Batch, chunk, result set, slice
**DateSpan**: **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. 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 _Avoid_: Date range, period, window, interval
@@ -106,6 +122,10 @@ _Avoid_: Weather code, description, icon, summary
The coordinates a MoodEntry was logged at. Supplied by the client, optional, and the prerequisite for Weather. The coordinates a MoodEntry was logged at. Supplied by the client, optional, and the prerequisite for Weather.
_Avoid_: GPS, position, place, geo _Avoid_: GPS, position, place, geo
**MediaRef**:
A photo or voice memo named by its kind together with its id. What ownership of a blob is recorded against, so one record serves both kinds and two objects sharing a uuid across kinds stay distinct. The blob store itself knows nothing of Users; who may delete a photo is a fact held beside it, never inferred from whichever MoodEntry happens to wear it.
_Avoid_: Attachment id, file, asset, blob key
**CycleStart**: **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. 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 _Avoid_: Period, cycle day, menstrual day

175
Cargo.lock generated
View File

@@ -783,6 +783,20 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.4.5" version = "0.4.5"
@@ -1137,6 +1151,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1210,6 +1234,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.34"
@@ -1257,9 +1287,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1285,6 +1317,29 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -1315,6 +1370,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -1444,9 +1505,13 @@ dependencies = [
"axum", "axum",
"config", "config",
"domain", "domain",
"http-body-util",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"tower",
"tower-http 0.7.0", "tower-http 0.7.0",
"tower_governor",
"tracing", "tracing",
"utoipa", "utoipa",
"utoipa-scalar", "utoipa-scalar",
@@ -2124,6 +2189,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2432,6 +2509,26 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2497,6 +2594,12 @@ dependencies = [
"universal-hash 0.6.1", "universal-hash 0.6.1",
] ]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.6" version = "0.1.6"
@@ -2552,6 +2655,21 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -2725,6 +2843,15 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3317,6 +3444,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@@ -3939,6 +4075,22 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower_governor"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http 1.5.0",
"pin-project",
"thiserror 2.0.20",
"tower",
"tracing",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -4377,6 +4529,22 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4386,6 +4554,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -4548,6 +4722,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"application", "application",
"bootstrap", "bootstrap",
"chrono",
"config", "config",
"domain", "domain",
"tokio", "tokio",

View File

@@ -57,7 +57,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = { version = "0.8", features = ["macros", "multipart"] } axum = { version = "0.8", features = ["macros", "multipart"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
tower-http = { version = "0.7", features = ["cors", "trace", "fs"] } tower-http = { version = "0.7", features = ["cors", "trace", "fs"] }
tower_governor = { version = "0.8", default-features = false, features = ["axum"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
email_address = "0.2" email_address = "0.2"
chrono-tz = { version = "0.10", features = ["serde"] } chrono-tz = { version = "0.10", features = ["serde"] }

View File

@@ -18,6 +18,25 @@ max_content_length = 65536
max_photos = 10 max_photos = 10
max_voice_memos = 5 max_voice_memos = 5
max_activities_per_entry = 50 max_activities_per_entry = 50
max_entries_per_page = 200
[server.rate_limit]
# Guards the API only. The client's own static files are served outside this
# budget — one cold load asks for a dozen or more hashed assets at once, and
# counting those here lets a hard refresh starve the app of its own code.
enabled = true
# Sustained ceiling per caller. Opening the client and touching every screen
# costs roughly 25 API calls, so this is far above any person and still caps
# what a script can pull.
requests_per_second = 15
# How much can arrive at once before the ceiling applies. Covers that opening
# flurry about twice over, and refills completely in four seconds.
burst = 60
# Behind a reverse proxy this MUST be true, or every caller shares one bucket
# keyed by the proxy's address and one busy client throttles everyone. Only
# turn it on when the proxy sets X-Forwarded-For itself and refuses to pass
# through a client-supplied one, or the limit can be evaded by forging it.
trust_forwarded_for = false
[storage] [storage]
data_dir = "./data" data_dir = "./data"
@@ -67,6 +86,7 @@ poll_seconds = 15
sweep_seconds = 900 sweep_seconds = 900
session_cleanup_seconds = 3600 session_cleanup_seconds = 3600
reminder_seconds = 60 reminder_seconds = 60
reminder_grace_minutes = 30
jobs_per_poll = 20 jobs_per_poll = 20
enqueued_per_sweep = 200 enqueued_per_sweep = 200
most_attempts = 5 most_attempts = 5

View File

@@ -15,7 +15,7 @@ use domain::reminder::Reminder;
use super::shared::{io_err, json_err, zip_err}; use super::shared::{io_err, json_err, zip_err};
pub const BACKUP_FORMAT_VERSION: u32 = 2; pub const BACKUP_FORMAT_VERSION: u32 = 3;
pub const BACKUP_MANIFEST: &str = "backup.json"; pub const BACKUP_MANIFEST: &str = "backup.json";
pub struct ZipBackupWriter; pub struct ZipBackupWriter;
@@ -67,6 +67,7 @@ fn manifest(backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
.map(BackedUpReminder::from) .map(BackedUpReminder::from)
.collect(), .collect(),
tracks_cycle: backup.preferences.tracks_cycle(), tracks_cycle: backup.preferences.tracks_cycle(),
media: backed_up_media(backup),
}; };
serde_json::to_vec_pretty(&manifest).map_err(json_err) serde_json::to_vec_pretty(&manifest).map_err(json_err)
@@ -82,6 +83,37 @@ pub struct BackupManifest {
pub activities: Vec<BackedUpActivity>, pub activities: Vec<BackedUpActivity>,
pub reminders: Vec<BackedUpReminder>, pub reminders: Vec<BackedUpReminder>,
pub tracks_cycle: bool, pub tracks_cycle: bool,
#[serde(default)]
pub media: Vec<BackedUpMedia>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackedUpMedia {
pub id: String,
pub kind: String,
pub content_type: String,
}
pub const PHOTO_KIND: &str = "photo";
pub const VOICE_MEMO_KIND: &str = "voice_memo";
fn backed_up_media(backup: &UserBackup) -> Vec<BackedUpMedia> {
let photos = backup.media.photos.iter().map(|blob| (PHOTO_KIND, blob));
let memos = backup
.media
.voice_memos
.iter()
.map(|blob| (VOICE_MEMO_KIND, blob));
photos
.chain(memos)
.map(|(kind, blob)| BackedUpMedia {
id: blob.id.clone(),
kind: kind.to_string(),
content_type: blob.content_type.value().to_string(),
})
.collect()
} }
#[derive(Debug, serde::Serialize, serde::Deserialize)] #[derive(Debug, serde::Serialize, serde::Deserialize)]

View File

@@ -3,7 +3,7 @@ mod extract;
mod shared; mod shared;
pub use backup::{ pub use backup::{
BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMetric, BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMedia,
BackedUpReminder, BackupManifest, ZipBackupWriter, BackedUpMetric, BackedUpReminder, BackupManifest, PHOTO_KIND, VOICE_MEMO_KIND, ZipBackupWriter,
}; };
pub use extract::MarkdownExtractWriter; pub use extract::MarkdownExtractWriter;

View File

@@ -11,6 +11,7 @@ config.workspace = true
web-push-adapter.workspace = true web-push-adapter.workspace = true
axum.workspace = true axum.workspace = true
tower-http.workspace = true tower-http.workspace = true
tower_governor.workspace = true
async-trait.workspace = true async-trait.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -18,3 +19,8 @@ uuid.workspace = true
utoipa.workspace = true utoipa.workspace = true
utoipa-scalar.workspace = true utoipa-scalar.workspace = true
tracing.workspace = true tracing.workspace = true
[dev-dependencies]
tower.workspace = true
http-body-util.workspace = true
tokio.workspace = true

View File

@@ -3,24 +3,37 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use api_types::errors::ApiValidationError; use api_types::errors::ApiValidationError;
use api_types::responses::ErrorResponse;
use application::errors::ApplicationError; use application::errors::ApplicationError;
use domain::errors::DomainError; use domain::errors::DomainError;
pub const NOT_FOUND: &str = "NOT_FOUND";
pub const INVALID_INPUT: &str = "INVALID_INPUT";
pub const CONFLICT: &str = "CONFLICT";
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
pub const FORBIDDEN: &str = "FORBIDDEN";
pub const VALIDATION_ERROR: &str = "VALIDATION_ERROR";
pub const TOO_MANY_REQUESTS: &str = "TOO_MANY_REQUESTS";
pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
pub struct ApiError(pub ApplicationError); pub struct ApiError(pub ApplicationError);
pub fn refuse(status: StatusCode, code: &str, message: impl Into<String>) -> Response {
(status, Json(ErrorResponse::new(code, message))).into_response()
}
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, code, message) = match &self.0 { let (status, code, message) = match &self.0 {
ApplicationError::Domain(domain_err) => domain_error_response(domain_err), ApplicationError::Domain(domain_err) => domain_error_response(domain_err),
ApplicationError::Validation(msg) => ( ApplicationError::Validation(msg) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
"VALIDATION_ERROR", VALIDATION_ERROR,
msg.clone(), msg.clone(),
), ),
}; };
let body = serde_json::json!({ "error": { "code": code, "message": message } }); refuse(status, code, message)
(status, Json(body)).into_response()
} }
} }
@@ -47,10 +60,10 @@ impl From<DomainError> for ApiError {
fn domain_error_response(err: &DomainError) -> (StatusCode, &'static str, String) { fn domain_error_response(err: &DomainError) -> (StatusCode, &'static str, String) {
match err { match err {
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()), DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, NOT_FOUND, msg.clone()),
DomainError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, "INVALID_INPUT", msg.clone()), DomainError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, INVALID_INPUT, msg.clone()),
DomainError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()), DomainError::Conflict(msg) => (StatusCode::CONFLICT, CONFLICT, msg.clone()),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()), DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, UNAUTHORIZED, msg.clone()),
DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()), DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, FORBIDDEN, msg.clone()),
} }
} }

View File

@@ -1,57 +0,0 @@
use axum::Json;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use domain::user::UserId;
use crate::state::AppState;
pub struct AuthenticatedUser(pub UserId);
impl IntoResponse for AuthRejection {
fn into_response(self) -> Response {
let body = serde_json::json!({ "error": self.0 });
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
}
}
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,
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 token = bearer_token(parts)
.ok_or_else(|| AuthRejection("missing or invalid authorization header".into()))?;
let user_id = app_state
.auth_service
.validate_token(&token)
.await
.map_err(|_| AuthRejection("invalid or expired token".into()))?;
Ok(AuthenticatedUser(user_id))
}
}
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())
}
use axum::extract::FromRef;

View File

@@ -0,0 +1,145 @@
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use application::errors::ApplicationError;
use domain::api_token::{ApiToken, TokenScope};
use domain::errors::DomainError;
use domain::metric::Source;
use domain::user::UserId;
use application::api_token::use_cases::authenticate_api_token;
use crate::errors::{FORBIDDEN, UNAUTHORIZED, refuse};
use crate::state::AppState;
pub struct AuthRejection {
status: StatusCode,
code: &'static str,
reason: String,
}
impl AuthRejection {
pub fn new(reason: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
code: UNAUTHORIZED,
reason: reason.into(),
}
}
pub fn forbidding(reason: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: FORBIDDEN,
reason: reason.into(),
}
}
}
impl IntoResponse for AuthRejection {
fn into_response(self) -> Response {
refuse(self.status, self.code, self.reason)
}
}
pub enum Bearer {
Session(UserId),
Token(ApiToken),
}
impl Bearer {
pub fn user_id(&self) -> &UserId {
match self {
Self::Session(user_id) => user_id,
Self::Token(token) => token.user_id(),
}
}
pub fn into_user_id(self) -> UserId {
match self {
Self::Session(user_id) => user_id,
Self::Token(token) => token.user_id().clone(),
}
}
pub fn attributes_writes_to(&self) -> Source {
match self {
Self::Session(_) => Source::Manual,
Self::Token(token) => Source::Provider(token.name().clone()),
}
}
}
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())
}
pub async fn session_only(parts: &Parts, state: &AppState) -> Result<UserId, AuthRejection> {
let presented = bearer_token(parts)
.ok_or_else(|| AuthRejection::new("missing or invalid authorization header"))?;
state
.auth_service
.validate_token(&presented)
.await
.map_err(|_| {
AuthRejection::new("this endpoint needs a signed-in session, not an api token")
})
}
pub async fn administrator(parts: &Parts, state: &AppState) -> Result<UserId, AuthRejection> {
let user_id = session_only(parts, state).await?;
let user = state
.user_query
.find_by_id(&user_id)
.await
.map_err(|_| AuthRejection::new("this account could not be read"))?
.ok_or_else(|| AuthRejection::new("this account no longer exists"))?;
if !user.is_admin() {
return Err(AuthRejection::forbidding(
"this endpoint is for whoever runs the server",
));
}
Ok(user_id)
}
pub async fn session_or_token_granting(
parts: &Parts,
state: &AppState,
needed: TokenScope,
) -> Result<Bearer, AuthRejection> {
let presented = bearer_token(parts)
.ok_or_else(|| AuthRejection::new("missing or invalid authorization header"))?;
if let Ok(user_id) = state.auth_service.validate_token(&presented).await {
return Ok(Bearer::Session(user_id));
}
let deps = authenticate_api_token::Deps {
query: state.api_token_query.clone(),
command: state.api_token_command.clone(),
secrets: state.api_token_secrets.clone(),
};
let token = authenticate_api_token::execute(&presented, needed, &deps)
.await
.map_err(refusal_for)?;
Ok(Bearer::Token(token))
}
fn refusal_for(error: ApplicationError) -> AuthRejection {
match error {
ApplicationError::Domain(DomainError::Forbidden(reason)) => {
AuthRejection::forbidding(reason)
}
other => AuthRejection::new(other.to_string()),
}
}

View File

@@ -0,0 +1,59 @@
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::extract::{FromRequest, FromRequestParts, Query, Request};
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use crate::errors::{INVALID_INPUT, refuse};
pub struct Body<T>(pub T);
pub struct Params<T>(pub T);
pub struct MalformedRequest(String);
impl IntoResponse for MalformedRequest {
fn into_response(self) -> Response {
refuse(StatusCode::BAD_REQUEST, INVALID_INPUT, self.0)
}
}
impl From<JsonRejection> for MalformedRequest {
fn from(rejection: JsonRejection) -> Self {
Self(rejection.body_text())
}
}
impl From<QueryRejection> for MalformedRequest {
fn from(rejection: QueryRejection) -> Self {
Self(rejection.body_text())
}
}
impl<S, T> FromRequest<S> for Body<T>
where
S: Send + Sync,
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = MalformedRequest;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
let axum::Json(value) = axum::Json::<T>::from_request(request, state).await?;
Ok(Self(value))
}
}
impl<S, T> FromRequestParts<S> for Params<T>
where
S: Send + Sync,
Query<T>: FromRequestParts<S, Rejection = QueryRejection>,
{
type Rejection = MalformedRequest;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Query(value) = Query::<T>::from_request_parts(parts, state).await?;
Ok(Self(value))
}
}

View File

@@ -8,7 +8,9 @@ use application::api_token::use_cases::authenticate_api_token;
use crate::state::AppState; use crate::state::AppState;
use super::authenticated_user::{AuthRejection, bearer_token}; use domain::api_token::TokenScope;
use super::authentication::{AuthRejection, bearer_token};
pub struct ImportingProvider { pub struct ImportingProvider {
pub user_id: UserId, pub user_id: UserId,
@@ -34,9 +36,13 @@ where
secrets: app_state.api_token_secrets, secrets: app_state.api_token_secrets,
}; };
let token = authenticate_api_token::execute(&presented, &deps) let token = authenticate_api_token::execute(&presented, TokenScope::WriteMetrics, &deps)
.await .await
.map_err(|_| AuthRejection::new("importing needs an api token minted in settings"))?; .map_err(|_| {
AuthRejection::new(
"importing needs an api token granting writeMetrics, minted in settings",
)
})?;
Ok(Self { Ok(Self {
user_id: token.user_id().clone(), user_id: token.user_id().clone(),

View File

@@ -1,53 +0,0 @@
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,11 +1,15 @@
pub mod authenticated_user; pub mod authentication;
mod body;
mod importing_provider; mod importing_provider;
mod metric_writer;
mod multipart; mod multipart;
mod path_id; mod path_id;
mod scoped;
pub use authenticated_user::AuthenticatedUser; pub use authentication::AuthRejection;
pub use body::{Body, Params};
pub use importing_provider::ImportingProvider; pub use importing_provider::ImportingProvider;
pub use metric_writer::MetricWriter;
pub use multipart::{extract_file_bytes, extract_media_upload}; pub use multipart::{extract_file_bytes, extract_media_upload};
pub use path_id::PathId; pub use path_id::PathId;
pub use scoped::{
Administrator, JournalReader, JournalWriter, MetricWriter, ProfileReader, SessionUser,
};

View File

@@ -1,4 +1,3 @@
use axum::Json;
use axum::extract::Path; use axum::extract::Path;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
@@ -9,8 +8,11 @@ pub struct PathIdRejection(String);
impl IntoResponse for PathIdRejection { impl IntoResponse for PathIdRejection {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let body = serde_json::json!({ "error": self.0 }); crate::errors::refuse(
(StatusCode::BAD_REQUEST, Json(body)).into_response() StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
self.0,
)
} }
} }

View File

@@ -0,0 +1,99 @@
use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts;
use domain::api_token::TokenScope;
use domain::metric::Source;
use domain::user::UserId;
use crate::state::AppState;
use super::authentication::{
AuthRejection, administrator, session_only, session_or_token_granting,
};
pub struct SessionUser(pub UserId);
pub struct Administrator(pub UserId);
pub struct JournalReader(pub UserId);
pub struct JournalWriter(pub UserId);
pub struct ProfileReader(pub UserId);
pub struct MetricWriter {
pub user_id: UserId,
pub source: Source,
}
impl<S> FromRequestParts<S> for SessionUser
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 state = AppState::from_ref(state);
Ok(Self(session_only(parts, &state).await?))
}
}
impl<S> FromRequestParts<S> for Administrator
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 state = AppState::from_ref(state);
Ok(Self(administrator(parts, &state).await?))
}
}
macro_rules! scoped_extractor {
($name:ident, $scope:expr) => {
impl<S> FromRequestParts<S> for $name
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 state = AppState::from_ref(state);
let bearer = session_or_token_granting(parts, &state, $scope).await?;
Ok(Self(bearer.into_user_id()))
}
}
};
}
scoped_extractor!(JournalReader, TokenScope::ReadJournal);
scoped_extractor!(JournalWriter, TokenScope::WriteJournal);
scoped_extractor!(ProfileReader, TokenScope::ReadProfile);
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 state = AppState::from_ref(state);
let bearer = session_or_token_granting(parts, &state, TokenScope::WriteMetrics).await?;
Ok(Self {
source: bearer.attributes_writes_to(),
user_id: bearer.into_user_id(),
})
}
}

View File

@@ -2,8 +2,9 @@ use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::params::ListActivitiesParams;
use api_types::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest}; use api_types::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest};
use api_types::responses::ActivityResponse; use api_types::responses::{ActivityResponse, ErrorResponse};
use application::activity::use_cases::{ use application::activity::use_cases::{
archive_activity, create_activity, delete_activity, get_activity, list_activities, archive_activity, create_activity, delete_activity, get_activity, list_activities,
rename_activity, set_category, rename_activity, set_category,
@@ -11,17 +12,21 @@ use application::activity::use_cases::{
use domain::activity::ActivityId; use domain::activity::ActivityId;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId}; use crate::extractors::{Body, JournalReader, JournalWriter, Params, PathId};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
request_body = CreateActivityRequest, request_body = CreateActivityRequest,
responses((status = 201, body = ActivityResponse)) responses((status = 201, body = ActivityResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_create( pub async fn handle_create(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Json(body): Json<CreateActivityRequest>, Body(body): Body<CreateActivityRequest>,
) -> Result<(StatusCode, Json<ActivityResponse>), ApiError> { ) -> Result<(StatusCode, Json<ActivityResponse>), ApiError> {
let cmd = body.into_command(user_id)?; let cmd = body.into_command(user_id)?;
let deps = create_activity::Deps { let deps = create_activity::Deps {
@@ -34,11 +39,15 @@ pub async fn handle_create(
#[utoipa::path(get, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 200, body = ActivityResponse)) responses((status = 200, body = ActivityResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_get( pub async fn handle_get(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
) -> Result<Json<ActivityResponse>, ApiError> { ) -> Result<Json<ActivityResponse>, ApiError> {
let deps = get_activity::Deps { let deps = get_activity::Deps {
@@ -49,16 +58,28 @@ pub async fn handle_get(
} }
#[utoipa::path(get, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
responses((status = 200, body = Vec<ActivityResponse>)) description = "This account's activity catalogue. Archived activities are left out unless \
`includeArchived` asks for them, because they cannot be tagged onto anything \
new — but they stay listable, so a client can show them and unarchive one.",
params(ListActivitiesParams),
responses((status = 200, body = Vec<ActivityResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Params(params): Params<ListActivitiesParams>,
) -> Result<Json<Vec<ActivityResponse>>, ApiError> { ) -> Result<Json<Vec<ActivityResponse>>, ApiError> {
let deps = list_activities::Deps { let deps = list_activities::Deps {
query: state.activity_query, query: state.activity_query,
}; };
let activities = list_activities::active_only(user_id, &deps).await?; let activities = match params.include_archived.unwrap_or(false) {
true => list_activities::all(user_id, &deps).await?,
false => list_activities::active_only(user_id, &deps).await?,
};
Ok(Json( Ok(Json(
activities.into_iter().map(ActivityResponse::from).collect(), activities.into_iter().map(ActivityResponse::from).collect(),
)) ))
@@ -67,13 +88,17 @@ pub async fn handle_list(
#[utoipa::path(patch, path = "/api/v1/activities/{id}/name", tag = "activities", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/activities/{id}/name", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
request_body = RenameActivityRequest, request_body = RenameActivityRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_rename( pub async fn handle_rename(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
Json(body): Json<RenameActivityRequest>, Body(body): Body<RenameActivityRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = body.into_command(activity_id)?; let cmd = body.into_command(activity_id)?;
let deps = rename_activity::Deps { let deps = rename_activity::Deps {
@@ -88,13 +113,17 @@ pub async fn handle_rename(
#[utoipa::path(patch, path = "/api/v1/activities/{id}/category", tag = "activities", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/activities/{id}/category", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
request_body = SetCategoryRequest, request_body = SetCategoryRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_set_category( pub async fn handle_set_category(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
Json(body): Json<SetCategoryRequest>, Body(body): Body<SetCategoryRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = body.into_command(activity_id)?; let cmd = body.into_command(activity_id)?;
let deps = set_category::Deps { let deps = set_category::Deps {
@@ -107,11 +136,15 @@ pub async fn handle_set_category(
#[utoipa::path(post, path = "/api/v1/activities/{id}/archive", tag = "activities", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/activities/{id}/archive", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_archive( pub async fn handle_archive(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = archive_activity::Deps { let deps = archive_activity::Deps {
@@ -125,11 +158,15 @@ pub async fn handle_archive(
#[utoipa::path(post, path = "/api/v1/activities/{id}/unarchive", tag = "activities", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/activities/{id}/unarchive", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_unarchive( pub async fn handle_unarchive(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = archive_activity::Deps { let deps = archive_activity::Deps {
@@ -143,14 +180,19 @@ pub async fn handle_unarchive(
#[utoipa::path(delete, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete( pub async fn handle_delete(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(activity_id): PathId<ActivityId>, PathId(activity_id): PathId<ActivityId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_activity::Deps { let deps = delete_activity::Deps {
entries: state.entry_query.clone(),
command: state.activity_command, command: state.activity_command,
query: state.activity_query, query: state.activity_query,
}; };

View File

@@ -3,20 +3,23 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::requests::LoginRequest; use api_types::requests::LoginRequest;
use api_types::responses::UserResponse; use api_types::responses::{ErrorResponse, RefreshedResponse, SignedInResponse, UserResponse};
use application::auth::use_cases::{login, logout, refresh}; use application::auth::use_cases::{login, logout, refresh};
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::Body;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/auth/login", tag = "auth", #[utoipa::path(post, path = "/api/v1/auth/login", tag = "auth",
request_body = LoginRequest, request_body = LoginRequest,
responses((status = 200, description = "Login successful")) responses((status = 200, body = SignedInResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_login( pub async fn handle_login(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LoginRequest>, Body(body): Body<LoginRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<SignedInResponse>, ApiError> {
let cmd = body.into_command(); let cmd = body.into_command();
let deps = login::Deps { let deps = login::Deps {
user_query: state.user_query, user_query: state.user_query,
@@ -27,14 +30,13 @@ pub async fn handle_login(
}; };
let result = login::execute(cmd, &deps).await?; let result = login::execute(cmd, &deps).await?;
let user_response = UserResponse::from(result.user);
Ok(Json(serde_json::json!({ Ok(Json(SignedInResponse {
"accessToken": result.access_token.token(), access_token: result.access_token.token().to_string(),
"refreshToken": result.refresh_token, refresh_token: result.refresh_token,
"expiresAt": result.access_token.expires_at(), expires_at: *result.access_token.expires_at(),
"user": user_response, user: UserResponse::from(result.user),
}))) }))
} }
#[derive(serde::Deserialize, utoipa::ToSchema)] #[derive(serde::Deserialize, utoipa::ToSchema)]
@@ -45,12 +47,14 @@ pub struct RefreshRequest {
#[utoipa::path(post, path = "/api/v1/auth/refresh", tag = "auth", #[utoipa::path(post, path = "/api/v1/auth/refresh", tag = "auth",
request_body = RefreshRequest, request_body = RefreshRequest,
responses((status = 200, description = "Token refreshed")) responses((status = 200, body = RefreshedResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_refresh( pub async fn handle_refresh(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<RefreshRequest>, Body(body): Body<RefreshRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<RefreshedResponse>, ApiError> {
let deps = refresh::Deps { let deps = refresh::Deps {
auth_service: state.auth_service, auth_service: state.auth_service,
refresh_session_command: state.refresh_session_command, refresh_session_command: state.refresh_session_command,
@@ -60,11 +64,11 @@ pub async fn handle_refresh(
let result = refresh::execute(&body.refresh_token, &deps).await?; let result = refresh::execute(&body.refresh_token, &deps).await?;
Ok(Json(serde_json::json!({ Ok(Json(RefreshedResponse {
"accessToken": result.access_token.token(), access_token: result.access_token.token().to_string(),
"refreshToken": result.refresh_token, refresh_token: result.refresh_token,
"expiresAt": result.access_token.expires_at(), expires_at: *result.access_token.expires_at(),
}))) }))
} }
#[derive(serde::Deserialize, utoipa::ToSchema)] #[derive(serde::Deserialize, utoipa::ToSchema)]
@@ -75,11 +79,13 @@ pub struct LogoutRequest {
#[utoipa::path(post, path = "/api/v1/auth/logout", tag = "auth", #[utoipa::path(post, path = "/api/v1/auth/logout", tag = "auth",
request_body = LogoutRequest, request_body = LogoutRequest,
responses((status = 204, description = "Logged out")) responses((status = 204, description = "Logged out"),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_logout( pub async fn handle_logout(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LogoutRequest>, Body(body): Body<LogoutRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = logout::Deps { let deps = logout::Deps {
refresh_session_command: state.refresh_session_command, refresh_session_command: state.refresh_session_command,

View File

@@ -1,13 +1,13 @@
use axum::Json; use axum::Json;
use axum::extract::{Query, State}; use axum::extract::State;
use api_types::requests::DateSpanParams; use api_types::params::DateSpanParams;
use api_types::responses::CorrelationRowResponse; use api_types::responses::{CorrelationRowResponse, ErrorResponse};
use application::correlation::queries::CorrelationQuery; use application::correlation::queries::CorrelationQuery;
use application::correlation::use_cases::get_correlations; use application::correlation::use_cases::get_correlations;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser; use crate::extractors::{JournalReader, Params};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/correlations", tag = "correlations", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/correlations", tag = "correlations", security(("bearer" = [])),
@@ -18,12 +18,16 @@ use crate::state::AppState;
never ranked by strength. Below the configured minimum sample size a row \ never ranked by strength. Below the configured minimum sample size a row \
carries its day count and no coefficient.", carries its day count and no coefficient.",
params(DateSpanParams), params(DateSpanParams),
responses((status = 200, body = Vec<CorrelationRowResponse>)) responses((status = 200, body = Vec<CorrelationRowResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Query(params): Query<DateSpanParams>, Params(params): Params<DateSpanParams>,
) -> Result<Json<Vec<CorrelationRowResponse>>, ApiError> { ) -> Result<Json<Vec<CorrelationRowResponse>>, ApiError> {
let span = params.into_span()?; let span = params.into_span()?;
@@ -33,6 +37,7 @@ pub async fn handle_list(
activities: state.activity_query, activities: state.activity_query,
cycles: state.cycle_query, cycles: state.cycle_query,
weather_store: state.weather_store, weather_store: state.weather_store,
activity_store: state.activity_store,
preferences: state.preferences_query, preferences: state.preferences_query,
users: state.user_query, users: state.user_query,
}; };

View File

@@ -4,23 +4,27 @@ use axum::http::StatusCode;
use api_types::mappers::parse_date; use api_types::mappers::parse_date;
use api_types::requests::SetPreferencesRequest; use api_types::requests::SetPreferencesRequest;
use api_types::responses::{CycleViewResponse, PreferencesResponse}; use api_types::responses::{CycleViewResponse, ErrorResponse, PreferencesResponse};
use application::cycle::use_cases::{forget_cycle_start, read_cycle, record_cycle_start}; use application::cycle::use_cases::{forget_cycle_start, read_cycle, record_cycle_start};
use application::user::use_cases::set_preferences; use application::user::use_cases::set_preferences;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser; use crate::extractors::{Body, JournalReader, JournalWriter, ProfileReader, SessionUser};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/cycle", tag = "cycle", security(("bearer" = [])), #[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 \ 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. \ never stored: correcting a start corrects every day that depended on it. \
Returns nothing at all while cycle tracking is off.", Returns nothing at all while cycle tracking is off.",
responses((status = 200, body = CycleViewResponse)) responses((status = 200, body = CycleViewResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_read( pub async fn handle_read(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
) -> Result<Json<CycleViewResponse>, ApiError> { ) -> Result<Json<CycleViewResponse>, ApiError> {
let deps = read_cycle::Deps { let deps = read_cycle::Deps {
query: state.cycle_query, query: state.cycle_query,
@@ -37,11 +41,15 @@ pub async fn handle_read(
description = "Records that a cycle began on this date. Recording the same date twice \ description = "Records that a cycle began on this date. Recording the same date twice \
records it once.", records it once.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")), params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_record( pub async fn handle_record(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Path(date): Path<String>, Path(date): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = record_cycle_start::Deps { let deps = record_cycle_start::Deps {
@@ -58,11 +66,15 @@ pub async fn handle_record(
description = "Forgets a recorded start. Every day that derived its cycle day from it \ description = "Forgets a recorded start. Every day that derived its cycle day from it \
changes at once.", changes at once.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")), params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_forget( pub async fn handle_forget(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Path(date): Path<String>, Path(date): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = forget_cycle_start::Deps { let deps = forget_cycle_start::Deps {
@@ -78,12 +90,16 @@ pub async fn handle_forget(
description = "Turns optional features on or off. Cycle tracking is off until turned on, \ 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.", and turning it off hides the cycle without forgetting what was recorded.",
request_body = SetPreferencesRequest, request_body = SetPreferencesRequest,
responses((status = 200, body = PreferencesResponse)) responses((status = 200, body = PreferencesResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_set_preferences( pub async fn handle_set_preferences(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<SetPreferencesRequest>, Body(body): Body<SetPreferencesRequest>,
) -> Result<Json<PreferencesResponse>, ApiError> { ) -> Result<Json<PreferencesResponse>, ApiError> {
let deps = set_preferences::Deps { let deps = set_preferences::Deps {
command: state.preferences_command, command: state.preferences_command,
@@ -96,11 +112,15 @@ pub async fn handle_set_preferences(
} }
#[utoipa::path(get, path = "/api/v1/users/me/preferences", tag = "users", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/users/me/preferences", tag = "users", security(("bearer" = [])),
responses((status = 200, body = PreferencesResponse)) responses((status = 200, body = PreferencesResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_preferences( pub async fn handle_preferences(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, ProfileReader(user_id): ProfileReader,
) -> Result<Json<PreferencesResponse>, ApiError> { ) -> Result<Json<PreferencesResponse>, ApiError> {
let preferences = let preferences =
application::user::preferences::preferences_of(&user_id, &state.preferences_query).await?; application::user::preferences::preferences_of(&user_id, &state.preferences_query).await?;

View File

@@ -3,13 +3,13 @@ use axum::extract::{Multipart, State};
use axum::http::{StatusCode, header}; use axum::http::{StatusCode, header};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use api_types::responses::RestoreOutcomeResponse; use api_types::responses::{ErrorResponse, RestoreOutcomeResponse};
use application::export::use_cases::{write_backup, write_extract}; use application::export::use_cases::{write_backup, write_extract};
use application::restore::commands::RestoreBackupCommand; use application::restore::commands::RestoreBackupCommand;
use application::restore::use_cases::restore_backup; use application::restore::use_cases::restore_backup;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, extract_file_bytes}; use crate::extractors::{SessionUser, extract_file_bytes};
use crate::state::AppState; use crate::state::AppState;
const BACKUP_FILENAME: &str = "k-mood-complete-backup.zip"; const BACKUP_FILENAME: &str = "k-mood-complete-backup.zip";
@@ -20,11 +20,15 @@ const EXTRACT_FILENAME: &str = "k-mood-shareable-journal.md";
every cycle start, the activity catalogue, reminders, preferences and all \ every cycle start, the activity catalogue, reminders, preferences and all \
media. Restores through /data/restore. Keep it private — it holds everything \ media. Restores through /data/restore. Keep it private — it holds everything \
the account knows.", the account knows.",
responses((status = 200, description = "A zip archive", content_type = "application/zip")) responses((status = 200, description = "A zip archive", content_type = "application/zip"),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_backup( pub async fn handle_backup(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let deps = write_backup::Deps { let deps = write_backup::Deps {
entries: state.entry_query, entries: state.entry_query,
@@ -48,11 +52,15 @@ pub async fn handle_backup(
readable markdown document. It carries no places, no health readings, no \ 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 \ cycle records and no media, and it cannot be restored from. This is the one \
to hand to someone.", to hand to someone.",
responses((status = 200, description = "A markdown document", content_type = "text/markdown")) responses((status = 200, description = "A markdown document", content_type = "text/markdown"),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_extract( pub async fn handle_extract(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let deps = write_extract::Deps { let deps = write_extract::Deps {
entries: state.entry_query, entries: state.entry_query,
@@ -74,16 +82,23 @@ pub async fn handle_extract(
description = "Restores a complete backup into this account. Existing data is kept: a \ 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 \ restore adds, it does not replace. Anything in the archive this build cannot \
read is reported rather than silently dropped.", read is reported rather than silently dropped.",
responses((status = 200, body = RestoreOutcomeResponse)) responses((status = 200, body = RestoreOutcomeResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_restore( pub async fn handle_restore(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
multipart: Multipart, multipart: Multipart,
) -> Result<Json<RestoreOutcomeResponse>, ApiError> { ) -> Result<Json<RestoreOutcomeResponse>, ApiError> {
let data = extract_file_bytes(multipart).await?; let data = extract_file_bytes(multipart).await?;
let deps = restore_backup::Deps { let deps = restore_backup::Deps {
entry_query: state.entry_query.clone(),
reminder_query: state.reminder_query.clone(),
media_ownership: state.media_ownership.clone(),
reader: state.backup_reader, reader: state.backup_reader,
entry_command: state.entry_command, entry_command: state.entry_command,
dimensions: state.dimensions, dimensions: state.dimensions,

View File

@@ -1,26 +1,25 @@
use axum::Json; use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::params::{DateRangeParams, ListEntriesParams};
use api_types::requests::{ use api_types::requests::{
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest, CreateEntriesRequest, CreateEntryRequest, ReplaceActivityRequest, UpdateEntryRequest,
UpdateEntryRequest,
}; };
use api_types::responses::{ use api_types::responses::{
BulkActionResponse, CalendarDayResponse, EntryResponse, MoodStatsResponse, BulkActionResponse, CalendarDayResponse, EntryPageResponse, EntryResponse, ErrorResponse,
MoodStatsResponse,
}; };
use application::entry::composition::EntryComposer; use application::entry::composition::EntryComposer;
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery, MoodStatsQuery}; use application::entry::queries::MoodStatsQuery;
use application::entry::use_cases::{ use application::entry::use_cases::{
create_entry, delete_entries_by_date_range, delete_entry, filter_by_activity, filter_by_mood, create_entries, create_entry, delete_entries_by_date_range, delete_entry, get_calendar,
get_calendar, get_entry, get_mood_stats, list_entries, replace_activity, update_entry, get_entry, get_mood_stats, list_entries, replace_activity, update_entry,
}; };
use domain::activity::ActivityId; use domain::entry::{MoodEntry, MoodEntryId, Page};
use domain::entry::MoodEntry;
use domain::entry::{Mood, MoodEntryId};
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId}; use crate::extractors::{Body, JournalReader, JournalWriter, Params, PathId};
use crate::state::AppState; use crate::state::AppState;
async fn compose( async fn compose(
@@ -31,6 +30,24 @@ async fn compose(
Ok(composed.into_iter().map(EntryResponse::from).collect()) Ok(composed.into_iter().map(EntryResponse::from).collect())
} }
async fn composed_page(
dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>,
page: Page<MoodEntry>,
) -> Result<EntryPageResponse, ApiError> {
let total = page.total();
let limit = page.limit();
let offset = page.offset();
let has_more = page.more_after_this();
Ok(EntryPageResponse {
items: compose(dimensions, page.into_items()).await?,
total,
limit,
offset,
has_more,
})
}
async fn compose_one( async fn compose_one(
dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>, dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>,
entry: MoodEntry, entry: MoodEntry,
@@ -41,16 +58,21 @@ async fn compose_one(
#[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
request_body = CreateEntryRequest, request_body = CreateEntryRequest,
responses((status = 201, body = EntryResponse)) responses((status = 201, body = EntryResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_create( pub async fn handle_create(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Json(body): Json<CreateEntryRequest>, Body(body): Body<CreateEntryRequest>,
) -> Result<(StatusCode, Json<EntryResponse>), ApiError> { ) -> Result<(StatusCode, Json<EntryResponse>), ApiError> {
let cmd = body.into_command(user_id, &state.entry_config)?; let cmd = body.into_command(user_id, &state.entry_config)?;
let dimensions = state.dimensions.clone(); let dimensions = state.dimensions.clone();
let deps = create_entry::Deps { let deps = create_entry::Deps {
activities: state.activity_query.clone(),
entries: state.entry_command, entries: state.entry_command,
dimensions: state.dimensions.clone(), dimensions: state.dimensions.clone(),
events: state.event_publisher, events: state.event_publisher,
@@ -62,13 +84,55 @@ pub async fn handle_create(
)) ))
} }
#[utoipa::path(post, path = "/api/v1/entries/bulk", tag = "entries", security(("bearer" = [])),
description = "Creates many entries in one request, for a client syncing what it collected \
while offline. Either every entry is written or none is: one activity the \
account does not own refuses the whole batch.",
request_body = CreateEntriesRequest,
responses((status = 201, body = Vec<EntryResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)]
pub async fn handle_create_many(
State(state): State<AppState>,
JournalWriter(user_id): JournalWriter,
Body(body): Body<CreateEntriesRequest>,
) -> Result<(StatusCode, Json<Vec<EntryResponse>>), ApiError> {
let wanted = body
.entries
.into_iter()
.map(|one| one.into_command(user_id.clone(), &state.entry_config))
.collect::<Result<Vec<_>, _>>()?;
let dimensions = state.dimensions.clone();
let deps = create_entries::Deps {
entries: state.entry_command,
dimensions: state.dimensions.clone(),
activities: state.activity_query.clone(),
events: state.event_publisher,
};
let created = create_entries::execute(user_id, wanted, &deps).await?;
Ok((
StatusCode::CREATED,
Json(compose(dimensions, created).await?),
))
}
#[utoipa::path(get, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
params(("id" = String, Path, description = "Entry ID")), params(("id" = String, Path, description = "Entry ID")),
responses((status = 200, body = EntryResponse)) responses((status = 200, body = EntryResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_get( pub async fn handle_get(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
PathId(entry_id): PathId<MoodEntryId>, PathId(entry_id): PathId<MoodEntryId>,
) -> Result<Json<EntryResponse>, ApiError> { ) -> Result<Json<EntryResponse>, ApiError> {
let dimensions = state.dimensions.clone(); let dimensions = state.dimensions.clone();
@@ -81,36 +145,52 @@ pub async fn handle_get(
#[utoipa::path(get, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
params(ListEntriesParams), params(ListEntriesParams),
responses((status = 200, body = Vec<EntryResponse>)) description = "One page of entries, newest first, narrowed by any combination of the \
parameters. `total` counts everything the selection matches, not the page, \
so a client can page with confidence. `updatedSince` returns only entries \
changed after that instant, which is what a polling client should use rather \
than refetching the journal.",
responses((status = 200, body = EntryPageResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Query(params): Query<ListEntriesParams>, Params(params): Params<ListEntriesParams>,
) -> Result<Json<Vec<EntryResponse>>, ApiError> { ) -> Result<Json<EntryPageResponse>, ApiError> {
let query = params.into_query(user_id)?; let query = params.into_query(user_id, &state.entry_config)?;
let dimensions = state.dimensions.clone(); let dimensions = state.dimensions.clone();
let deps = list_entries::Deps { let deps = list_entries::Deps {
query: state.entry_query, query: state.entry_query,
}; };
let entries = list_entries::execute(query, &deps).await?;
Ok(Json(compose(dimensions, entries).await?)) let page = list_entries::execute(query, &deps).await?;
Ok(Json(composed_page(dimensions, page).await?))
} }
#[utoipa::path(patch, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
params(("id" = String, Path, description = "Entry ID")), params(("id" = String, Path, description = "Entry ID")),
request_body = UpdateEntryRequest, request_body = UpdateEntryRequest,
responses((status = 200, body = EntryResponse)) responses((status = 200, body = EntryResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_update( pub async fn handle_update(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(entry_id): PathId<MoodEntryId>, PathId(entry_id): PathId<MoodEntryId>,
Json(body): Json<UpdateEntryRequest>, Body(body): Body<UpdateEntryRequest>,
) -> Result<Json<EntryResponse>, ApiError> { ) -> Result<Json<EntryResponse>, ApiError> {
let cmd = body.into_command(entry_id, &state.entry_config)?; let cmd = body.into_command(entry_id, &state.entry_config)?;
let dimensions = state.dimensions.clone(); let dimensions = state.dimensions.clone();
let deps = update_entry::Deps { let deps = update_entry::Deps {
activities: state.activity_query.clone(),
command: state.entry_command, command: state.entry_command,
dimensions: state.dimensions.clone(), dimensions: state.dimensions.clone(),
query: state.entry_query, query: state.entry_query,
@@ -123,11 +203,15 @@ pub async fn handle_update(
#[utoipa::path(delete, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
params(("id" = String, Path, description = "Entry ID")), params(("id" = String, Path, description = "Entry ID")),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete( pub async fn handle_delete(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(entry_id): PathId<MoodEntryId>, PathId(entry_id): PathId<MoodEntryId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_entry::Deps { let deps = delete_entry::Deps {
@@ -142,53 +226,65 @@ pub async fn handle_delete(
} }
#[utoipa::path(get, path = "/api/v1/entries/filter/mood/{mood}", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries/filter/mood/{mood}", tag = "entries", security(("bearer" = [])),
params(("mood" = u8, Path, description = "Mood value 1-5")), description = "A page of entries of one mood. The same selection is available on \
responses((status = 200, body = Vec<EntryResponse>)) GET /entries as the `mood` parameter, alongside every other narrowing.",
params(("mood" = u8, Path, description = "Mood value 1-5"), ListEntriesParams),
responses((status = 200, body = EntryPageResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_filter_by_mood( pub async fn handle_filter_by_mood(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, reader: JournalReader,
Path(mood): Path<u8>, Path(mood): Path<u8>,
) -> Result<Json<Vec<EntryResponse>>, ApiError> { Params(params): Params<ListEntriesParams>,
let mood = Mood::try_from(mood)?; ) -> Result<Json<EntryPageResponse>, ApiError> {
let query = FilterByMoodQuery { user_id, mood }; let narrowed = ListEntriesParams {
let dimensions = state.dimensions.clone(); mood: Some(mood),
let deps = filter_by_mood::Deps { ..params
query: state.entry_query,
}; };
let entries = filter_by_mood::execute(query, &deps).await?;
Ok(Json(compose(dimensions, entries).await?)) handle_list(State(state), reader, Params(narrowed)).await
} }
#[utoipa::path(get, path = "/api/v1/entries/filter/activity/{id}", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries/filter/activity/{id}", tag = "entries", security(("bearer" = [])),
params(("id" = String, Path, description = "Activity ID")), description = "A page of entries tagged with one activity. The same selection is available \
responses((status = 200, body = Vec<EntryResponse>)) on GET /entries as the `activity` parameter.",
params(("id" = String, Path, description = "Activity ID"), ListEntriesParams),
responses((status = 200, body = EntryPageResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_filter_by_activity( pub async fn handle_filter_by_activity(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, reader: JournalReader,
PathId(activity_id): PathId<ActivityId>, Path(activity_id): Path<String>,
) -> Result<Json<Vec<EntryResponse>>, ApiError> { Params(params): Params<ListEntriesParams>,
let query = FilterByActivityQuery { ) -> Result<Json<EntryPageResponse>, ApiError> {
user_id, let narrowed = ListEntriesParams {
activity_id, activity: Some(activity_id),
..params
}; };
let dimensions = state.dimensions.clone();
let deps = filter_by_activity::Deps { handle_list(State(state), reader, Params(narrowed)).await
query: state.entry_query,
};
let entries = filter_by_activity::execute(query, &deps).await?;
Ok(Json(compose(dimensions, entries).await?))
} }
#[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])),
params(ListEntriesParams), params(ListEntriesParams),
responses((status = 200, body = MoodStatsResponse)) responses((status = 200, body = MoodStatsResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_stats( pub async fn handle_stats(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Query(params): Query<ListEntriesParams>, Params(params): Params<ListEntriesParams>,
) -> Result<Json<MoodStatsResponse>, ApiError> { ) -> Result<Json<MoodStatsResponse>, ApiError> {
let range = match (params.from, params.to) { let range = match (params.from, params.to) {
(Some(from), Some(to)) => { (Some(from), Some(to)) => {
@@ -209,12 +305,16 @@ pub async fn handle_stats(
#[utoipa::path(get, path = "/api/v1/entries/calendar", tag = "entries", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/entries/calendar", tag = "entries", security(("bearer" = [])),
params(DateRangeParams), params(DateRangeParams),
responses((status = 200, body = Vec<CalendarDayResponse>)) responses((status = 200, body = Vec<CalendarDayResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_calendar( pub async fn handle_calendar(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Query(params): Query<DateRangeParams>, Params(params): Params<DateRangeParams>,
) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> { ) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> {
let range = params.into_date_range()?; let range = params.into_date_range()?;
let deps = get_calendar::Deps { let deps = get_calendar::Deps {
@@ -232,12 +332,16 @@ pub async fn handle_calendar(
#[utoipa::path(delete, path = "/api/v1/entries/bulk/delete", tag = "entries", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/entries/bulk/delete", tag = "entries", security(("bearer" = [])),
params(DateRangeParams), params(DateRangeParams),
responses((status = 200, body = BulkActionResponse)) responses((status = 200, body = BulkActionResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete_by_date_range( pub async fn handle_delete_by_date_range(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Query(params): Query<DateRangeParams>, Params(params): Params<DateRangeParams>,
) -> Result<Json<BulkActionResponse>, ApiError> { ) -> Result<Json<BulkActionResponse>, ApiError> {
let range = params.into_date_range()?; let range = params.into_date_range()?;
let deps = delete_entries_by_date_range::Deps { let deps = delete_entries_by_date_range::Deps {
@@ -252,12 +356,16 @@ pub async fn handle_delete_by_date_range(
#[utoipa::path(post, path = "/api/v1/entries/bulk/replace-activity", tag = "entries", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/entries/bulk/replace-activity", tag = "entries", security(("bearer" = [])),
request_body = ReplaceActivityRequest, request_body = ReplaceActivityRequest,
responses((status = 200, body = BulkActionResponse)) responses((status = 200, body = BulkActionResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_replace_activity( pub async fn handle_replace_activity(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Json(body): Json<ReplaceActivityRequest>, Body(body): Body<ReplaceActivityRequest>,
) -> Result<Json<BulkActionResponse>, ApiError> { ) -> Result<Json<BulkActionResponse>, ApiError> {
let (user_id, old_id, new_id) = body.into_parts(user_id)?; let (user_id, old_id, new_id) = body.into_parts(user_id)?;
let deps = replace_activity::Deps { let deps = replace_activity::Deps {

View File

@@ -1,20 +1,24 @@
use axum::Json; use axum::Json;
use axum::extract::{Multipart, State}; use axum::extract::{Multipart, State};
use api_types::responses::ImportResultResponse; use api_types::responses::{ErrorResponse, ImportResultResponse};
use application::import::commands::ImportCommand; use application::import::commands::ImportCommand;
use application::import::use_cases::import_entries; use application::import::use_cases::import_entries;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, extract_file_bytes}; use crate::extractors::{SessionUser, extract_file_bytes};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/data/import", tag = "data", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/data/import", tag = "data", security(("bearer" = [])),
responses((status = 200, body = ImportResultResponse)) responses((status = 200, body = ImportResultResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_import( pub async fn handle_import(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
multipart: Multipart, multipart: Multipart,
) -> Result<Json<ImportResultResponse>, ApiError> { ) -> Result<Json<ImportResultResponse>, ApiError> {
let data = extract_file_bytes(multipart).await?; let data = extract_file_bytes(multipart).await?;

View File

@@ -0,0 +1,35 @@
use axum::Json;
use axum::extract::State;
use api_types::responses::{ErrorResponse, ExhaustedJobResponse};
use application::job::use_cases::list_exhausted_jobs;
use crate::errors::ApiError;
use crate::extractors::Administrator;
use crate::state::AppState;
const MOST_SHOWN: usize = 100;
#[utoipa::path(get, path = "/api/v1/jobs/exhausted", tag = "jobs", security(("bearer" = [])),
description = "Background work that used every attempt and stopped being retried, with the \
reason the last attempt failed. Losing a job costs promptness and never data, \
so this is an operator's view of what is stale rather than what is missing. \
Needs an administrator's session.",
responses((status = 200, body = Vec<ExhaustedJobResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)]
pub async fn handle_list_exhausted(
State(state): State<AppState>,
Administrator(_who): Administrator,
) -> Result<Json<Vec<ExhaustedJobResponse>>, ApiError> {
let deps = list_exhausted_jobs::Deps {
jobs: state.job_view,
};
let stalled = list_exhausted_jobs::execute(MOST_SHOWN, &deps).await?;
Ok(Json(stalled.into_iter().map(Into::into).collect()))
}

View File

@@ -3,55 +3,89 @@ use axum::extract::{Multipart, State};
use axum::http::{StatusCode, header}; use axum::http::{StatusCode, header};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use api_types::responses::MediaIdResponse; use api_types::responses::{ErrorResponse, MediaIdResponse, OwnedMediaResponse};
use application::media::use_cases::{ use application::media::use_cases::{
delete_photo, delete_voice_memo, upload_photo, upload_voice_memo, delete_photo, delete_voice_memo, list_media, upload_photo, upload_voice_memo,
}; };
use domain::attachment::{PhotoId, VoiceMemoId}; use domain::attachment::{PhotoId, VoiceMemoId};
use domain::errors::DomainError; use domain::errors::DomainError;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId, extract_media_upload}; use crate::extractors::{JournalReader, JournalWriter, PathId, extract_media_upload};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/media/photos", tag = "media", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/media/photos", tag = "media", security(("bearer" = [])),
responses((status = 201, body = MediaIdResponse)) responses((status = 201, body = MediaIdResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_upload_photo( pub async fn handle_upload_photo(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
multipart: Multipart, multipart: Multipart,
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> { ) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
let upload = extract_media_upload(multipart).await?; let upload = extract_media_upload(multipart).await?;
let deps = upload_photo::Deps { let deps = upload_photo::Deps {
storage: state.media_storage, storage: state.media_storage,
ownership: state.media_ownership,
}; };
let photo_id = upload_photo::execute(upload, &deps).await?; let photo_id = upload_photo::execute(user_id, upload, &deps).await?;
Ok((StatusCode::CREATED, Json(MediaIdResponse::from(photo_id)))) Ok((StatusCode::CREATED, Json(MediaIdResponse::from(photo_id))))
} }
#[utoipa::path(post, path = "/api/v1/media/voice-memos", tag = "media", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/media/voice-memos", tag = "media", security(("bearer" = [])),
responses((status = 201, body = MediaIdResponse)) responses((status = 201, body = MediaIdResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_upload_voice_memo( pub async fn handle_upload_voice_memo(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
multipart: Multipart, multipart: Multipart,
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> { ) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
let upload = extract_media_upload(multipart).await?; let upload = extract_media_upload(multipart).await?;
let deps = upload_voice_memo::Deps { let deps = upload_voice_memo::Deps {
storage: state.media_storage, storage: state.media_storage,
ownership: state.media_ownership,
}; };
let voice_memo_id = upload_voice_memo::execute(upload, &deps).await?; let voice_memo_id = upload_voice_memo::execute(user_id, upload, &deps).await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(MediaIdResponse::from(voice_memo_id)), Json(MediaIdResponse::from(voice_memo_id)),
)) ))
} }
#[utoipa::path(get, path = "/api/v1/media", tag = "media", security(("bearer" = [])),
description = "Every photo and voice memo this account owns, including any uploaded but \
never attached to an entry, each with the url it is served from.",
responses((status = 200, body = Vec<OwnedMediaResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)]
pub async fn handle_list(
State(state): State<AppState>,
JournalReader(user_id): JournalReader,
) -> Result<Json<Vec<OwnedMediaResponse>>, ApiError> {
let deps = list_media::Deps {
ownership: state.media_ownership,
};
let held = list_media::execute(user_id, &deps).await?;
Ok(Json(held.into_iter().map(Into::into).collect()))
}
#[utoipa::path(get, path = "/api/v1/media/photos/{id}", tag = "media", #[utoipa::path(get, path = "/api/v1/media/photos/{id}", tag = "media",
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 200, description = "Photo binary")) responses((status = 200, description = "Photo binary"),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_serve_photo( pub async fn handle_serve_photo(
State(state): State<AppState>, State(state): State<AppState>,
@@ -71,7 +105,9 @@ pub async fn handle_serve_photo(
#[utoipa::path(get, path = "/api/v1/media/voice-memos/{id}", tag = "media", #[utoipa::path(get, path = "/api/v1/media/voice-memos/{id}", tag = "media",
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 200, description = "Voice memo binary")) responses((status = 200, description = "Voice memo binary"),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_serve_voice_memo( pub async fn handle_serve_voice_memo(
State(state): State<AppState>, State(state): State<AppState>,
@@ -91,32 +127,42 @@ pub async fn handle_serve_voice_memo(
#[utoipa::path(delete, path = "/api/v1/media/photos/{id}", tag = "media", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/media/photos/{id}", tag = "media", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete_photo( pub async fn handle_delete_photo(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(photo_id): PathId<PhotoId>, PathId(photo_id): PathId<PhotoId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_photo::Deps { let deps = delete_photo::Deps {
storage: state.media_storage, storage: state.media_storage,
ownership: state.media_ownership,
}; };
delete_photo::execute(photo_id, &deps).await?; delete_photo::execute(photo_id, user_id, &deps).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(delete, path = "/api/v1/media/voice-memos/{id}", tag = "media", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/media/voice-memos/{id}", tag = "media", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete_voice_memo( pub async fn handle_delete_voice_memo(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(voice_memo_id): PathId<VoiceMemoId>, PathId(voice_memo_id): PathId<VoiceMemoId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_voice_memo::Deps { let deps = delete_voice_memo::Deps {
storage: state.media_storage, storage: state.media_storage,
ownership: state.media_ownership,
}; };
delete_voice_memo::execute(voice_memo_id, &deps).await?; delete_voice_memo::execute(voice_memo_id, user_id, &deps).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -1,27 +1,34 @@
use axum::Json; use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::mappers::parse_date; use api_types::mappers::parse_date;
use api_types::requests::{DateSpanParams, ImportDailyMetricsRequest, SetDailyMetricsRequest}; use api_types::params::DateSpanParams;
use api_types::responses::{DailyMetricResponse, ImportOutcomeResponse, RejectionResponse}; use api_types::requests::{ImportDailyMetricsRequest, SetDailyMetricsRequest};
use api_types::responses::{
DailyMetricResponse, ErrorResponse, ImportOutcomeResponse, RejectionResponse,
};
use application::import::commands::ImportDailyMetricsCommand; use application::import::commands::ImportDailyMetricsCommand;
use application::import::use_cases::import_daily_metrics; use application::import::use_cases::import_daily_metrics;
use application::metric::commands::SetDailyMetricsCommand; use application::metric::commands::SetDailyMetricsCommand;
use application::metric::use_cases::{list_daily_metrics, set_daily_metrics}; use application::metric::use_cases::{list_daily_metrics, list_rejections, set_daily_metrics};
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, ImportingProvider, MetricWriter}; use crate::extractors::{Body, ImportingProvider, JournalReader, MetricWriter, Params};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/metrics", tag = "metrics", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/metrics", tag = "metrics", security(("bearer" = [])),
params(DateSpanParams), params(DateSpanParams),
responses((status = 200, body = Vec<DailyMetricResponse>)) responses((status = 200, body = Vec<DailyMetricResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
Query(params): Query<DateSpanParams>, Params(params): Params<DateSpanParams>,
) -> Result<Json<Vec<DailyMetricResponse>>, ApiError> { ) -> Result<Json<Vec<DailyMetricResponse>>, ApiError> {
let span = params.into_span()?; let span = params.into_span()?;
@@ -40,13 +47,17 @@ pub async fn handle_list(
only once per request.", only once per request.",
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")), params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
request_body = SetDailyMetricsRequest, request_body = SetDailyMetricsRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_set( pub async fn handle_set(
State(state): State<AppState>, State(state): State<AppState>,
writer: MetricWriter, writer: MetricWriter,
Path(date): Path<String>, Path(date): Path<String>,
Json(body): Json<SetDailyMetricsRequest>, Body(body): Body<SetDailyMetricsRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let date = parse_date(&date)?; let date = parse_date(&date)?;
@@ -85,12 +96,16 @@ pub async fn handle_set(
rejection. Only a payload carrying more days than the configured limit is \ rejection. Only a payload carrying more days than the configured limit is \
refused outright.", refused outright.",
request_body = ImportDailyMetricsRequest, request_body = ImportDailyMetricsRequest,
responses((status = 200, body = ImportOutcomeResponse)) responses((status = 200, body = ImportOutcomeResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_import( pub async fn handle_import(
State(state): State<AppState>, State(state): State<AppState>,
importer: ImportingProvider, importer: ImportingProvider,
Json(body): Json<ImportDailyMetricsRequest>, Body(body): Body<ImportDailyMetricsRequest>,
) -> Result<Json<ImportOutcomeResponse>, ApiError> { ) -> Result<Json<ImportOutcomeResponse>, ApiError> {
let deps = import_daily_metrics::Deps { let deps = import_daily_metrics::Deps {
metrics: state.daily_metric_command, metrics: state.daily_metric_command,
@@ -115,13 +130,21 @@ pub async fn handle_import(
description = "Readings that could not be used, most recent first, whether they arrived \ 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 \ broken from an importer or were stored by an older build and can no longer be \
read. Only the most recent are kept.", read. Only the most recent are kept.",
responses((status = 200, body = Vec<RejectionResponse>)) responses((status = 200, body = Vec<RejectionResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_rejections( pub async fn handle_rejections(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
) -> Result<Json<Vec<RejectionResponse>>, ApiError> { ) -> Result<Json<Vec<RejectionResponse>>, ApiError> {
let rejections = state.rejection_query.find_recent_by_user(&user_id).await?; let deps = list_rejections::Deps {
rejections: state.rejection_query,
};
let rejections = list_rejections::execute(user_id, &deps).await?;
Ok(Json(rejections.into_iter().map(Into::into).collect())) Ok(Json(rejections.into_iter().map(Into::into).collect()))
} }

View File

@@ -5,10 +5,12 @@ pub mod cycle;
pub mod data; pub mod data;
pub mod entries; pub mod entries;
pub mod import_export; pub mod import_export;
pub mod jobs;
pub mod media; pub mod media;
pub mod metrics; pub mod metrics;
pub mod providers; pub mod providers;
pub mod push; pub mod push;
pub mod reminders; pub mod reminders;
pub mod server;
pub mod tokens; pub mod tokens;
pub mod users; pub mod users;

View File

@@ -4,7 +4,7 @@ use axum::http::StatusCode;
use api_types::dimension::DimensionPayload; use api_types::dimension::DimensionPayload;
use api_types::requests::ConnectProviderRequest; use api_types::requests::ConnectProviderRequest;
use api_types::responses::ProviderConnectionResponse; use api_types::responses::{ErrorResponse, ProviderConnectionResponse};
use application::provider::commands::ConnectProviderCommand; use application::provider::commands::ConnectProviderCommand;
use application::provider::use_cases::{ use application::provider::use_cases::{
connect_provider, disconnect_provider, get_now_playing, list_connections, connect_provider, disconnect_provider, get_now_playing, list_connections,
@@ -14,7 +14,7 @@ use domain::errors::DomainError;
use domain::provider::ProviderName; use domain::provider::ProviderName;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser; use crate::extractors::{Body, JournalReader, ProfileReader, SessionUser};
use crate::state::AppState; use crate::state::AppState;
fn cipher( fn cipher(
@@ -30,11 +30,15 @@ fn cipher(
} }
#[utoipa::path(get, path = "/api/v1/providers", tag = "providers", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/providers", tag = "providers", security(("bearer" = [])),
responses((status = 200, body = Vec<ProviderConnectionResponse>)) responses((status = 200, body = Vec<ProviderConnectionResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, ProfileReader(user_id): ProfileReader,
) -> Result<Json<Vec<ProviderConnectionResponse>>, ApiError> { ) -> Result<Json<Vec<ProviderConnectionResponse>>, ApiError> {
let deps = list_connections::Deps { let deps = list_connections::Deps {
query: state.provider_connection_query, query: state.provider_connection_query,
@@ -47,13 +51,17 @@ pub async fn handle_list(
#[utoipa::path(put, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])), #[utoipa::path(put, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])),
params(("provider" = String, Path, description = "Provider name")), params(("provider" = String, Path, description = "Provider name")),
request_body = ConnectProviderRequest, request_body = ConnectProviderRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_connect( pub async fn handle_connect(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Path(provider): Path<String>, Path(provider): Path<String>,
Json(body): Json<ConnectProviderRequest>, Body(body): Body<ConnectProviderRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cipher = cipher(&state)?; let cipher = cipher(&state)?;
let provider = ProviderName::new(provider)?; let provider = ProviderName::new(provider)?;
@@ -81,11 +89,15 @@ pub async fn handle_connect(
#[utoipa::path(delete, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/providers/{provider}", tag = "providers", security(("bearer" = [])),
params(("provider" = String, Path, description = "Provider name")), params(("provider" = String, Path, description = "Provider name")),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_disconnect( pub async fn handle_disconnect(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Path(provider): Path<String>, Path(provider): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let provider = ProviderName::new(provider)?; let provider = ProviderName::new(provider)?;
@@ -100,11 +112,15 @@ pub async fn handle_disconnect(
} }
#[utoipa::path(get, path = "/api/v1/providers/now-playing", tag = "providers", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/providers/now-playing", tag = "providers", security(("bearer" = [])),
responses((status = 200, body = Option<DimensionPayload>)) responses((status = 200, body = Option<DimensionPayload>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_now_playing( pub async fn handle_now_playing(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
) -> Result<Json<Option<DimensionPayload>>, ApiError> { ) -> Result<Json<Option<DimensionPayload>>, ApiError> {
let cipher = cipher(&state)?; let cipher = cipher(&state)?;

View File

@@ -3,18 +3,21 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::requests::{PushSubscribeRequest, PushUnsubscribeRequest}; use api_types::requests::{PushSubscribeRequest, PushUnsubscribeRequest};
use application::push::use_cases::{subscribe, unsubscribe}; use api_types::responses::{ErrorResponse, PushSubscriptionResponse, VapidKeyResponse};
use application::push::use_cases::{list_subscriptions, subscribe, unsubscribe};
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser; use crate::extractors::{Body, SessionUser};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/push/vapid-key", tag = "push", #[utoipa::path(get, path = "/api/v1/push/vapid-key", tag = "push",
responses((status = 200, description = "VAPID public key")) responses((status = 200, body = VapidKeyResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_vapid_key( pub async fn handle_vapid_key(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<VapidKeyResponse>, ApiError> {
if !state.push_config.enabled { if !state.push_config.enabled {
return Err(domain::errors::DomainError::NotFound( return Err(domain::errors::DomainError::NotFound(
"push notifications are disabled".into(), "push notifications are disabled".into(),
@@ -22,17 +25,44 @@ pub async fn handle_vapid_key(
.into()); .into());
} }
let public_key = web_push_adapter::WebPushSender::public_key_base64(&state.push_config)?; let public_key = web_push_adapter::WebPushSender::public_key_base64(&state.push_config)?;
Ok(Json(serde_json::json!({ "publicKey": public_key })))
Ok(Json(VapidKeyResponse { public_key }))
}
#[utoipa::path(get, path = "/api/v1/push/subscriptions", tag = "push", security(("bearer" = [])),
description = "The devices this account has subscribed for reminders. Keys are never \
returned; a client sees only which endpoints exist and when each was added.",
responses((status = 200, body = Vec<PushSubscriptionResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)]
pub async fn handle_list_subscriptions(
State(state): State<AppState>,
SessionUser(user_id): SessionUser,
) -> Result<Json<Vec<PushSubscriptionResponse>>, ApiError> {
let deps = list_subscriptions::Deps {
push_query: state.push_subscription_query,
};
let held = list_subscriptions::execute(user_id, &deps).await?;
Ok(Json(held.into_iter().map(Into::into).collect()))
} }
#[utoipa::path(post, path = "/api/v1/push/subscribe", tag = "push", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/push/subscribe", tag = "push", security(("bearer" = [])),
request_body = PushSubscribeRequest, request_body = PushSubscribeRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_subscribe( pub async fn handle_subscribe(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<PushSubscribeRequest>, Body(body): Body<PushSubscribeRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = body.into_command(user_id); let cmd = body.into_command(user_id);
let deps = subscribe::Deps { let deps = subscribe::Deps {
@@ -45,14 +75,18 @@ pub async fn handle_subscribe(
#[utoipa::path(post, path = "/api/v1/push/unsubscribe", tag = "push", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/push/unsubscribe", tag = "push", security(("bearer" = [])),
request_body = PushUnsubscribeRequest, request_body = PushUnsubscribeRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_unsubscribe( pub async fn handle_unsubscribe(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<PushUnsubscribeRequest>, Body(body): Body<PushUnsubscribeRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = body.into_command(); let cmd = body.into_command(user_id);
let deps = unsubscribe::Deps { let deps = unsubscribe::Deps {
push_command: state.push_subscription_command, push_command: state.push_subscription_command,
}; };
@@ -61,11 +95,15 @@ pub async fn handle_unsubscribe(
} }
#[utoipa::path(post, path = "/api/v1/push/test", tag = "push", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/push/test", tag = "push", security(("bearer" = [])),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_test( pub async fn handle_test(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let sender = state let sender = state
.reminder_sender .reminder_sender

View File

@@ -3,24 +3,28 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::requests::{CreateReminderRequest, UpdateReminderRequest}; use api_types::requests::{CreateReminderRequest, UpdateReminderRequest};
use api_types::responses::ReminderResponse; use api_types::responses::{ErrorResponse, ReminderResponse};
use application::reminder::use_cases::{ use application::reminder::use_cases::{
create_reminder, delete_reminder, get_reminder, list_reminders, update_reminder, create_reminder, delete_reminder, get_reminder, list_reminders, update_reminder,
}; };
use domain::reminder::ReminderId; use domain::reminder::ReminderId;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId}; use crate::extractors::{Body, JournalReader, JournalWriter, PathId};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])), #[utoipa::path(post, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
request_body = CreateReminderRequest, request_body = CreateReminderRequest,
responses((status = 201, body = ReminderResponse)) responses((status = 201, body = ReminderResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_create( pub async fn handle_create(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
Json(body): Json<CreateReminderRequest>, Body(body): Body<CreateReminderRequest>,
) -> Result<(StatusCode, Json<ReminderResponse>), ApiError> { ) -> Result<(StatusCode, Json<ReminderResponse>), ApiError> {
let cmd = body.into_command(user_id)?; let cmd = body.into_command(user_id)?;
let deps = create_reminder::Deps { let deps = create_reminder::Deps {
@@ -33,11 +37,15 @@ pub async fn handle_create(
#[utoipa::path(get, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 200, body = ReminderResponse)) responses((status = 200, body = ReminderResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_get( pub async fn handle_get(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
PathId(reminder_id): PathId<ReminderId>, PathId(reminder_id): PathId<ReminderId>,
) -> Result<Json<ReminderResponse>, ApiError> { ) -> Result<Json<ReminderResponse>, ApiError> {
let deps = get_reminder::Deps { let deps = get_reminder::Deps {
@@ -48,11 +56,15 @@ pub async fn handle_get(
} }
#[utoipa::path(get, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
responses((status = 200, body = Vec<ReminderResponse>)) responses((status = 200, body = Vec<ReminderResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalReader(user_id): JournalReader,
) -> Result<Json<Vec<ReminderResponse>>, ApiError> { ) -> Result<Json<Vec<ReminderResponse>>, ApiError> {
let deps = list_reminders::Deps { let deps = list_reminders::Deps {
query: state.reminder_query, query: state.reminder_query,
@@ -66,13 +78,17 @@ pub async fn handle_list(
#[utoipa::path(patch, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
request_body = UpdateReminderRequest, request_body = UpdateReminderRequest,
responses((status = 200, body = ReminderResponse)) responses((status = 200, body = ReminderResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_update( pub async fn handle_update(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(reminder_id): PathId<ReminderId>, PathId(reminder_id): PathId<ReminderId>,
Json(body): Json<UpdateReminderRequest>, Body(body): Body<UpdateReminderRequest>,
) -> Result<Json<ReminderResponse>, ApiError> { ) -> Result<Json<ReminderResponse>, ApiError> {
let cmd = body.into_command(reminder_id)?; let cmd = body.into_command(reminder_id)?;
let deps = update_reminder::Deps { let deps = update_reminder::Deps {
@@ -86,11 +102,15 @@ pub async fn handle_update(
#[utoipa::path(delete, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
params(("id" = String, Path)), params(("id" = String, Path)),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete( pub async fn handle_delete(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, JournalWriter(user_id): JournalWriter,
PathId(reminder_id): PathId<ReminderId>, PathId(reminder_id): PathId<ReminderId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_reminder::Deps { let deps = delete_reminder::Deps {

View File

@@ -0,0 +1,76 @@
use axum::Json;
use axum::extract::State;
use api_types::responses::{
ErrorResponse, MetricKindResponse, MoodResponse, ServerInfoResponse, ServerLimitsResponse,
TokenScopeResponse,
};
use domain::api_token::TokenScope;
use domain::entry::Mood;
use domain::metric::MetricKind;
use domain::weather::Condition;
use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/server", tag = "server",
description = "What this deployment is and what it allows, for a client deciding what to \
offer before anyone has signed in. Carries no secrets and needs no \
authentication. The vocabularies are the ones the API validates against, so \
a client can build its forms from here rather than hardcoding them.",
responses((status = 200, body = ServerInfoResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)]
pub async fn handle_info(State(state): State<AppState>) -> Json<ServerInfoResponse> {
Json(ServerInfoResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
registration_open: state.auth_config.allow_registration,
push_enabled: state.push_config.enabled,
weather_enabled: state.weather_enabled,
now_playing_provider: state
.now_playing
.as_ref()
.map(|playing| playing.provider().to_string()),
access_token_ttl_seconds: state.auth_config.access_token_ttl_seconds,
limits: ServerLimitsResponse {
max_body_size: state.server_config.max_body_size,
max_content_length: state.entry_config.max_content_length,
max_photos: state.entry_config.max_photos,
max_voice_memos: state.entry_config.max_voice_memos,
max_activities_per_entry: state.entry_config.max_activities_per_entry,
max_entries_per_page: state.entry_config.max_entries_per_page,
max_import_days: state.import_config.maximum_days_per_import,
},
token_scopes: TokenScope::ALL
.into_iter()
.map(|scope| TokenScopeResponse {
name: scope.name().to_string(),
describes: scope.describes().to_string(),
})
.collect(),
metric_kinds: MetricKind::ALL
.into_iter()
.map(|kind| {
let (minimum, maximum) = kind.bounds();
MetricKindResponse {
name: kind.name().to_string(),
unit: kind.unit().to_string(),
minimum,
maximum,
}
})
.collect(),
moods: Mood::ALL
.into_iter()
.map(|mood| MoodResponse {
value: mood.value(),
label: mood.label().to_string(),
})
.collect(),
weather_conditions: Condition::ALL
.into_iter()
.map(|condition| condition.name().to_string())
.collect(),
})
}

View File

@@ -3,24 +3,28 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::requests::MintApiTokenRequest; use api_types::requests::MintApiTokenRequest;
use api_types::responses::{ApiTokenResponse, MintedApiTokenResponse}; use api_types::responses::{ApiTokenResponse, ErrorResponse, MintedApiTokenResponse};
use application::api_token::commands::MintApiTokenCommand; use application::api_token::commands::MintApiTokenCommand;
use application::api_token::use_cases::{list_api_tokens, mint_api_token, revoke_api_token}; use application::api_token::use_cases::{list_api_tokens, mint_api_token, revoke_api_token};
use domain::api_token::ApiTokenId; use domain::api_token::{ApiTokenId, TokenScopes};
use domain::provider::ProviderName; use domain::provider::ProviderName;
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::{AuthenticatedUser, PathId}; use crate::extractors::{Body, PathId, SessionUser};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(get, path = "/api/v1/tokens", tag = "tokens", security(("bearer" = [])), #[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, \ description = "Lists this account's api tokens. Values are never returned; only the name, \
when it was minted, and when it was last used.", when it was minted, and when it was last used.",
responses((status = 200, body = Vec<ApiTokenResponse>)) responses((status = 200, body = Vec<ApiTokenResponse>),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_list( pub async fn handle_list(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<Json<Vec<ApiTokenResponse>>, ApiError> { ) -> Result<Json<Vec<ApiTokenResponse>>, ApiError> {
let deps = list_api_tokens::Deps { let deps = list_api_tokens::Deps {
query: state.api_token_query, query: state.api_token_query,
@@ -32,16 +36,23 @@ pub async fn handle_list(
} }
#[utoipa::path(post, path = "/api/v1/tokens", tag = "tokens", security(("bearer" = [])), #[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 \ description = "Mints a token granting the scopes named in the request, and nothing else. \
never retrievable again. The name becomes the Provider that the token's \ The value comes back once and is never retrievable again. The name becomes \
writes are attributed to, so it must be lowercase letters, digits and hyphens.", the Provider that the token's metric writes are attributed to, so it must be \
lowercase letters, digits and hyphens. Account operations — minting tokens, \
changing the password, backup and restore — always need a signed-in session \
and are never reachable with a token.",
request_body = MintApiTokenRequest, request_body = MintApiTokenRequest,
responses((status = 201, body = MintedApiTokenResponse)) responses((status = 201, body = MintedApiTokenResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_mint( pub async fn handle_mint(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<MintApiTokenRequest>, Body(body): Body<MintApiTokenRequest>,
) -> Result<(StatusCode, Json<MintedApiTokenResponse>), ApiError> { ) -> Result<(StatusCode, Json<MintedApiTokenResponse>), ApiError> {
let deps = mint_api_token::Deps { let deps = mint_api_token::Deps {
command: state.api_token_command, command: state.api_token_command,
@@ -52,6 +63,7 @@ pub async fn handle_mint(
MintApiTokenCommand { MintApiTokenCommand {
user_id, user_id,
name: ProviderName::new(body.name)?, name: ProviderName::new(body.name)?,
scopes: TokenScopes::from_names(body.scopes.iter().map(String::as_str))?,
}, },
&deps, &deps,
) )
@@ -63,11 +75,15 @@ pub async fn handle_mint(
#[utoipa::path(delete, path = "/api/v1/tokens/{id}", tag = "tokens", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/tokens/{id}", tag = "tokens", security(("bearer" = [])),
description = "Revokes a token. It stops working at once.", description = "Revokes a token. It stops working at once.",
params(("id" = String, Path, description = "Token ID")), params(("id" = String, Path, description = "Token ID")),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_revoke( pub async fn handle_revoke(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
PathId(token_id): PathId<ApiTokenId>, PathId(token_id): PathId<ApiTokenId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = revoke_api_token::Deps { let deps = revoke_api_token::Deps {

View File

@@ -3,22 +3,24 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use api_types::requests::{ChangePasswordRequest, RegisterRequest, UpdateProfileRequest}; use api_types::requests::{ChangePasswordRequest, RegisterRequest, UpdateProfileRequest};
use api_types::responses::UserResponse; use api_types::responses::{ErrorResponse, UserResponse};
use application::user::use_cases::{ use application::user::use_cases::{
change_password, clear_data, delete_user, get_profile, register, update_profile, change_password, clear_data, delete_user, get_profile, register, update_profile,
}; };
use crate::errors::ApiError; use crate::errors::ApiError;
use crate::extractors::AuthenticatedUser; use crate::extractors::{Body, ProfileReader, SessionUser};
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(post, path = "/api/v1/users/register", tag = "users", #[utoipa::path(post, path = "/api/v1/users/register", tag = "users",
request_body = RegisterRequest, request_body = RegisterRequest,
responses((status = 201, body = UserResponse)) responses((status = 201, body = UserResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_register( pub async fn handle_register(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<RegisterRequest>, Body(body): Body<RegisterRequest>,
) -> Result<(StatusCode, Json<UserResponse>), ApiError> { ) -> Result<(StatusCode, Json<UserResponse>), ApiError> {
if !state.auth_config.allow_registration { if !state.auth_config.allow_registration {
return Err( return Err(
@@ -39,11 +41,15 @@ pub async fn handle_register(
} }
#[utoipa::path(get, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])), #[utoipa::path(get, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
responses((status = 200, body = UserResponse)) responses((status = 200, body = UserResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_get_profile( pub async fn handle_get_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, ProfileReader(user_id): ProfileReader,
) -> Result<Json<UserResponse>, ApiError> { ) -> Result<Json<UserResponse>, ApiError> {
let deps = get_profile::Deps { let deps = get_profile::Deps {
user_query: state.user_query, user_query: state.user_query,
@@ -54,12 +60,16 @@ pub async fn handle_get_profile(
#[utoipa::path(patch, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
request_body = UpdateProfileRequest, request_body = UpdateProfileRequest,
responses((status = 200, body = UserResponse)) responses((status = 200, body = UserResponse),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_update_profile( pub async fn handle_update_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<UpdateProfileRequest>, Body(body): Body<UpdateProfileRequest>,
) -> Result<Json<UserResponse>, ApiError> { ) -> Result<Json<UserResponse>, ApiError> {
let cmd = body.into_command(user_id)?; let cmd = body.into_command(user_id)?;
let deps = update_profile::Deps { let deps = update_profile::Deps {
@@ -72,15 +82,20 @@ pub async fn handle_update_profile(
#[utoipa::path(patch, path = "/api/v1/users/me/password", tag = "users", security(("bearer" = [])), #[utoipa::path(patch, path = "/api/v1/users/me/password", tag = "users", security(("bearer" = [])),
request_body = ChangePasswordRequest, request_body = ChangePasswordRequest,
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_change_password( pub async fn handle_change_password(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
Json(body): Json<ChangePasswordRequest>, Body(body): Body<ChangePasswordRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = body.into_command(user_id); let cmd = body.into_command(user_id);
let deps = change_password::Deps { let deps = change_password::Deps {
refresh_sessions: state.refresh_session_command.clone(),
user_command: state.user_command, user_command: state.user_command,
user_query: state.user_query, user_query: state.user_query,
password_hasher: state.password_hasher, password_hasher: state.password_hasher,
@@ -90,18 +105,21 @@ pub async fn handle_change_password(
} }
#[utoipa::path(delete, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
responses((status = 204)) responses((status = 204),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_delete( pub async fn handle_delete(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = delete_user::Deps { let deps = delete_user::Deps {
dimensions: state.dimensions.clone(),
user_query: state.user_query, user_query: state.user_query,
entry_query: state.entry_query,
cascade: state.cascade, cascade: state.cascade,
media_storage: state.media_storage, media_storage: state.media_storage,
media_ownership: state.media_ownership,
events: state.event_publisher, events: state.event_publisher,
}; };
delete_user::execute(user_id, &deps).await?; delete_user::execute(user_id, &deps).await?;
@@ -109,17 +127,20 @@ pub async fn handle_delete(
} }
#[utoipa::path(delete, path = "/api/v1/users/me/data", tag = "users", security(("bearer" = [])), #[utoipa::path(delete, path = "/api/v1/users/me/data", tag = "users", security(("bearer" = [])),
responses((status = 204, description = "All user data cleared")) responses((status = 204, description = "All user data cleared"),
(status = 401, description = "No credential, or one this endpoint does not accept", body = ErrorResponse),
(status = 403, description = "A token that does not grant what this endpoint needs", body = ErrorResponse),
(status = 400, description = "Malformed request", body = ErrorResponse),
(status = 422, description = "Understood but refused", body = ErrorResponse))
)] )]
pub async fn handle_clear_data( pub async fn handle_clear_data(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, SessionUser(user_id): SessionUser,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let deps = clear_data::Deps { let deps = clear_data::Deps {
dimensions: state.dimensions.clone(),
entry_query: state.entry_query,
cascade: state.cascade, cascade: state.cascade,
media_storage: state.media_storage, media_storage: state.media_storage,
media_ownership: state.media_ownership,
}; };
clear_data::execute(user_id, &deps).await?; clear_data::execute(user_id, &deps).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)

View File

@@ -18,6 +18,7 @@ use utoipa::{Modify, OpenApi};
crate::handlers::auth::handle_refresh, crate::handlers::auth::handle_refresh,
crate::handlers::auth::handle_logout, crate::handlers::auth::handle_logout,
crate::handlers::entries::handle_create, crate::handlers::entries::handle_create,
crate::handlers::entries::handle_create_many,
crate::handlers::entries::handle_get, crate::handlers::entries::handle_get,
crate::handlers::entries::handle_list, crate::handlers::entries::handle_list,
crate::handlers::entries::handle_update, crate::handlers::entries::handle_update,
@@ -47,6 +48,7 @@ use utoipa::{Modify, OpenApi};
crate::handlers::reminders::handle_list, crate::handlers::reminders::handle_list,
crate::handlers::reminders::handle_update, crate::handlers::reminders::handle_update,
crate::handlers::reminders::handle_delete, crate::handlers::reminders::handle_delete,
crate::handlers::media::handle_list,
crate::handlers::media::handle_upload_photo, crate::handlers::media::handle_upload_photo,
crate::handlers::media::handle_upload_voice_memo, crate::handlers::media::handle_upload_voice_memo,
crate::handlers::media::handle_serve_photo, crate::handlers::media::handle_serve_photo,
@@ -58,6 +60,7 @@ use utoipa::{Modify, OpenApi};
crate::handlers::data::handle_restore, crate::handlers::data::handle_restore,
crate::handlers::import_export::handle_import, crate::handlers::import_export::handle_import,
crate::handlers::push::handle_vapid_key, crate::handlers::push::handle_vapid_key,
crate::handlers::push::handle_list_subscriptions,
crate::handlers::push::handle_subscribe, crate::handlers::push::handle_subscribe,
crate::handlers::push::handle_unsubscribe, crate::handlers::push::handle_unsubscribe,
crate::handlers::push::handle_test, crate::handlers::push::handle_test,
@@ -74,14 +77,19 @@ use utoipa::{Modify, OpenApi};
crate::handlers::metrics::handle_import, crate::handlers::metrics::handle_import,
crate::handlers::metrics::handle_rejections, crate::handlers::metrics::handle_rejections,
crate::handlers::metrics::handle_set, crate::handlers::metrics::handle_set,
crate::handlers::server::handle_info,
crate::handlers::jobs::handle_list_exhausted,
), ),
components(schemas( components(schemas(
api_types::requests::CreateEntryRequest, api_types::requests::CreateEntryRequest,
api_types::requests::CreateEntriesRequest,
api_types::responses::EntryPageResponse,
api_types::requests::UpdateEntryRequest, api_types::requests::UpdateEntryRequest,
api_types::requests::ListEntriesParams, api_types::params::ListEntriesParams,
api_types::requests::DateRangeParams, api_types::params::DateRangeParams,
api_types::requests::ReplaceActivityRequest, api_types::requests::ReplaceActivityRequest,
api_types::requests::CreateActivityRequest, api_types::requests::CreateActivityRequest,
api_types::params::ListActivitiesParams,
api_types::requests::RenameActivityRequest, api_types::requests::RenameActivityRequest,
api_types::requests::SetCategoryRequest, api_types::requests::SetCategoryRequest,
api_types::requests::RegisterRequest, api_types::requests::RegisterRequest,
@@ -98,15 +106,18 @@ use utoipa::{Modify, OpenApi};
api_types::responses::ReminderResponse, api_types::responses::ReminderResponse,
api_types::responses::DayScheduleResponse, api_types::responses::DayScheduleResponse,
api_types::responses::MoodStatsResponse, api_types::responses::MoodStatsResponse,
api_types::responses::MoodFrequency, api_types::responses::MoodFrequencyResponse,
api_types::responses::CalendarDayResponse, api_types::responses::CalendarDayResponse,
api_types::responses::BulkActionResponse, api_types::responses::BulkActionResponse,
api_types::responses::ImportResultResponse, api_types::responses::ImportResultResponse,
api_types::responses::RestoreOutcomeResponse, api_types::responses::RestoreOutcomeResponse,
api_types::responses::MediaIdResponse, api_types::responses::MediaIdResponse,
api_types::responses::OwnedMediaResponse,
api_types::responses::ExhaustedJobResponse,
api_types::responses::PushSubscriptionResponse,
api_types::requests::SetDailyMetricsRequest, api_types::requests::SetDailyMetricsRequest,
api_types::requests::MetricPayload, api_types::requests::MetricPayload,
api_types::requests::DateSpanParams, api_types::params::DateSpanParams,
api_types::responses::DailyMetricResponse, api_types::responses::DailyMetricResponse,
api_types::requests::MintApiTokenRequest, api_types::requests::MintApiTokenRequest,
api_types::requests::SetPreferencesRequest, api_types::requests::SetPreferencesRequest,
@@ -127,6 +138,16 @@ use utoipa::{Modify, OpenApi};
api_types::responses::StrategyScoreResponse, api_types::responses::StrategyScoreResponse,
api_types::requests::PushSubscribeRequest, api_types::requests::PushSubscribeRequest,
api_types::requests::PushUnsubscribeRequest, api_types::requests::PushUnsubscribeRequest,
api_types::responses::ErrorResponse,
api_types::responses::ErrorDetailResponse,
api_types::responses::SignedInResponse,
api_types::responses::RefreshedResponse,
api_types::responses::VapidKeyResponse,
api_types::responses::ServerInfoResponse,
api_types::responses::ServerLimitsResponse,
api_types::responses::TokenScopeResponse,
api_types::responses::MetricKindResponse,
api_types::responses::MoodResponse,
)), )),
tags( tags(
(name = "auth", description = "Authentication"), (name = "auth", description = "Authentication"),

View File

@@ -1,15 +1,20 @@
use axum::extract::DefaultBodyLimit; use axum::extract::DefaultBodyLimit;
use axum::http::HeaderValue; use axum::http::HeaderValue;
use axum::http::StatusCode;
use axum::routing::{delete, get, patch, post, put}; use axum::routing::{delete, get, patch, post, put};
use axum::{Json, Router}; use axum::{Json, Router};
use tower_governor::GovernorError;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::{GovernorLayer, key_extractor::SmartIpKeyExtractor};
use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use utoipa::OpenApi; use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable}; use utoipa_scalar::{Scalar, Servable};
use crate::errors::{INTERNAL_ERROR, TOO_MANY_REQUESTS, refuse};
use crate::handlers::{ use crate::handlers::{
activities, auth, correlations, cycle, data, entries, import_export, media, metrics, providers, activities, auth, correlations, cycle, data, entries, import_export, jobs, media, metrics,
push, reminders, tokens, users, providers, push, reminders, server, tokens, users,
}; };
use crate::openapi::ApiDoc; use crate::openapi::ApiDoc;
use crate::state::AppState; use crate::state::AppState;
@@ -18,11 +23,17 @@ pub fn build_router(state: AppState) -> Router {
let cors = build_cors(&state.server_config.cors); let cors = build_cors(&state.server_config.cors);
let body_limit = DefaultBodyLimit::max(state.server_config.max_body_size); let body_limit = DefaultBodyLimit::max(state.server_config.max_body_size);
Router::new() let answered_here = Router::new()
.nest("/api/v1", api_routes()) .nest("/api/v1", api_routes())
.route("/health", get(health)) .route("/health", get(health))
.route("/openapi.json", get(openapi_json)) .route("/openapi.json", get(openapi_json))
.merge(Scalar::with_url("/docs", ApiDoc::openapi())) .merge(Scalar::with_url("/docs", ApiDoc::openapi()));
// The limit guards the journal, not the files the browser needs to show
// it. One cold load of the client asks for a dozen or more hashed assets
// at once, and counting those against the same budget as the API means a
// hard refresh can leave the app unable to fetch its own code.
rate_limited(answered_here, &state.server_config.rate_limit)
.fallback_service(crate::spa::serve_spa(&state.server_config.spa_dir)) .fallback_service(crate::spa::serve_spa(&state.server_config.spa_dir))
.layer(body_limit) .layer(body_limit)
.layer(cors) .layer(cors)
@@ -30,6 +41,98 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state) .with_state(state)
} }
/// One second, in milliseconds.
const ONE_SECOND_IN_MILLIS: u64 = 1000;
/// How long to wait between replenishing one unit of the quota.
///
/// `GovernorConfigBuilder::per_second` takes the *interval* between
/// replenishments, not a rate, so feeding it a per-second figure means the
/// opposite of what the name says: `50` asks for one request every fifty
/// seconds rather than fifty every second. This converts the configured rate
/// into the interval the builder actually wants.
pub fn replenish_interval_millis(requests_per_second: u64) -> u64 {
(ONE_SECOND_IN_MILLIS / requests_per_second.max(1)).max(1)
}
/// Applies the limit with `route_layer`, so it runs only for requests that
/// match a route here. Static client assets are served by the fallback and are
/// deliberately outside it.
pub fn rate_limited<S>(router: Router<S>, config: &config::RateLimitConfig) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
if !config.enabled {
tracing::warn!("rate limiting is switched off, so any client may poll as fast as it likes");
return router;
}
match config.trust_forwarded_for {
true => keyed_by_forwarded_ip(router, config),
false => keyed_by_peer_ip(router, config),
}
}
fn keyed_by_forwarded_ip<S>(router: Router<S>, config: &config::RateLimitConfig) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let built = GovernorConfigBuilder::default()
.per_millisecond(replenish_interval_millis(config.requests_per_second))
.burst_size(config.burst)
.key_extractor(SmartIpKeyExtractor)
.finish();
match built {
Some(governor) => router.route_layer(GovernorLayer::new(governor).error_handler(slow_down)),
None => unusable(router),
}
}
fn keyed_by_peer_ip<S>(router: Router<S>, config: &config::RateLimitConfig) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let built = GovernorConfigBuilder::default()
.per_millisecond(replenish_interval_millis(config.requests_per_second))
.burst_size(config.burst)
.finish();
match built {
Some(governor) => router.route_layer(GovernorLayer::new(governor).error_handler(slow_down)),
None => unusable(router),
}
}
fn unusable<S>(router: Router<S>) -> Router<S> {
tracing::error!("the configured rate limit is not usable, so none is applied");
router
}
fn slow_down(error: GovernorError) -> axum::response::Response {
let (status, code, message) = match error {
GovernorError::TooManyRequests { .. } => (
StatusCode::TOO_MANY_REQUESTS,
TOO_MANY_REQUESTS,
"too many requests: slow down and try again shortly".to_string(),
),
GovernorError::UnableToExtractKey => (
StatusCode::INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
"the caller could not be identified for rate limiting".to_string(),
),
GovernorError::Other { msg, .. } => (
StatusCode::INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
msg.unwrap_or_else(|| "rate limiting failed".to_string()),
),
};
refuse(status, code, message)
}
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> { async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi()) Json(ApiDoc::openapi())
} }
@@ -72,6 +175,8 @@ fn api_routes() -> Router<AppState> {
.nest("/providers", provider_routes()) .nest("/providers", provider_routes())
.nest("/push", push_routes()) .nest("/push", push_routes())
.nest("/data", data_routes()) .nest("/data", data_routes())
.route("/server", get(server::handle_info))
.route("/jobs/exhausted", get(jobs::handle_list_exhausted))
} }
fn cycle_routes() -> Router<AppState> { fn cycle_routes() -> Router<AppState> {
@@ -125,6 +230,7 @@ fn entry_routes() -> Router<AppState> {
.patch(entries::handle_update) .patch(entries::handle_update)
.delete(entries::handle_delete), .delete(entries::handle_delete),
) )
.route("/bulk", post(entries::handle_create_many))
.route("/stats", get(entries::handle_stats)) .route("/stats", get(entries::handle_stats))
.route("/calendar", get(entries::handle_calendar)) .route("/calendar", get(entries::handle_calendar))
.route("/filter/mood/{mood}", get(entries::handle_filter_by_mood)) .route("/filter/mood/{mood}", get(entries::handle_filter_by_mood))
@@ -188,6 +294,7 @@ fn reminder_routes() -> Router<AppState> {
fn media_routes() -> Router<AppState> { fn media_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(media::handle_list))
.route("/photos", post(media::handle_upload_photo)) .route("/photos", post(media::handle_upload_photo))
.route( .route(
"/photos/{id}", "/photos/{id}",
@@ -203,6 +310,7 @@ fn media_routes() -> Router<AppState> {
fn push_routes() -> Router<AppState> { fn push_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/vapid-key", get(push::handle_vapid_key)) .route("/vapid-key", get(push::handle_vapid_key))
.route("/subscriptions", get(push::handle_list_subscriptions))
.route("/subscribe", post(push::handle_subscribe)) .route("/subscribe", post(push::handle_subscribe))
.route("/unsubscribe", post(push::handle_unsubscribe)) .route("/unsubscribe", post(push::handle_unsubscribe))
.route("/test", post(push::handle_test)) .route("/test", post(push::handle_test))

View File

@@ -7,12 +7,13 @@ use domain::ports::{
ActivityCommandPort, ActivityQueryPort, ApiTokenCommandPort, ApiTokenQueryPort, ActivityCommandPort, ActivityQueryPort, ApiTokenCommandPort, ApiTokenQueryPort,
ApiTokenSecretPort, AuthServicePort, BackupReaderPort, BackupWriterPort, CascadeDeletePort, ApiTokenSecretPort, AuthServicePort, BackupReaderPort, BackupWriterPort, CascadeDeletePort,
CycleStartCommandPort, CycleStartQueryPort, DailyMetricCommandPort, DailyMetricQueryPort, CycleStartCommandPort, CycleStartQueryPort, DailyMetricCommandPort, DailyMetricQueryPort,
EntryDimensionPort, EventPublisherPort, ExtractWriterPort, ImportSourcePort, MediaStoragePort, EntryDimensionPort, EventPublisherPort, ExtractWriterPort, ImportSourcePort,
MoodEntryCommandPort, MoodEntryQueryPort, PasswordHasherPort, ProviderConnectionCommandPort, MediaOwnershipPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
ProviderConnectionQueryPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort, PasswordHasherPort, ProviderConnectionCommandPort, ProviderConnectionQueryPort,
RefreshSessionCommandPort, RefreshSessionQueryPort, RejectionCommandPort, RejectionQueryPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort, RefreshSessionCommandPort,
ReminderCommandPort, ReminderQueryPort, ReminderSenderPort, UserCommandPort, RefreshSessionQueryPort, RejectionCommandPort, RejectionQueryPort, ReminderCommandPort,
UserPreferencesCommandPort, UserPreferencesQueryPort, UserQueryPort, ReminderQueryPort, ReminderSenderPort, UserCommandPort, UserPreferencesCommandPort,
UserPreferencesQueryPort, UserQueryPort,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -21,6 +22,7 @@ pub struct AppState {
pub entry_query: Arc<dyn MoodEntryQueryPort>, pub entry_query: Arc<dyn MoodEntryQueryPort>,
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>, pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
pub weather_store: Arc<dyn EntryDimensionPort>, pub weather_store: Arc<dyn EntryDimensionPort>,
pub activity_store: Arc<dyn EntryDimensionPort>,
pub activity_command: Arc<dyn ActivityCommandPort>, pub activity_command: Arc<dyn ActivityCommandPort>,
pub activity_query: Arc<dyn ActivityQueryPort>, pub activity_query: Arc<dyn ActivityQueryPort>,
pub user_command: Arc<dyn UserCommandPort>, pub user_command: Arc<dyn UserCommandPort>,
@@ -45,6 +47,8 @@ pub struct AppState {
pub password_hasher: Arc<dyn PasswordHasherPort>, pub password_hasher: Arc<dyn PasswordHasherPort>,
pub event_publisher: Arc<dyn EventPublisherPort>, pub event_publisher: Arc<dyn EventPublisherPort>,
pub media_storage: Arc<dyn MediaStoragePort>, pub media_storage: Arc<dyn MediaStoragePort>,
pub media_ownership: Arc<dyn MediaOwnershipPort>,
pub job_view: Arc<dyn domain::ports::JobQueueQueryPort>,
pub backup_writer: Arc<dyn BackupWriterPort>, pub backup_writer: Arc<dyn BackupWriterPort>,
pub backup_reader: Arc<dyn BackupReaderPort>, pub backup_reader: Arc<dyn BackupReaderPort>,
pub extract_writer: Arc<dyn ExtractWriterPort>, pub extract_writer: Arc<dyn ExtractWriterPort>,
@@ -53,6 +57,7 @@ pub struct AppState {
pub provider_connection_query: Arc<dyn ProviderConnectionQueryPort>, pub provider_connection_query: Arc<dyn ProviderConnectionQueryPort>,
pub credential_cipher: Option<Arc<dyn domain::provider::CredentialCipher>>, pub credential_cipher: Option<Arc<dyn domain::provider::CredentialCipher>>,
pub now_playing: Option<Arc<dyn domain::ports::NowPlayingPort>>, pub now_playing: Option<Arc<dyn domain::ports::NowPlayingPort>>,
pub weather_enabled: bool,
pub recording_lookup: Arc<dyn domain::ports::RecordingLookupPort>, pub recording_lookup: Arc<dyn domain::ports::RecordingLookupPort>,
pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>, pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>,
pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>, pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>,

View File

@@ -0,0 +1,129 @@
use utoipa::OpenApi;
fn spec() -> serde_json::Value {
serde_json::to_value(http_axum::openapi::ApiDoc::openapi()).unwrap()
}
fn operations(spec: &serde_json::Value) -> Vec<(String, String, serde_json::Value)> {
spec["paths"]
.as_object()
.unwrap()
.iter()
.flat_map(|(path, methods)| {
methods
.as_object()
.unwrap()
.iter()
.map(move |(verb, op)| (verb.to_uppercase(), path.clone(), op.clone()))
})
.collect()
}
#[test]
fn every_operation_that_can_succeed_with_a_body_declares_its_shape() {
let spec = spec();
let mut untyped = Vec::new();
for (verb, path, op) in operations(&spec) {
let responses = op["responses"].as_object().unwrap();
let describes_a_success = responses
.iter()
.any(|(code, response)| code.starts_with('2') && response.get("content").is_some());
let says_nothing_comes_back = responses.contains_key("204");
let serves_bytes = path.starts_with("/api/v1/media/");
if !describes_a_success && !says_nothing_comes_back && !serves_bytes {
untyped.push(format!("{verb} {path}"));
}
}
assert!(
untyped.is_empty(),
"a generated client cannot type these responses: {untyped:?}"
);
}
#[test]
fn every_operation_documents_the_refusals_a_client_must_handle() {
let spec = spec();
let mut silent = Vec::new();
for (verb, path, op) in operations(&spec) {
let responses = op["responses"].as_object().unwrap();
if !responses.keys().any(|code| code.starts_with('4')) {
silent.push(format!("{verb} {path}"));
}
}
assert!(
silent.is_empty(),
"these operations can refuse a client but never say so: {silent:?}"
);
}
#[test]
fn every_guarded_operation_says_what_a_wrong_credential_looks_like() {
let spec = spec();
let mut silent = Vec::new();
for (verb, path, op) in operations(&spec) {
if op.get("security").is_none() {
continue;
}
let responses = op["responses"].as_object().unwrap();
if !responses.contains_key("401") || !responses.contains_key("403") {
silent.push(format!("{verb} {path}"));
}
}
assert!(
silent.is_empty(),
"these need a credential but never describe refusing one: {silent:?}"
);
}
#[test]
fn every_refusal_is_the_one_error_shape() {
let spec = spec();
let mut odd = Vec::new();
for (verb, path, op) in operations(&spec) {
for (code, response) in op["responses"].as_object().unwrap() {
if !code.starts_with('4') && !code.starts_with('5') {
continue;
}
let named = response["content"]["application/json"]["schema"]["$ref"]
.as_str()
.unwrap_or_default();
if named != "#/components/schemas/ErrorResponse" {
odd.push(format!("{verb} {path} -> {code}"));
}
}
}
assert!(
odd.is_empty(),
"a client should parse one error shape, not several: {odd:?}"
);
}
#[test]
fn the_server_tells_a_client_what_it_allows_without_a_credential() {
let spec = spec();
let info = &spec["paths"]["/api/v1/server"]["get"];
assert!(
info.get("security").is_none(),
"a client decides whether to offer registration before anyone has signed in"
);
assert_eq!(
info["responses"]["200"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/ServerInfoResponse"
);
}

View File

@@ -0,0 +1,119 @@
//! The limit exists to protect the journal from being hammered. It must not
//! also throttle the static files the browser needs to render the journal:
//! one cold load of the client asks for a dozen or more hashed assets at once,
//! and counting those against the same budget makes a hard refresh able to
//! starve the app of its own code.
use std::net::SocketAddr;
use axum::Router;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use config::RateLimitConfig;
use http_axum::router::rate_limited;
use tower::util::ServiceExt;
const CALLER: SocketAddr =
SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 9000);
/// One request allowed, then nothing until it replenishes — so a second call
/// is refused and the difference between guarded and exempt is unmistakable.
fn strictest() -> RateLimitConfig {
RateLimitConfig {
enabled: true,
requests_per_second: 1,
burst: 1,
trust_forwarded_for: false,
}
}
fn under_test(config: &RateLimitConfig) -> Router {
rate_limited(
Router::new().route("/api/v1/entries", get(|| async { "journal" })),
config,
)
.fallback(|| async { "index.html" })
}
async fn status_of(app: &Router, path: &str) -> StatusCode {
let mut request = Request::builder().uri(path).body(Body::empty()).unwrap();
request.extensions_mut().insert(ConnectInfo(CALLER));
app.clone().oneshot(request).await.unwrap().status()
}
#[tokio::test]
async fn a_guarded_route_is_refused_once_its_budget_is_spent() {
let app = under_test(&strictest());
assert_eq!(status_of(&app, "/api/v1/entries").await, StatusCode::OK);
assert_eq!(
status_of(&app, "/api/v1/entries").await,
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn client_assets_keep_being_served_after_the_budget_is_spent() {
let app = under_test(&strictest());
// Spend the whole budget on the API.
assert_eq!(status_of(&app, "/api/v1/entries").await, StatusCode::OK);
assert_eq!(
status_of(&app, "/api/v1/entries").await,
StatusCode::TOO_MANY_REQUESTS
);
// The files the client is made of are still served, as many as it asks for.
for _ in 0..25 {
assert_eq!(
status_of(&app, "/assets/index-abc123.js").await,
StatusCode::OK
);
}
}
#[tokio::test]
async fn a_switched_off_limit_guards_nothing() {
let off = RateLimitConfig {
enabled: false,
..strictest()
};
let app = under_test(&off);
for _ in 0..10 {
assert_eq!(status_of(&app, "/api/v1/entries").await, StatusCode::OK);
}
}
/// `GovernorConfigBuilder::per_second` takes an interval, not a rate, so the
/// configured figure has to be converted or it means its own opposite.
mod replenishment {
use http_axum::router::replenish_interval_millis;
#[test]
fn a_rate_becomes_the_interval_between_replenishments() {
assert_eq!(replenish_interval_millis(1), 1000);
assert_eq!(replenish_interval_millis(10), 100);
assert_eq!(replenish_interval_millis(15), 66);
}
#[test]
fn a_faster_rate_waits_a_shorter_time() {
assert!(replenish_interval_millis(50) < replenish_interval_millis(5));
}
#[test]
fn an_interval_is_never_zero_however_high_the_rate() {
// The builder rejects a zero interval outright.
assert_eq!(replenish_interval_millis(10_000), 1);
assert_eq!(replenish_interval_millis(u64::MAX), 1);
}
#[test]
fn a_nonsensical_rate_of_zero_falls_back_to_one_a_second() {
assert_eq!(replenish_interval_millis(0), 1000);
}
}

View File

@@ -1,10 +1,16 @@
use api_types::dimension::DimensionPayload; use api_types::dimension::DimensionPayload;
use domain::dimension::DimensionValue; use domain::dimension::DimensionValue;
use domain::errors::DomainError; use domain::errors::DomainError;
use std::collections::HashMap;
use domain::attachment::ContentType;
use domain::ports::{ use domain::ports::{
RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric, RestorableReminder, RestorableActivity, RestorableContents, RestorableEntry, RestorableMedia, RestorableMetric,
RestorableReminder,
}; };
use exporter::{BackedUpMedia, PHOTO_KIND, VOICE_MEMO_KIND};
use super::kmood_backup::KmoodBackupReader; use super::kmood_backup::KmoodBackupReader;
pub struct KmoodBackupAdapter; pub struct KmoodBackupAdapter;
@@ -14,6 +20,7 @@ impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError> { async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError> {
let read = KmoodBackupReader::read(data)?; let read = KmoodBackupReader::read(data)?;
let manifest = read.manifest; let manifest = read.manifest;
let types = declared_types(&manifest.media);
Ok(RestorableContents { Ok(RestorableContents {
entries: manifest entries: manifest
@@ -63,8 +70,8 @@ impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
}) })
.collect(), .collect(),
tracks_cycle: manifest.tracks_cycle, tracks_cycle: manifest.tracks_cycle,
photos: read.photos.into_iter().collect(), photos: restorable(read.photos, &types, PHOTO_KIND),
voice_memos: read.voice_memos.into_iter().collect(), voice_memos: restorable(read.voice_memos, &types, VOICE_MEMO_KIND),
}) })
} }
} }
@@ -81,3 +88,30 @@ fn readable_dimensions(payloads: Vec<DimensionPayload>) -> Vec<DimensionValue> {
}) })
.collect() .collect()
} }
fn declared_types(media: &[BackedUpMedia]) -> HashMap<(String, String), ContentType> {
media
.iter()
.map(|held| {
(
(held.kind.clone(), held.id.clone()),
ContentType::from_persistence(held.content_type.clone()),
)
})
.collect()
}
fn restorable(
blobs: HashMap<String, Vec<u8>>,
types: &HashMap<(String, String), ContentType>,
kind: &str,
) -> Vec<RestorableMedia> {
blobs
.into_iter()
.map(|(id, data)| RestorableMedia {
content_type: types.get(&(kind.to_string(), id.clone())).cloned(),
id,
data,
})
.collect()
}

View File

@@ -48,6 +48,22 @@ const MIGRATIONS: &[(&str, &str)] = &[
"012_entry_weather", "012_entry_weather",
include_str!("migrations/012_entry_weather.sql"), include_str!("migrations/012_entry_weather.sql"),
), ),
(
"013_reminder_last_sent",
include_str!("migrations/013_reminder_last_sent.sql"),
),
(
"014_media_ownership",
include_str!("migrations/014_media_ownership.sql"),
),
(
"015_logged_at_in_utc",
include_str!("migrations/015_logged_at_in_utc.sql"),
),
(
"016_api_token_scopes",
include_str!("migrations/016_api_token_scopes.sql"),
),
]; ];
const TAKE_THE_WRITE_LOCK_UP_FRONT: &str = "BEGIN IMMEDIATE"; const TAKE_THE_WRITE_LOCK_UP_FRONT: &str = "BEGIN IMMEDIATE";
@@ -57,6 +73,13 @@ const SCHEMA_MIGRATIONS_TABLE: &str = "CREATE TABLE IF NOT EXISTS schema_migrati
applied_at TEXT NOT NULL applied_at TEXT NOT NULL
)"; )";
pub fn migrations_before(name: &str) -> impl Iterator<Item = (&'static str, &'static str)> {
MIGRATIONS
.iter()
.take_while(move |(applied, _)| *applied != name)
.copied()
}
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> { pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options: SqliteConnectOptions = database_url let options: SqliteConnectOptions = database_url
.parse::<SqliteConnectOptions>()? .parse::<SqliteConnectOptions>()?

View File

@@ -1,4 +1,4 @@
mod db; mod db;
pub mod repositories; pub mod repositories;
pub use db::{create_pool, run_migrations}; pub use db::{create_pool, migrations_before, run_migrations};

View File

@@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN last_sent_at TEXT;

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS media_owners (
kind TEXT NOT NULL,
media_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (kind, media_id)
);
CREATE INDEX IF NOT EXISTS idx_media_owners_user_id ON media_owners(user_id);
INSERT OR IGNORE INTO media_owners (kind, media_id, user_id, created_at)
SELECT 'photo', ep.photo_id, me.user_id, me.created_at
FROM entry_photos ep
JOIN mood_entries me ON me.id = ep.entry_id;
INSERT OR IGNORE INTO media_owners (kind, media_id, user_id, created_at)
SELECT 'voice_memo', evm.voice_memo_id, me.user_id, me.created_at
FROM entry_voice_memos evm
JOIN mood_entries me ON me.id = evm.entry_id;

View File

@@ -0,0 +1,6 @@
UPDATE mood_entries
SET logged_at = strftime('%Y-%m-%dT%H:%M:%S+00:00', logged_at)
WHERE logged_at NOT LIKE '%+00:00';
CREATE INDEX IF NOT EXISTS idx_mood_entries_user_logged_at
ON mood_entries(user_id, logged_at);

View File

@@ -0,0 +1,5 @@
ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'writeMetrics';
UPDATE api_tokens SET scopes = scope WHERE scope IS NOT NULL AND scope != '';
ALTER TABLE api_tokens DROP COLUMN scope;

View File

@@ -35,14 +35,14 @@ impl domain::ports::ApiTokenCommandPort for SqliteApiTokenCommandRepository {
} }
sqlx::query( sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at) "INSERT INTO api_tokens (id, user_id, name, digest, scopes, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(token.id().value().to_string()) .bind(token.id().value().to_string())
.bind(token.user_id().value().to_string()) .bind(token.user_id().value().to_string())
.bind(token.name().value()) .bind(token.name().value())
.bind(token.digest().value()) .bind(token.digest().value())
.bind(token.scope().name()) .bind(token.scopes().to_persistence())
.bind(token.created_at().to_rfc3339()) .bind(token.created_at().to_rfc3339())
.bind(token.last_used_at().map(|used| used.to_rfc3339())) .bind(token.last_used_at().map(|used| used.to_rfc3339()))
.execute(&self.pool) .execute(&self.pool)

View File

@@ -21,7 +21,7 @@ impl SqliteApiTokenQueryRepository {
impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository { impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository {
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> { async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
let row: Option<ApiTokenRow> = sqlx::query_as( let row: Option<ApiTokenRow> = sqlx::query_as(
"SELECT id, user_id, name, digest, scope, created_at, last_used_at "SELECT id, user_id, name, digest, scopes, created_at, last_used_at
FROM api_tokens WHERE digest = ?", FROM api_tokens WHERE digest = ?",
) )
.bind(digest.value()) .bind(digest.value())
@@ -34,7 +34,7 @@ impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> { async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> {
let rows: Vec<ApiTokenRow> = sqlx::query_as( let rows: Vec<ApiTokenRow> = sqlx::query_as(
"SELECT id, user_id, name, digest, scope, created_at, last_used_at "SELECT id, user_id, name, digest, scopes, created_at, last_used_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC", FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
) )
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())

View File

@@ -1,4 +1,4 @@
use domain::api_token::{ApiToken, ApiTokenData, ApiTokenId, TokenDigest, TokenScope}; use domain::api_token::{ApiToken, ApiTokenData, ApiTokenId, TokenDigest, TokenScopes};
use domain::provider::ProviderName; use domain::provider::ProviderName;
use domain::user::UserId; use domain::user::UserId;
@@ -8,7 +8,7 @@ pub struct ApiTokenRow {
pub user_id: String, pub user_id: String,
pub name: String, pub name: String,
pub digest: String, pub digest: String,
pub scope: String, pub scopes: String,
pub created_at: String, pub created_at: String,
pub last_used_at: Option<String>, pub last_used_at: Option<String>,
} }
@@ -24,7 +24,7 @@ pub fn row_to_token(row: &ApiTokenRow) -> Option<ApiToken> {
user_id: UserId::from_uuid(row.user_id.parse().ok()?), user_id: UserId::from_uuid(row.user_id.parse().ok()?),
name: ProviderName::from_persistence(row.name.clone()), name: ProviderName::from_persistence(row.name.clone()),
digest: TokenDigest::from_persistence(row.digest.clone()), digest: TokenDigest::from_persistence(row.digest.clone()),
scope: TokenScope::from_name(&row.scope)?, scopes: TokenScopes::from_persistence(&row.scopes)?,
created_at: row.created_at.parse().ok()?, created_at: row.created_at.parse().ok()?,
last_used_at, last_used_at,
})) }))
@@ -36,7 +36,7 @@ pub fn readable(row: &ApiTokenRow) -> Option<ApiToken> {
if token.is_none() { if token.is_none() {
tracing::warn!( tracing::warn!(
token_id = %row.id, token_id = %row.id,
scope = %row.scope, scopes = %row.scopes,
"skipped a stored api token this build cannot read" "skipped a stored api token this build cannot read"
); );
} }

View File

@@ -5,7 +5,17 @@ use domain::errors::DomainError;
use domain::user::UserId; use domain::user::UserId;
use super::entry::rows::{EntryRow, hydrate_batch}; use super::entry::rows::{EntryRow, hydrate_batch};
use super::shared::db_err; use super::shared::{db_err, sortable_instant};
const EVERY_TABLE_THAT_HOLDS_WHAT_A_USER_LOGGED: [&str; 7] = [
"mood_entries",
"activities",
"reminders",
"daily_metrics",
"cycle_starts",
"metric_rejections",
"media_owners",
];
pub struct SqliteCascadeDeleteRepository { pub struct SqliteCascadeDeleteRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -23,29 +33,15 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let mut tx = self.pool.begin().await.map_err(db_err)?; let mut tx = self.pool.begin().await.map_err(db_err)?;
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?") for table in EVERY_TABLE_THAT_HOLDS_WHAT_A_USER_LOGGED {
.bind(&uid) sqlx::query(sqlx::AssertSqlSafe(format!(
.execute(&mut *tx) "DELETE FROM {table} WHERE user_id = ?"
.await )))
.map_err(db_err)?;
sqlx::query("DELETE FROM activities WHERE user_id = ?")
.bind(&uid)
.execute(&mut *tx)
.await
.map_err(db_err)?;
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
.bind(&uid)
.execute(&mut *tx)
.await
.map_err(db_err)?;
sqlx::query("DELETE FROM daily_metrics WHERE user_id = ?")
.bind(&uid) .bind(&uid)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
}
tx.commit().await.map_err(db_err)?; tx.commit().await.map_err(db_err)?;
Ok(()) Ok(())
@@ -85,6 +81,12 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
.await .await
.map_err(db_err)?; .map_err(db_err)?;
sqlx::query("DELETE FROM media_owners WHERE user_id = ?")
.bind(&uid)
.execute(&mut *tx)
.await
.map_err(db_err)?;
sqlx::query("DELETE FROM users WHERE id = ?") sqlx::query("DELETE FROM users WHERE id = ?")
.bind(&uid) .bind(&uid)
.execute(&mut *tx) .execute(&mut *tx)
@@ -106,8 +108,8 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC", "SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
) )
.bind(&uid) .bind(&uid)
.bind(range.start().to_rfc3339()) .bind(sortable_instant(range.start()))
.bind(range.end().to_rfc3339()) .bind(sortable_instant(range.end()))
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
@@ -120,8 +122,8 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?", "DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
) )
.bind(&uid) .bind(&uid)
.bind(range.start().to_rfc3339()) .bind(sortable_instant(range.start()))
.bind(range.end().to_rfc3339()) .bind(sortable_instant(range.end()))
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(db_err)?; .map_err(db_err)?;

View File

@@ -5,7 +5,7 @@ use domain::entry::{DateRange, MoodEntry, MoodEntryId};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::user::UserId; use domain::user::UserId;
use super::super::shared::db_err; use super::super::shared::{db_err, sortable_instant};
pub struct SqliteEntryCommandRepository { pub struct SqliteEntryCommandRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -30,7 +30,7 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
.bind(entry.id().value().to_string()) .bind(entry.id().value().to_string())
.bind(entry.user_id().value().to_string()) .bind(entry.user_id().value().to_string())
.bind(entry.mood().value() as i32) .bind(entry.mood().value() as i32)
.bind(entry.logged_at().to_rfc3339()) .bind(sortable_instant(entry.logged_at()))
.bind(entry.created_at().to_rfc3339()) .bind(entry.created_at().to_rfc3339())
.bind(entry.updated_at().to_rfc3339()) .bind(entry.updated_at().to_rfc3339())
.execute(&self.pool) .execute(&self.pool)
@@ -56,7 +56,7 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
.bind(&entry_id) .bind(&entry_id)
.bind(entry.user_id().value().to_string()) .bind(entry.user_id().value().to_string())
.bind(entry.mood().value() as i32) .bind(entry.mood().value() as i32)
.bind(entry.logged_at().to_rfc3339()) .bind(sortable_instant(entry.logged_at()))
.bind(entry.created_at().to_rfc3339()) .bind(entry.created_at().to_rfc3339())
.bind(entry.updated_at().to_rfc3339()) .bind(entry.updated_at().to_rfc3339())
.execute(&mut *tx) .execute(&mut *tx)
@@ -95,8 +95,8 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?", "DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
) )
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())
.bind(range.start().to_rfc3339()) .bind(sortable_instant(range.start()))
.bind(range.end().to_rfc3339()) .bind(sortable_instant(range.end()))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;

View File

@@ -1,11 +1,11 @@
use sqlx::SqlitePool; use sqlx::SqlitePool;
use domain::activity::ActivityId; use domain::activity::ActivityId;
use domain::entry::{DateRange, Mood, MoodEntry, MoodEntryId}; use domain::entry::{DateRange, EntrySelection, MoodEntry, MoodEntryId, Pagination};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::user::UserId; use domain::user::UserId;
use super::super::shared::db_err; use super::super::shared::{db_err, sortable_instant};
use super::rows::{EntryRow, hydrate_batch, hydrate_single}; use super::rows::{EntryRow, hydrate_batch, hydrate_single};
pub struct SqliteEntryQueryRepository { pub struct SqliteEntryQueryRepository {
@@ -33,21 +33,11 @@ impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
} }
} }
async fn find_by_user( async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError> {
&self,
user_id: &UserId,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<MoodEntry>, DomainError> {
let limit = limit.unwrap_or(i64::MAX);
let offset = offset.unwrap_or(0);
let rows = sqlx::query_as::<_, EntryRow>( let rows = sqlx::query_as::<_, EntryRow>(
"SELECT * FROM mood_entries WHERE user_id = ? ORDER BY logged_at DESC LIMIT ? OFFSET ?", "SELECT * FROM mood_entries WHERE user_id = ? ORDER BY logged_at DESC",
) )
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())
.bind(limit)
.bind(offset)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
@@ -55,6 +45,47 @@ impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
hydrate_batch(&self.pool, rows).await hydrate_batch(&self.pool, rows).await
} }
async fn select(
&self,
selection: &EntrySelection,
page: Pagination,
) -> Result<Vec<MoodEntry>, DomainError> {
let narrowing = Narrowing::of(selection);
let sql = format!(
"SELECT me.* FROM mood_entries me{} WHERE {} ORDER BY me.logged_at DESC LIMIT ? OFFSET ?",
narrowing.join, narrowing.conditions
);
let mut query = sqlx::query_as::<_, EntryRow>(sqlx::AssertSqlSafe(sql));
query = narrowing.bind(query);
let rows = query
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
hydrate_batch(&self.pool, rows).await
}
async fn count(&self, selection: &EntrySelection) -> Result<u64, DomainError> {
let narrowing = Narrowing::of(selection);
let sql = format!(
"SELECT COUNT(*) FROM mood_entries me{} WHERE {}",
narrowing.join, narrowing.conditions
);
let mut query = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
query = narrowing.bind(query);
let (counted,) = query.fetch_one(&self.pool).await.map_err(db_err)?;
Ok(u64::try_from(counted).unwrap_or(0))
}
async fn find_by_date_range( async fn find_by_date_range(
&self, &self,
user_id: &UserId, user_id: &UserId,
@@ -64,47 +95,79 @@ impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC", "SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
) )
.bind(user_id.value().to_string()) .bind(user_id.value().to_string())
.bind(range.start().to_rfc3339()) .bind(sortable_instant(range.start()))
.bind(range.end().to_rfc3339()) .bind(sortable_instant(range.end()))
.fetch_all(&self.pool).await.map_err(db_err)?; .fetch_all(&self.pool).await.map_err(db_err)?;
hydrate_batch(&self.pool, rows).await hydrate_batch(&self.pool, rows).await
} }
async fn find_by_mood( async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError> {
&self, let (count,): (i64,) =
user_id: &UserId, sqlx::query_as("SELECT COUNT(*) FROM entry_activities WHERE activity_id = ?")
mood: Mood,
) -> Result<Vec<MoodEntry>, DomainError> {
let rows = sqlx::query_as::<_, EntryRow>(
"SELECT * FROM mood_entries WHERE user_id = ? AND mood = ? ORDER BY logged_at DESC",
)
.bind(user_id.value().to_string())
.bind(mood.value() as i32)
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
hydrate_batch(&self.pool, rows).await
}
async fn find_by_activity(
&self,
user_id: &UserId,
activity_id: &ActivityId,
) -> Result<Vec<MoodEntry>, DomainError> {
let rows = sqlx::query_as::<_, EntryRow>(
"SELECT me.* FROM mood_entries me
INNER JOIN entry_activities ea ON ea.entry_id = me.id
WHERE me.user_id = ? AND ea.activity_id = ?
ORDER BY me.logged_at DESC",
)
.bind(user_id.value().to_string())
.bind(activity_id.value().to_string()) .bind(activity_id.value().to_string())
.fetch_all(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
hydrate_batch(&self.pool, rows).await Ok(u64::try_from(count).unwrap_or(0))
}
}
struct Narrowing<'a> {
join: &'static str,
conditions: String,
selection: &'a EntrySelection,
}
impl<'a> Narrowing<'a> {
fn of(selection: &'a EntrySelection) -> Self {
let mut conditions = vec!["me.user_id = ?".to_string()];
if selection.range().is_some() {
conditions.push("me.logged_at >= ?".into());
conditions.push("me.logged_at <= ?".into());
}
if selection.mood().is_some() {
conditions.push("me.mood = ?".into());
}
if selection.activity().is_some() {
conditions.push("ea.activity_id = ?".into());
}
if selection.since().is_some() {
conditions.push("me.updated_at > ?".into());
}
Self {
join: match selection.activity() {
Some(_) => " INNER JOIN entry_activities ea ON ea.entry_id = me.id",
None => "",
},
conditions: conditions.join(" AND "),
selection,
}
}
fn bind<T>(
&self,
mut query: sqlx::query::QueryAs<'a, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments>,
) -> sqlx::query::QueryAs<'a, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments> {
query = query.bind(self.selection.user_id().value().to_string());
if let Some(range) = self.selection.range() {
query = query.bind(sortable_instant(range.start()));
query = query.bind(sortable_instant(range.end()));
}
if let Some(mood) = self.selection.mood() {
query = query.bind(i32::from(mood.value()));
}
if let Some(activity_id) = self.selection.activity() {
query = query.bind(activity_id.value().to_string());
}
if let Some(since) = self.selection.since() {
query = query.bind(since.to_rfc3339());
}
query
} }
} }

View File

@@ -48,6 +48,24 @@ impl domain::ports::RecordingBackfillQueryPort for SqliteRecordingBackfillReposi
Ok(rows.iter().filter_map(readable).collect()) Ok(rows.iter().filter_map(readable).collect())
} }
async fn find_song_without_a_recording(
&self,
entry_id: &MoodEntryId,
) -> Result<Option<UnidentifiedSong>, DomainError> {
let row: Option<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 AND s.entry_id = ?",
)
.bind(entry_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.as_ref().and_then(readable))
}
async fn record_identity( async fn record_identity(
&self, &self,
entry_id: &MoodEntryId, entry_id: &MoodEntryId,

View File

@@ -47,6 +47,25 @@ impl domain::ports::WeatherBacklogQueryPort for SqliteWeatherBacklogRepository {
Ok(rows.iter().filter_map(readable).collect()) Ok(rows.iter().filter_map(readable).collect())
} }
async fn find_place_without_weather(
&self,
entry_id: &MoodEntryId,
) -> Result<Option<UnwatchedPlace>, DomainError> {
let row: Option<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 AND l.entry_id = ?",
)
.bind(entry_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(row.as_ref().and_then(readable))
}
} }
fn readable(row: &UnwatchedPlaceRow) -> Option<UnwatchedPlace> { fn readable(row: &UnwatchedPlaceRow) -> Option<UnwatchedPlace> {

View File

@@ -0,0 +1,3 @@
mod ownership;
pub use ownership::SqliteMediaOwnershipRepository;

View File

@@ -0,0 +1,95 @@
use chrono::Utc;
use sqlx::SqlitePool;
use domain::attachment::{MediaKind, MediaRef, PhotoId, VoiceMemoId};
use domain::errors::DomainError;
use domain::user::UserId;
use super::super::shared::db_err;
pub struct SqliteMediaOwnershipRepository {
pool: SqlitePool,
}
impl SqliteMediaOwnershipRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl domain::ports::MediaOwnershipPort for SqliteMediaOwnershipRepository {
async fn remember(&self, owner: &UserId, media: MediaRef) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO media_owners (kind, media_id, user_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(kind, media_id) DO UPDATE SET user_id = excluded.user_id",
)
.bind(media.kind().name())
.bind(media.id().to_string())
.bind(owner.value().to_string())
.bind(Utc::now().to_rfc3339())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn owner_of(&self, media: MediaRef) -> Result<Option<UserId>, DomainError> {
let found: Option<(String,)> =
sqlx::query_as("SELECT user_id FROM media_owners WHERE kind = ? AND media_id = ?")
.bind(media.kind().name())
.bind(media.id().to_string())
.fetch_optional(&self.pool)
.await
.map_err(db_err)?;
Ok(found
.and_then(|(user_id,)| user_id.parse().ok())
.map(UserId::from_uuid))
}
async fn owned_by(&self, owner: &UserId) -> Result<Vec<MediaRef>, DomainError> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT kind, media_id FROM media_owners WHERE user_id = ?")
.bind(owner.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(db_err)?;
Ok(rows.iter().filter_map(readable).collect())
}
async fn forget(&self, media: MediaRef) -> Result<(), DomainError> {
sqlx::query("DELETE FROM media_owners WHERE kind = ? AND media_id = ?")
.bind(media.kind().name())
.bind(media.id().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
async fn forget_all_by_user(&self, owner: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM media_owners WHERE user_id = ?")
.bind(owner.value().to_string())
.execute(&self.pool)
.await
.map_err(db_err)?;
Ok(())
}
}
fn readable(row: &(String, String)) -> Option<MediaRef> {
let (kind, media_id) = row;
match MediaKind::from_name(kind)? {
MediaKind::Photo => Some(MediaRef::from(&PhotoId::from_uuid(media_id.parse().ok()?))),
MediaKind::VoiceMemo => Some(MediaRef::from(&VoiceMemoId::from_uuid(
media_id.parse().ok()?,
))),
}
}

View File

@@ -8,6 +8,7 @@ mod daily_metric;
mod dimension; mod dimension;
mod entry; mod entry;
mod job; mod job;
mod media;
mod provider_connection; mod provider_connection;
mod push_subscription; mod push_subscription;
mod refresh_session; mod refresh_session;
@@ -29,6 +30,7 @@ pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
pub use job::{ pub use job::{
SqliteJobQueueRepository, SqliteRecordingBackfillRepository, SqliteWeatherBacklogRepository, SqliteJobQueueRepository, SqliteRecordingBackfillRepository, SqliteWeatherBacklogRepository,
}; };
pub use media::SqliteMediaOwnershipRepository;
pub use provider_connection::{ pub use provider_connection::{
SqliteProviderConnectionCommandRepository, SqliteProviderConnectionQueryRepository, SqliteProviderConnectionCommandRepository, SqliteProviderConnectionQueryRepository,
}; };

View File

@@ -48,8 +48,13 @@ impl domain::ports::PushSubscriptionCommandPort for SqlitePushSubscriptionComman
Ok(()) Ok(())
} }
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> { async fn delete_by_endpoint(
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?") &self,
user_id: &UserId,
endpoint: &str,
) -> Result<(), DomainError> {
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?")
.bind(user_id.value().to_string())
.bind(endpoint) .bind(endpoint)
.execute(&self.pool) .execute(&self.pool)
.await .await

View File

@@ -22,13 +22,14 @@ impl SqliteReminderCommandRepository {
impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository { impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository {
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> { async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at) "INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at, last_sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
monday = excluded.monday, tuesday = excluded.tuesday, monday = excluded.monday, tuesday = excluded.tuesday,
wednesday = excluded.wednesday, thursday = excluded.thursday, wednesday = excluded.wednesday, thursday = excluded.thursday,
friday = excluded.friday, saturday = excluded.saturday, friday = excluded.friday, saturday = excluded.saturday,
sunday = excluded.sunday, enabled = excluded.enabled" sunday = excluded.sunday, enabled = excluded.enabled,
last_sent_at = excluded.last_sent_at"
) )
.bind(reminder.id().value().to_string()) .bind(reminder.id().value().to_string())
.bind(reminder.user_id().value().to_string()) .bind(reminder.user_id().value().to_string())
@@ -41,6 +42,7 @@ impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository {
.bind(reminder.schedule().time_for(Weekday::Sun).map(format_time)) .bind(reminder.schedule().time_for(Weekday::Sun).map(format_time))
.bind(reminder.is_enabled()) .bind(reminder.is_enabled())
.bind(reminder.created_at().to_rfc3339()) .bind(reminder.created_at().to_rfc3339())
.bind(reminder.last_sent_at().map(|sent| sent.to_rfc3339()))
.execute(&self.pool).await.map_err(db_err)?; .execute(&self.pool).await.map_err(db_err)?;
Ok(()) Ok(())
} }

View File

@@ -16,6 +16,7 @@ pub struct ReminderRow {
pub sunday: Option<String>, pub sunday: Option<String>,
pub enabled: bool, pub enabled: bool,
pub created_at: String, pub created_at: String,
pub last_sent_at: Option<String>,
} }
impl ReminderRow { impl ReminderRow {
@@ -36,6 +37,7 @@ impl ReminderRow {
schedule, schedule,
self.enabled, self.enabled,
self.created_at.parse().unwrap(), self.created_at.parse().unwrap(),
self.last_sent_at.and_then(|sent| sent.parse().ok()),
) )
} }
} }

View File

@@ -1,5 +1,13 @@
use chrono::{DateTime, FixedOffset, SecondsFormat, Utc};
use domain::errors::DomainError; use domain::errors::DomainError;
pub fn db_err(e: sqlx::Error) -> DomainError { pub fn db_err(e: sqlx::Error) -> DomainError {
DomainError::InvalidInput(format!("database error: {e}")) DomainError::InvalidInput(format!("database error: {e}"))
} }
pub fn sortable_instant(instant: &DateTime<FixedOffset>) -> String {
instant
.with_timezone(&Utc)
.to_rfc3339_opts(SecondsFormat::Secs, false)
}

View File

@@ -1,6 +1,6 @@
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use domain::api_token::{ApiToken, TokenDigest}; use domain::api_token::{ApiToken, TokenDigest, TokenScope, TokenScopes};
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, CascadeDeletePort, UserCommandPort}; use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, CascadeDeletePort, UserCommandPort};
use domain::provider::ProviderName; use domain::provider::ProviderName;
use domain::testing::test_user; use domain::testing::test_user;
@@ -33,6 +33,7 @@ fn a_token(owner: &UserId, name: &str, digest: &str) -> ApiToken {
owner.clone(), owner.clone(),
ProviderName::new(name).unwrap(), ProviderName::new(name).unwrap(),
TokenDigest::from_persistence(digest.into()), TokenDigest::from_persistence(digest.into()),
TokenScopes::new([TokenScope::WriteMetrics]).unwrap(),
) )
} }
@@ -152,7 +153,7 @@ async fn a_row_with_a_scope_this_build_does_not_know_authenticates_nothing() {
let (pool, user) = a_pool_with_a_user().await; let (pool, user) = a_pool_with_a_user().await;
sqlx::query( sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at) "INSERT INTO api_tokens (id, user_id, name, digest, scopes, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)", VALUES (?, ?, ?, ?, ?, ?, NULL)",
) )
.bind(uuid::Uuid::new_v4().to_string()) .bind(uuid::Uuid::new_v4().to_string())

View File

@@ -283,3 +283,170 @@ fn remove(path: &str) {
let _ = std::fs::remove_file(format!("{path}-wal")); let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm")); let _ = std::fs::remove_file(format!("{path}-shm"));
} }
const EVERY_TABLE_A_USER_FILLS: [&str; 7] = [
"mood_entries",
"activities",
"reminders",
"daily_metrics",
"cycle_starts",
"metric_rejections",
"media_owners",
];
async fn fill_every_table_for(pool: &SqlitePool, user: &User) {
let entry = MoodEntry::new(
user.id().clone(),
Mood::Good,
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
domain::ports::ActivityCommandPort::save(
&sqlite::repositories::SqliteActivityCommandRepository::new(pool.clone()),
&domain::testing::test_activity(user.id().clone(), "gaming"),
)
.await
.unwrap();
domain::ports::ReminderCommandPort::save(
&sqlite::repositories::SqliteReminderCommandRepository::new(pool.clone()),
&domain::testing::test_reminder(user.id().clone()),
)
.await
.unwrap();
domain::ports::DailyMetricCommandPort::save(
&sqlite::repositories::SqliteDailyMetricCommandRepository::new(pool.clone()),
&[domain::metric::DailyMetric::new(
user.id().clone(),
a_date("2026-08-20"),
domain::metric::MetricValue::Steps(domain::metric::Steps::new(8_412).unwrap()),
domain::metric::Source::Manual,
)],
)
.await
.unwrap();
domain::ports::CycleStartCommandPort::record(
&sqlite::repositories::SqliteCycleStartRepository::new(pool.clone()),
user.id(),
&a_date("2026-08-01"),
)
.await
.unwrap();
domain::ports::RejectionCommandPort::record(
&sqlite::repositories::SqliteRejectionRepository::new(pool.clone(), 100),
&[domain::rejection::RejectedMetric::new(
user.id().clone(),
domain::rejection::RejectionOrigin::Import,
domain::rejection::RejectionDetail::new(
None,
Some(a_date("2026-08-19")),
"steps",
Some(-1),
),
String::from("steps cannot be negative"),
)],
)
.await
.unwrap();
domain::ports::MediaOwnershipPort::remember(
&sqlite::repositories::SqliteMediaOwnershipRepository::new(pool.clone()),
user.id(),
(&domain::attachment::PhotoId::generate()).into(),
)
.await
.unwrap();
}
fn a_date(day: &str) -> domain::entry::Date {
domain::entry::Date::from_persistence(day.parse().unwrap())
}
#[tokio::test]
async fn clearing_a_users_data_empties_every_table_they_fill() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
fill_every_table_for(&pool, &user).await;
for table in EVERY_TABLE_A_USER_FILLS {
assert_eq!(rows_in(&pool, table).await, 1, "{table} should be seeded");
}
domain::ports::CascadeDeletePort::delete_all_user_data(
&sqlite::repositories::SqliteCascadeDeleteRepository::new(pool.clone()),
user.id(),
)
.await
.unwrap();
for table in EVERY_TABLE_A_USER_FILLS {
assert_eq!(
rows_in(&pool, table).await,
0,
"{table} still holds data the user asked to be cleared"
);
}
let users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(users.0, 1, "clearing data is not deleting the account");
remove(&path);
}
#[tokio::test]
async fn deleting_an_account_empties_every_table_they_fill() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
fill_every_table_for(&pool, &user).await;
domain::ports::CascadeDeletePort::delete_user_account(
&sqlite::repositories::SqliteCascadeDeleteRepository::new(pool.clone()),
user.id(),
)
.await
.unwrap();
for table in EVERY_TABLE_A_USER_FILLS {
assert_eq!(
rows_in(&pool, table).await,
0,
"{table} outlived the account it belonged to"
);
}
let users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(users.0, 0);
remove(&path);
}

View File

@@ -0,0 +1,142 @@
use chrono::{DateTime, FixedOffset};
use sqlx::SqlitePool;
use domain::entry::{DateRange, Mood, MoodEntry};
use domain::ports::{MoodEntryCommandPort, MoodEntryQueryPort, UserCommandPort};
use domain::testing::test_user;
use domain::user::User;
use sqlite::repositories::{
SqliteEntryCommandRepository, SqliteEntryQueryRepository, SqliteUserCommandRepository,
};
fn a_file() -> String {
let name = format!("k-mood-instants-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
async fn a_pool_with_a_user() -> (SqlitePool, User) {
let path = a_file();
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user)
}
fn instant(text: &str) -> DateTime<FixedOffset> {
DateTime::parse_from_rfc3339(text).unwrap()
}
async fn save(pool: &SqlitePool, user: &User, logged_at: &str) -> MoodEntry {
let entry = MoodEntry::new(user.id().clone(), Mood::Good, instant(logged_at));
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
entry
}
async fn stored_text(pool: &SqlitePool, entry: &MoodEntry) -> String {
let (held,): (String,) = sqlx::query_as("SELECT logged_at FROM mood_entries WHERE id = ?")
.bind(entry.id().value().to_string())
.fetch_one(pool)
.await
.unwrap();
held
}
#[tokio::test]
async fn an_instant_is_held_in_utc_whatever_offset_it_arrived_in() {
let (pool, user) = a_pool_with_a_user().await;
let entry = save(&pool, &user, "2026-08-25T20:00:00+02:00").await;
assert_eq!(
stored_text(&pool, &entry).await,
"2026-08-25T18:00:00+00:00",
"one canonical form makes the column sortable"
);
}
#[tokio::test]
async fn an_instant_survives_the_round_trip_unchanged() {
let (pool, user) = a_pool_with_a_user().await;
let entry = save(&pool, &user, "2026-08-25T20:00:00+02:00").await;
let read = SqliteEntryQueryRepository::new(pool.clone())
.find_by_id(entry.id())
.await
.unwrap()
.unwrap();
assert_eq!(
read.logged_at(),
entry.logged_at(),
"the instant is the fact; the offset it was written in is not"
);
}
#[tokio::test]
async fn a_range_finds_entries_written_in_other_offsets() {
let (pool, user) = a_pool_with_a_user().await;
let eastern = save(&pool, &user, "2026-08-25T01:30:00+03:00").await;
let western = save(&pool, &user, "2026-08-24T21:30:00-04:00").await;
let whole_of_the_24th_utc = DateRange::new(
instant("2026-08-24T00:00:00+00:00"),
instant("2026-08-24T23:59:59+00:00"),
)
.unwrap();
let found = SqliteEntryQueryRepository::new(pool.clone())
.find_by_date_range(user.id(), &whole_of_the_24th_utc)
.await
.unwrap();
let ids: Vec<_> = found.iter().map(|entry| entry.id().clone()).collect();
assert!(
ids.contains(eastern.id()),
"01:30+03:00 is 22:30Z on the 24th and belongs in the range"
);
assert!(
!ids.contains(western.id()),
"21:30-04:00 is 01:30Z on the 25th, so it falls outside the range"
);
}
#[tokio::test]
async fn entries_come_back_newest_first_across_mixed_offsets() {
let (pool, user) = a_pool_with_a_user().await;
let earlier = save(&pool, &user, "2026-08-25T00:30:00+02:00").await;
let later = save(&pool, &user, "2026-08-25T00:00:00+00:00").await;
let found = SqliteEntryQueryRepository::new(pool.clone())
.find_all_by_user(user.id())
.await
.unwrap();
assert_eq!(
found[0].id(),
later.id(),
"22:30Z precedes 00:00Z, however the offsets sort as text"
);
assert_eq!(found[1].id(), earlier.id());
}

View File

@@ -140,3 +140,106 @@ async fn a_process_joining_a_migrated_database_applies_nothing() {
forget(&path); forget(&path);
} }
#[tokio::test]
async fn legacy_instants_are_rewritten_into_one_sortable_form() {
let pool = fresh_pool().await;
sqlite::run_migrations(&pool).await.unwrap();
let user = domain::testing::test_user("alice");
domain::ports::UserCommandPort::save(
&sqlite::repositories::SqliteUserCommandRepository::new(pool.clone()),
&user,
)
.await
.unwrap();
let legacy = [
("2026-08-25T20:00:00+02:00", "2026-08-25T18:00:00+00:00"),
("2026-08-24T21:30:00-04:00", "2026-08-25T01:30:00+00:00"),
("2026-08-23T09:00:00+00:00", "2026-08-23T09:00:00+00:00"),
];
for (index, (written, _)) in legacy.iter().enumerate() {
sqlx::query(
"INSERT INTO mood_entries (id, user_id, mood, logged_at, created_at, updated_at)
VALUES (?, ?, 3, ?, ?, ?)",
)
.bind(format!("00000000-0000-0000-0000-00000000000{index}"))
.bind(user.id().value().to_string())
.bind(written)
.bind(written)
.bind(written)
.execute(&pool)
.await
.unwrap();
}
sqlx::raw_sql(include_str!("../src/migrations/015_logged_at_in_utc.sql"))
.execute(&pool)
.await
.unwrap();
for (index, (written, expected)) in legacy.iter().enumerate() {
let (held,): (String,) = sqlx::query_as("SELECT logged_at FROM mood_entries WHERE id = ?")
.bind(format!("00000000-0000-0000-0000-00000000000{index}"))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(held, *expected, "{written} should normalise to {expected}");
}
}
#[tokio::test]
async fn a_token_minted_before_scopes_existed_still_writes_metrics() {
let pool = fresh_pool().await;
for (name, sql) in sqlite::migrations_before("016_api_token_scopes") {
sqlx::raw_sql(sql)
.execute(&pool)
.await
.unwrap_or_else(|error| panic!("{name} failed: {error}"));
}
let user = domain::testing::test_user("alice");
domain::ports::UserCommandPort::save(
&sqlite::repositories::SqliteUserCommandRepository::new(pool.clone()),
&user,
)
.await
.unwrap();
sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at)
VALUES (?, ?, 'tasker', 'abc123', 'writeMetrics', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user.id().value().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
sqlx::raw_sql(include_str!("../src/migrations/016_api_token_scopes.sql"))
.execute(&pool)
.await
.unwrap();
let found = domain::ports::ApiTokenQueryPort::find_by_digest(
&sqlite::repositories::SqliteApiTokenQueryRepository::new(pool.clone()),
&domain::api_token::TokenDigest::from_persistence("abc123".into()),
)
.await
.unwrap()
.expect("the token should have survived the migration");
assert!(
found.allows(domain::api_token::TokenScope::WriteMetrics),
"a token minted before scopes existed keeps doing what it always did"
);
assert!(
!found.allows(domain::api_token::TokenScope::ReadJournal),
"and gains nothing it was never granted"
);
}

View File

@@ -138,7 +138,7 @@ impl WebPushSender {
async fn forget(&self, sub: &PushSubscription) { async fn forget(&self, sub: &PushSubscription) {
if let Err(e) = self if let Err(e) = self
.subscription_command .subscription_command
.delete_by_endpoint(sub.endpoint()) .delete_by_endpoint(sub.user_id(), sub.endpoint())
.await .await
{ {
tracing::warn!(endpoint = sub.endpoint(), error = %e, "failed to drop push subscription"); tracing::warn!(endpoint = sub.endpoint(), error = %e, "failed to drop push subscription");

View File

@@ -1,5 +1,6 @@
pub mod dimension; pub mod dimension;
pub mod errors; pub mod errors;
pub mod mappers; pub mod mappers;
pub mod params;
pub mod requests; pub mod requests;
pub mod responses; pub mod responses;

View File

@@ -7,7 +7,12 @@ impl From<ApiToken> for ApiTokenResponse {
Self { Self {
id: token.id().value().to_string(), id: token.id().value().to_string(),
name: token.name().value().to_string(), name: token.name().value().to_string(),
scope: token.scope().name().to_string(), scopes: token
.scopes()
.names()
.iter()
.map(|n| n.to_string())
.collect(),
created_at: *token.created_at(), created_at: *token.created_at(),
last_used_at: token.last_used_at().copied(), last_used_at: token.last_used_at().copied(),
} }

View File

@@ -8,7 +8,7 @@ impl From<CalendarDay> for CalendarDayResponse {
date: day.date.value(), date: day.date.value(),
day_mood: day.day_mood.map(|mood| mood.value()), day_mood: day.day_mood.map(|mood| mood.value()),
mood: day.day_mood.map(|mood| mood.rounded().value()), mood: day.day_mood.map(|mood| mood.rounded().value()),
mood_label: day.day_mood.map(|mood| format!("{:?}", mood.rounded())), mood_label: day.day_mood.map(|mood| mood.rounded().label().to_string()),
cycle_day: day.cycle_day.map(|cycle| cycle.value()), cycle_day: day.cycle_day.map(|cycle| cycle.value()),
entries: day.entries.into_iter().map(EntryResponse::from).collect(), entries: day.entries.into_iter().map(EntryResponse::from).collect(),
} }

View File

@@ -1,18 +1,18 @@
use chrono::Utc; use chrono::Utc;
use config::EntryConfig; use config::EntryConfig;
use domain::activity::ActivityId;
use domain::dimension::{ComposedEntry, DimensionValue}; use domain::dimension::{ComposedEntry, DimensionValue};
use domain::entry::{DateRange, Mood, MoodEntryId}; use domain::entry::{DateRange, EntrySelection, Mood, MoodEntryId, Pagination};
use domain::user::UserId; use domain::user::UserId;
use application::entry::commands::{CreateEntryCommand, UpdateEntryCommand}; use application::entry::commands::{CreateEntryCommand, UpdateEntryCommand};
use application::entry::queries::{ use application::entry::queries::{ListEntriesQuery, MoodStatsQuery};
FilterByActivityQuery, FilterByMoodQuery, ListEntriesQuery, MoodStatsQuery,
};
use crate::dimension::DimensionPayload; use crate::dimension::DimensionPayload;
use crate::errors::ApiValidationError; use crate::errors::ApiValidationError;
use crate::requests::{CreateEntryRequest, DateRangeParams, ListEntriesParams, UpdateEntryRequest}; use crate::params::{DateRangeParams, ListEntriesParams};
use crate::requests::{CreateEntryRequest, UpdateEntryRequest};
use crate::responses::EntryResponse; use crate::responses::EntryResponse;
use super::shared::{parse_datetime, validate_content_length}; use super::shared::{parse_datetime, validate_content_length};
@@ -45,9 +45,13 @@ impl UpdateEntryRequest {
entry_id: MoodEntryId, entry_id: MoodEntryId,
config: &EntryConfig, config: &EntryConfig,
) -> Result<UpdateEntryCommand, ApiValidationError> { ) -> Result<UpdateEntryCommand, ApiValidationError> {
let mood = Mood::try_from(self.mood)?; let mood = self.mood.map(Mood::try_from).transpose()?;
let logged_at = self.logged_at.as_deref().map(parse_datetime).transpose()?; let logged_at = self.logged_at.as_deref().map(parse_datetime).transpose()?;
let dimensions = parse_dimensions(self.dimensions, config)?;
let dimensions = match self.dimensions {
Some(named) => Some(parse_dimensions(Some(named), config)?),
None => None,
};
Ok(UpdateEntryCommand { Ok(UpdateEntryCommand {
entry_id, entry_id,
@@ -59,7 +63,11 @@ impl UpdateEntryRequest {
} }
impl ListEntriesParams { impl ListEntriesParams {
pub fn into_query(self, user_id: UserId) -> Result<ListEntriesQuery, ApiValidationError> { pub fn into_query(
self,
user_id: UserId,
config: &EntryConfig,
) -> Result<ListEntriesQuery, ApiValidationError> {
let range = match (self.from, self.to) { let range = match (self.from, self.to) {
(Some(from), Some(to)) => Some(DateRange::new( (Some(from), Some(to)) => Some(DateRange::new(
parse_datetime(&from)?, parse_datetime(&from)?,
@@ -67,15 +75,43 @@ impl ListEntriesParams {
)?), )?),
_ => None, _ => None,
}; };
Ok(ListEntriesQuery {
user_id, let activity = self
range, .activity
limit: self.limit, .as_deref()
offset: self.offset, .map(parse_activity_id)
}) .transpose()?;
let updated_since = self
.updated_since
.as_deref()
.map(parse_datetime)
.transpose()?
.map(|instant| instant.with_timezone(&Utc));
let selection = EntrySelection::everything_of(user_id)
.logged_within(range)
.of_mood(self.mood.map(Mood::try_from).transpose()?)
.tagged_with(activity)
.changed_since(updated_since);
let page = Pagination::new(
self.limit.unwrap_or(config.max_entries_per_page),
self.offset.unwrap_or(0),
config.max_entries_per_page,
)?;
Ok(ListEntriesQuery { selection, page })
} }
} }
fn parse_activity_id(named: &str) -> Result<ActivityId, ApiValidationError> {
named
.parse()
.map(ActivityId::from_uuid)
.map_err(|_| ApiValidationError::Invalid(format!("{named} is not an activity id")))
}
impl DateRangeParams { impl DateRangeParams {
pub fn into_date_range(self) -> Result<DateRange, ApiValidationError> { pub fn into_date_range(self) -> Result<DateRange, ApiValidationError> {
let from = parse_datetime(&self.from)?; let from = parse_datetime(&self.from)?;
@@ -87,27 +123,6 @@ impl DateRangeParams {
let range = Some(self.into_date_range()?); let range = Some(self.into_date_range()?);
Ok(MoodStatsQuery { user_id, range }) Ok(MoodStatsQuery { user_id, range })
} }
pub fn into_mood_filter(
self,
user_id: UserId,
mood: u8,
) -> Result<FilterByMoodQuery, ApiValidationError> {
let mood = Mood::try_from(mood)?;
Ok(FilterByMoodQuery { user_id, mood })
}
pub fn into_activity_filter(
self,
user_id: UserId,
activity_id: &str,
) -> Result<FilterByActivityQuery, ApiValidationError> {
let activity_id = super::shared::parse_uuid(activity_id)?.into();
Ok(FilterByActivityQuery {
user_id,
activity_id,
})
}
} }
impl From<ComposedEntry> for EntryResponse { impl From<ComposedEntry> for EntryResponse {
@@ -118,7 +133,7 @@ impl From<ComposedEntry> for EntryResponse {
id: entry.id().value(), id: entry.id().value(),
user_id: entry.user_id().value(), user_id: entry.user_id().value(),
mood: entry.mood().value(), mood: entry.mood().value(),
mood_label: format!("{:?}", entry.mood()), mood_label: entry.mood().label().to_string(),
logged_at: *entry.logged_at(), logged_at: *entry.logged_at(),
photo_urls: composed photo_urls: composed
.photos() .photos()

View File

@@ -0,0 +1,19 @@
use domain::job::{Job, JobSubject};
use crate::responses::ExhaustedJobResponse;
impl From<Job> for ExhaustedJobResponse {
fn from(job: Job) -> Self {
let JobSubject::Entry(entry_id) = job.subject();
Self {
id: job.id().value().to_string(),
kind: job.kind().name().to_string(),
subject: entry_id.value().to_string(),
attempts: job.attempts(),
last_error: job.last_error().map(|reason| reason.to_string()),
enqueued_at: *job.enqueued_at(),
updated_at: *job.updated_at(),
}
}
}

View File

@@ -1,6 +1,6 @@
use domain::attachment::{PhotoId, VoiceMemoId}; use domain::attachment::{MediaKind, MediaRef, PhotoId, VoiceMemoId};
use crate::responses::MediaIdResponse; use crate::responses::{MediaIdResponse, OwnedMediaResponse};
impl From<PhotoId> for MediaIdResponse { impl From<PhotoId> for MediaIdResponse {
fn from(id: PhotoId) -> Self { fn from(id: PhotoId) -> Self {
@@ -13,3 +13,18 @@ impl From<VoiceMemoId> for MediaIdResponse {
Self { id: id.value() } Self { id: id.value() }
} }
} }
impl From<MediaRef> for OwnedMediaResponse {
fn from(media: MediaRef) -> Self {
let path = match media.kind() {
MediaKind::Photo => "photos",
MediaKind::VoiceMemo => "voice-memos",
};
Self {
url: format!("/api/v1/media/{path}/{}", media.id()),
id: media.id().to_string(),
kind: media.kind().name().to_string(),
}
}
}

View File

@@ -3,7 +3,8 @@ use domain::entry::{Date, DateSpan};
use domain::metric::{DailyMetric, MetricKind, MetricValue}; use domain::metric::{DailyMetric, MetricKind, MetricValue};
use crate::errors::ApiValidationError; use crate::errors::ApiValidationError;
use crate::requests::{DateSpanParams, MetricPayload}; use crate::params::DateSpanParams;
use crate::requests::MetricPayload;
use crate::responses::DailyMetricResponse; use crate::responses::DailyMetricResponse;
impl From<DailyMetric> for DailyMetricResponse { impl From<DailyMetric> for DailyMetricResponse {

View File

@@ -6,9 +6,11 @@ mod correlation;
mod cycle; mod cycle;
mod entry; mod entry;
mod import_metrics; mod import_metrics;
mod job;
mod media; mod media;
mod metric; mod metric;
mod provider; mod provider;
mod push;
mod reminder; mod reminder;
mod restore; mod restore;
pub mod shared; pub mod shared;

View File

@@ -0,0 +1,13 @@
use domain::push::PushSubscription;
use crate::responses::PushSubscriptionResponse;
impl From<PushSubscription> for PushSubscriptionResponse {
fn from(subscription: PushSubscription) -> Self {
Self {
id: subscription.id().value().to_string(),
endpoint: subscription.endpoint().to_string(),
created_at: *subscription.created_at(),
}
}
}

View File

@@ -11,6 +11,7 @@ impl From<RestoreOutcome> for RestoreOutcomeResponse {
activities: outcome.activities, activities: outcome.activities,
reminders: outcome.reminders, reminders: outcome.reminders,
media: outcome.media, media: outcome.media,
skipped: outcome.skipped,
unreadable: outcome.unreadable, unreadable: outcome.unreadable,
} }
} }

View File

@@ -1,6 +1,6 @@
use application::entry::use_cases::get_mood_stats::MoodStats; use application::entry::use_cases::get_mood_stats::MoodStats;
use crate::responses::{MoodFrequency, MoodStatsResponse}; use crate::responses::{MoodFrequencyResponse, MoodStatsResponse};
impl From<MoodStats> for MoodStatsResponse { impl From<MoodStats> for MoodStatsResponse {
fn from(stats: MoodStats) -> Self { fn from(stats: MoodStats) -> Self {
@@ -9,14 +9,16 @@ impl From<MoodStats> for MoodStatsResponse {
frequency: stats frequency: stats
.frequency .frequency
.into_iter() .into_iter()
.map(|(mood, count)| MoodFrequency { .map(|(mood, count)| MoodFrequencyResponse {
mood: mood.value(), mood: mood.value(),
mood_label: format!("{mood:?}"), mood_label: mood.label().to_string(),
count, count,
}) })
.collect(), .collect(),
current_streak: stats.current_streak, current_streak: stats.current_streak,
total_entries: stats.total_entries, total_entries: stats.total_entries,
first_logged_on: stats.first_logged_on.map(|date| date.to_string()),
last_logged_on: stats.last_logged_on.map(|date| date.to_string()),
} }
} }
} }

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Default, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListActivitiesParams {
pub include_archived: Option<bool>,
}

View File

@@ -0,0 +1,18 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListEntriesParams {
pub from: Option<String>,
pub to: Option<String>,
pub mood: Option<u8>,
pub activity: Option<String>,
pub updated_since: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateRangeParams {
pub from: String,
pub to: String,
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateSpanParams {
pub from: String,
pub to: String,
}

View File

@@ -0,0 +1,7 @@
mod activity;
mod entry;
mod metric;
pub use activity::ListActivitiesParams;
pub use entry::{DateRangeParams, ListEntriesParams};
pub use metric::DateSpanParams;

View File

@@ -2,4 +2,5 @@
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MintApiTokenRequest { pub struct MintApiTokenRequest {
pub name: String, pub name: String,
pub scopes: Vec<String>,
} }

View File

@@ -1,33 +1,25 @@
use crate::dimension::DimensionPayload;
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreateEntryRequest { pub struct CreateEntryRequest {
pub mood: u8, pub mood: u8,
pub logged_at: Option<String>, pub logged_at: Option<String>,
pub dimensions: Option<Vec<crate::dimension::DimensionPayload>>, pub dimensions: Option<Vec<DimensionPayload>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateEntriesRequest {
pub entries: Vec<CreateEntryRequest>,
} }
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UpdateEntryRequest { pub struct UpdateEntryRequest {
pub mood: u8, pub mood: Option<u8>,
pub logged_at: Option<String>, pub logged_at: Option<String>,
pub dimensions: Option<Vec<crate::dimension::DimensionPayload>>, pub dimensions: Option<Vec<DimensionPayload>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListEntriesParams {
pub from: Option<String>,
pub to: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateRangeParams {
pub from: String,
pub to: String,
} }
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]

View File

@@ -10,10 +10,3 @@ pub struct MetricPayload {
pub kind: String, pub kind: String,
pub value: Option<i64>, pub value: Option<i64>,
} }
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateSpanParams {
pub from: String,
pub to: String,
}

View File

@@ -13,11 +13,10 @@ pub use activity::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequ
pub use api_token::MintApiTokenRequest; pub use api_token::MintApiTokenRequest;
pub use cycle::SetPreferencesRequest; pub use cycle::SetPreferencesRequest;
pub use entry::{ pub use entry::{
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest, CreateEntriesRequest, CreateEntryRequest, ReplaceActivityRequest, UpdateEntryRequest,
UpdateEntryRequest,
}; };
pub use import::{ImportDailyMetricsRequest, ImportedDayPayload, ImportedMetricPayload}; pub use import::{ImportDailyMetricsRequest, ImportedDayPayload, ImportedMetricPayload};
pub use metric::{DateSpanParams, MetricPayload, SetDailyMetricsRequest}; pub use metric::{MetricPayload, SetDailyMetricsRequest};
pub use provider::ConnectProviderRequest; pub use provider::ConnectProviderRequest;
pub use push::{PushSubscribeRequest, PushUnsubscribeRequest}; pub use push::{PushSubscribeRequest, PushUnsubscribeRequest};
pub use reminder::{CreateReminderRequest, UpdateReminderRequest}; pub use reminder::{CreateReminderRequest, UpdateReminderRequest};

View File

@@ -25,8 +25,9 @@ pub struct PushUnsubscribeRequest {
} }
impl PushUnsubscribeRequest { impl PushUnsubscribeRequest {
pub fn into_command(self) -> UnsubscribePushCommand { pub fn into_command(self, user_id: UserId) -> UnsubscribePushCommand {
UnsubscribePushCommand { UnsubscribePushCommand {
user_id,
endpoint: self.endpoint, endpoint: self.endpoint,
} }
} }

View File

@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
pub struct ApiTokenResponse { pub struct ApiTokenResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub scope: String, pub scopes: Vec<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>, pub last_used_at: Option<DateTime<Utc>>,
} }

View File

@@ -0,0 +1,26 @@
use chrono::{DateTime, Utc};
use super::UserResponse;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SignedInResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub user: UserResponse,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RefreshedResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct VapidKeyResponse {
pub public_key: String,
}

View File

@@ -1,21 +0,0 @@
use uuid::Uuid;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct BulkActionResponse {
pub affected_count: u64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportResultResponse {
pub imported: u64,
pub skipped: u64,
pub errors: Vec<String>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MediaIdResponse {
pub id: Uuid,
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct BulkActionResponse {
pub affected_count: u64,
}

View File

@@ -0,0 +1,23 @@
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
pub error: ErrorDetailResponse,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ErrorDetailResponse {
pub code: String,
pub message: String,
}
impl ErrorResponse {
pub fn new(code: &str, message: impl Into<String>) -> Self {
Self {
error: ErrorDetailResponse {
code: code.to_string(),
message: message.into(),
},
}
}
}

View File

@@ -0,0 +1,7 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportResultResponse {
pub imported: u64,
pub skipped: u64,
pub errors: Vec<String>,
}

View File

@@ -0,0 +1,13 @@
use chrono::{DateTime, Utc};
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ExhaustedJobResponse {
pub id: String,
pub kind: String,
pub subject: String,
pub attempts: u32,
pub last_error: Option<String>,
pub enqueued_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,13 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MediaIdResponse {
pub id: uuid::Uuid,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct OwnedMediaResponse {
pub id: String,
pub kind: String,
pub url: String,
}

View File

@@ -1,31 +1,49 @@
mod activity; mod activity;
mod api_token; mod api_token;
mod bulk; mod auth;
mod bulk_action;
mod calendar; mod calendar;
mod correlation; mod correlation;
mod cycle; mod cycle;
mod entry; mod entry;
mod error;
mod import; mod import;
mod import_result;
mod job;
mod media;
mod metric; mod metric;
mod page;
mod provider; mod provider;
mod push;
mod reminder; mod reminder;
mod restore; mod restore;
mod server;
mod stats; mod stats;
mod user; mod user;
pub use activity::ActivityResponse; pub use activity::ActivityResponse;
pub use api_token::{ApiTokenResponse, MintedApiTokenResponse}; pub use api_token::{ApiTokenResponse, MintedApiTokenResponse};
pub use bulk::{BulkActionResponse, ImportResultResponse, MediaIdResponse}; pub use auth::{RefreshedResponse, SignedInResponse, VapidKeyResponse};
pub use bulk_action::BulkActionResponse;
pub use calendar::CalendarDayResponse; pub use calendar::CalendarDayResponse;
pub use correlation::{ pub use correlation::{
AgreementResponse, CorrelationInputResponse, CorrelationRowResponse, StrategyScoreResponse, AgreementResponse, CorrelationInputResponse, CorrelationRowResponse, StrategyScoreResponse,
}; };
pub use cycle::{CyclePositionResponse, CycleViewResponse, PreferencesResponse}; pub use cycle::{CyclePositionResponse, CycleViewResponse, PreferencesResponse};
pub use entry::EntryResponse; pub use entry::EntryResponse;
pub use error::{ErrorDetailResponse, ErrorResponse};
pub use import::{ImportOutcomeResponse, RejectedMetricResponse, RejectionResponse}; pub use import::{ImportOutcomeResponse, RejectedMetricResponse, RejectionResponse};
pub use import_result::ImportResultResponse;
pub use job::ExhaustedJobResponse;
pub use media::{MediaIdResponse, OwnedMediaResponse};
pub use metric::DailyMetricResponse; pub use metric::DailyMetricResponse;
pub use page::EntryPageResponse;
pub use provider::ProviderConnectionResponse; pub use provider::ProviderConnectionResponse;
pub use push::PushSubscriptionResponse;
pub use reminder::{DayScheduleResponse, ReminderResponse}; pub use reminder::{DayScheduleResponse, ReminderResponse};
pub use restore::RestoreOutcomeResponse; pub use restore::RestoreOutcomeResponse;
pub use stats::{MoodFrequency, MoodStatsResponse}; pub use server::{
MetricKindResponse, MoodResponse, ServerInfoResponse, ServerLimitsResponse, TokenScopeResponse,
};
pub use stats::{MoodFrequencyResponse, MoodStatsResponse};
pub use user::UserResponse; pub use user::UserResponse;

View File

@@ -0,0 +1,11 @@
use super::EntryResponse;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EntryPageResponse {
pub items: Vec<EntryResponse>,
pub total: u64,
pub limit: i64,
pub offset: i64,
pub has_more: bool,
}

View File

@@ -0,0 +1,9 @@
use chrono::{DateTime, Utc};
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PushSubscriptionResponse {
pub id: String,
pub endpoint: String,
pub created_at: DateTime<Utc>,
}

View File

@@ -7,5 +7,6 @@ pub struct RestoreOutcomeResponse {
pub activities: usize, pub activities: usize,
pub reminders: usize, pub reminders: usize,
pub media: usize, pub media: usize,
pub skipped: usize,
pub unreadable: Vec<String>, pub unreadable: Vec<String>,
} }

View File

@@ -0,0 +1,50 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfoResponse {
pub version: String,
pub registration_open: bool,
pub push_enabled: bool,
pub weather_enabled: bool,
pub now_playing_provider: Option<String>,
pub access_token_ttl_seconds: u64,
pub limits: ServerLimitsResponse,
pub token_scopes: Vec<TokenScopeResponse>,
pub metric_kinds: Vec<MetricKindResponse>,
pub moods: Vec<MoodResponse>,
pub weather_conditions: Vec<String>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServerLimitsResponse {
pub max_body_size: usize,
pub max_content_length: usize,
pub max_photos: usize,
pub max_voice_memos: usize,
pub max_activities_per_entry: usize,
pub max_entries_per_page: i64,
pub max_import_days: usize,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TokenScopeResponse {
pub name: String,
pub describes: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MetricKindResponse {
pub name: String,
pub unit: String,
pub minimum: i64,
pub maximum: i64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MoodResponse {
pub value: u8,
pub label: String,
}

View File

@@ -1,6 +1,6 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MoodFrequency { pub struct MoodFrequencyResponse {
pub mood: u8, pub mood: u8,
pub mood_label: String, pub mood_label: String,
pub count: usize, pub count: usize,
@@ -10,7 +10,9 @@ pub struct MoodFrequency {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MoodStatsResponse { pub struct MoodStatsResponse {
pub average: Option<f64>, pub average: Option<f64>,
pub frequency: Vec<MoodFrequency>, pub frequency: Vec<MoodFrequencyResponse>,
pub current_streak: usize, pub current_streak: usize,
pub total_entries: usize, pub total_entries: usize,
pub first_logged_on: Option<String>,
pub last_logged_on: Option<String>,
} }

View File

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

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