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:
26
CONTEXT.md
26
CONTEXT.md
@@ -23,7 +23,7 @@ Optional markdown text attached to a MoodEntry. Serves both quick annotations ("
|
||||
_Avoid_: Note, quick note, description, journal entry
|
||||
|
||||
**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
|
||||
|
||||
**Category**:
|
||||
@@ -43,9 +43,13 @@ _Avoid_: TZ, offset, locale, region
|
||||
### Scheduling
|
||||
|
||||
**Reminder**:
|
||||
A per-user notification schedule. Each Reminder defines an `Option<Time>` for each day of the week — `Some(20:00)` means remind at 8 PM, `None` means skip that day. Can be enabled or disabled. A User can have multiple Reminders (e.g., one for morning check-in, one for evening). The domain defines when to remind; clients decide the message and delivery mechanism.
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
**DailyMetric**:
|
||||
@@ -61,9 +65,13 @@ An external system that supplies data. Identified only by name — the domain kn
|
||||
_Avoid_: Integration, service, source, connector
|
||||
|
||||
**ApiToken**:
|
||||
A named, revocable, non-expiring credential a User mints for an automation that cannot hold a session. Scoped to writing DailyMetrics and nothing else — it cannot read entries or touch the account. Its name is the Provider its writes are attributed to, so two tokens of one account cannot share a name. The value is shown once at mint time and held only as a digest.
|
||||
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
|
||||
|
||||
**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**:
|
||||
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
|
||||
@@ -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.
|
||||
_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**:
|
||||
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
|
||||
@@ -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.
|
||||
_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**:
|
||||
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
|
||||
|
||||
175
Cargo.lock
generated
175
Cargo.lock
generated
@@ -783,6 +783,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "der"
|
||||
version = "0.4.5"
|
||||
@@ -1137,6 +1151,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1210,6 +1234,12 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
@@ -1257,9 +1287,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1285,6 +1317,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1315,6 +1370,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1444,9 +1505,13 @@ dependencies = [
|
||||
"axum",
|
||||
"config",
|
||||
"domain",
|
||||
"http-body-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http 0.7.0",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"utoipa-scalar",
|
||||
@@ -2124,6 +2189,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2432,6 +2509,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2497,6 +2594,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -2552,6 +2655,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -2725,6 +2843,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3317,6 +3444,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -3939,6 +4075,22 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -4377,6 +4529,22 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4386,6 +4554,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -4548,6 +4722,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"bootstrap",
|
||||
"chrono",
|
||||
"config",
|
||||
"domain",
|
||||
"tokio",
|
||||
|
||||
@@ -57,7 +57,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
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_governor = { version = "0.8", default-features = false, features = ["axum"] }
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
|
||||
email_address = "0.2"
|
||||
chrono-tz = { version = "0.10", features = ["serde"] }
|
||||
|
||||
@@ -18,6 +18,25 @@ max_content_length = 65536
|
||||
max_photos = 10
|
||||
max_voice_memos = 5
|
||||
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]
|
||||
data_dir = "./data"
|
||||
@@ -67,6 +86,7 @@ poll_seconds = 15
|
||||
sweep_seconds = 900
|
||||
session_cleanup_seconds = 3600
|
||||
reminder_seconds = 60
|
||||
reminder_grace_minutes = 30
|
||||
jobs_per_poll = 20
|
||||
enqueued_per_sweep = 200
|
||||
most_attempts = 5
|
||||
|
||||
@@ -15,7 +15,7 @@ use domain::reminder::Reminder;
|
||||
|
||||
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 struct ZipBackupWriter;
|
||||
@@ -67,6 +67,7 @@ fn manifest(backup: &UserBackup) -> Result<Vec<u8>, DomainError> {
|
||||
.map(BackedUpReminder::from)
|
||||
.collect(),
|
||||
tracks_cycle: backup.preferences.tracks_cycle(),
|
||||
media: backed_up_media(backup),
|
||||
};
|
||||
|
||||
serde_json::to_vec_pretty(&manifest).map_err(json_err)
|
||||
@@ -82,6 +83,37 @@ pub struct BackupManifest {
|
||||
pub activities: Vec<BackedUpActivity>,
|
||||
pub reminders: Vec<BackedUpReminder>,
|
||||
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)]
|
||||
|
||||
@@ -3,7 +3,7 @@ mod extract;
|
||||
mod shared;
|
||||
|
||||
pub use backup::{
|
||||
BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMetric,
|
||||
BackedUpReminder, BackupManifest, ZipBackupWriter,
|
||||
BACKUP_FORMAT_VERSION, BACKUP_MANIFEST, BackedUpActivity, BackedUpEntry, BackedUpMedia,
|
||||
BackedUpMetric, BackedUpReminder, BackupManifest, PHOTO_KIND, VOICE_MEMO_KIND, ZipBackupWriter,
|
||||
};
|
||||
pub use extract::MarkdownExtractWriter;
|
||||
|
||||
@@ -11,6 +11,7 @@ config.workspace = true
|
||||
web-push-adapter.workspace = true
|
||||
axum.workspace = true
|
||||
tower-http.workspace = true
|
||||
tower_governor.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -18,3 +19,8 @@ uuid.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-scalar.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tower.workspace = true
|
||||
http-body-util.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -3,24 +3,37 @@ use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use api_types::errors::ApiValidationError;
|
||||
use api_types::responses::ErrorResponse;
|
||||
use application::errors::ApplicationError;
|
||||
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 fn refuse(status: StatusCode, code: &str, message: impl Into<String>) -> Response {
|
||||
(status, Json(ErrorResponse::new(code, message))).into_response()
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match &self.0 {
|
||||
ApplicationError::Domain(domain_err) => domain_error_response(domain_err),
|
||||
ApplicationError::Validation(msg) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"VALIDATION_ERROR",
|
||||
VALIDATION_ERROR,
|
||||
msg.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = serde_json::json!({ "error": { "code": code, "message": message } });
|
||||
(status, Json(body)).into_response()
|
||||
refuse(status, code, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +60,10 @@ impl From<DomainError> for ApiError {
|
||||
|
||||
fn domain_error_response(err: &DomainError) -> (StatusCode, &'static str, String) {
|
||||
match err {
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
|
||||
DomainError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, "INVALID_INPUT", msg.clone()),
|
||||
DomainError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()),
|
||||
DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, NOT_FOUND, msg.clone()),
|
||||
DomainError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, INVALID_INPUT, msg.clone()),
|
||||
DomainError::Conflict(msg) => (StatusCode::CONFLICT, CONFLICT, msg.clone()),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, UNAUTHORIZED, msg.clone()),
|
||||
DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, FORBIDDEN, msg.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
145
crates/adapters/http-axum/src/extractors/authentication.rs
Normal file
145
crates/adapters/http-axum/src/extractors/authentication.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
59
crates/adapters/http-axum/src/extractors/body.rs
Normal file
59
crates/adapters/http-axum/src/extractors/body.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ use application::api_token::use_cases::authenticate_api_token;
|
||||
|
||||
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 user_id: UserId,
|
||||
@@ -34,9 +36,13 @@ where
|
||||
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
|
||||
.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 {
|
||||
user_id: token.user_id().clone(),
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
pub mod authenticated_user;
|
||||
pub mod authentication;
|
||||
mod body;
|
||||
mod importing_provider;
|
||||
mod metric_writer;
|
||||
mod multipart;
|
||||
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 metric_writer::MetricWriter;
|
||||
pub use multipart::{extract_file_bytes, extract_media_upload};
|
||||
pub use path_id::PathId;
|
||||
pub use scoped::{
|
||||
Administrator, JournalReader, JournalWriter, MetricWriter, ProfileReader, SessionUser,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use axum::Json;
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -9,8 +8,11 @@ pub struct PathIdRejection(String);
|
||||
|
||||
impl IntoResponse for PathIdRejection {
|
||||
fn into_response(self) -> Response {
|
||||
let body = serde_json::json!({ "error": self.0 });
|
||||
(StatusCode::BAD_REQUEST, Json(body)).into_response()
|
||||
crate::errors::refuse(
|
||||
StatusCode::BAD_REQUEST,
|
||||
crate::errors::INVALID_INPUT,
|
||||
self.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
crates/adapters/http-axum/src/extractors/scoped.rs
Normal file
99
crates/adapters/http-axum/src/extractors/scoped.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@ use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::params::ListActivitiesParams;
|
||||
use api_types::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest};
|
||||
use api_types::responses::ActivityResponse;
|
||||
use api_types::responses::{ActivityResponse, ErrorResponse};
|
||||
use application::activity::use_cases::{
|
||||
archive_activity, create_activity, delete_activity, get_activity, list_activities,
|
||||
rename_activity, set_category,
|
||||
@@ -11,17 +12,21 @@ use application::activity::use_cases::{
|
||||
use domain::activity::ActivityId;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::extractors::{Body, JournalReader, JournalWriter, Params, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateActivityRequest>,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Body(body): Body<CreateActivityRequest>,
|
||||
) -> Result<(StatusCode, Json<ActivityResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<Json<ActivityResponse>, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<ListActivitiesParams>,
|
||||
) -> Result<Json<Vec<ActivityResponse>>, ApiError> {
|
||||
let deps = list_activities::Deps {
|
||||
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(
|
||||
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" = [])),
|
||||
params(("id" = String, Path)),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
Json(body): Json<RenameActivityRequest>,
|
||||
Body(body): Body<RenameActivityRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(activity_id)?;
|
||||
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" = [])),
|
||||
params(("id" = String, Path)),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
Json(body): Json<SetCategoryRequest>,
|
||||
Body(body): Body<SetCategoryRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(activity_id)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_activity::Deps {
|
||||
entries: state.entry_query.clone(),
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
};
|
||||
|
||||
@@ -3,20 +3,23 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
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 crate::errors::ApiError;
|
||||
use crate::extractors::Body;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/login", tag = "auth",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Body(body): Body<LoginRequest>,
|
||||
) -> Result<Json<SignedInResponse>, ApiError> {
|
||||
let cmd = body.into_command();
|
||||
let deps = login::Deps {
|
||||
user_query: state.user_query,
|
||||
@@ -27,14 +30,13 @@ pub async fn handle_login(
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await?;
|
||||
let user_response = UserResponse::from(result.user);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"accessToken": result.access_token.token(),
|
||||
"refreshToken": result.refresh_token,
|
||||
"expiresAt": result.access_token.expires_at(),
|
||||
"user": user_response,
|
||||
})))
|
||||
Ok(Json(SignedInResponse {
|
||||
access_token: result.access_token.token().to_string(),
|
||||
refresh_token: result.refresh_token,
|
||||
expires_at: *result.access_token.expires_at(),
|
||||
user: UserResponse::from(result.user),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
@@ -45,12 +47,14 @@ pub struct RefreshRequest {
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/refresh", tag = "auth",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Body(body): Body<RefreshRequest>,
|
||||
) -> Result<Json<RefreshedResponse>, ApiError> {
|
||||
let deps = refresh::Deps {
|
||||
auth_service: state.auth_service,
|
||||
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?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"accessToken": result.access_token.token(),
|
||||
"refreshToken": result.refresh_token,
|
||||
"expiresAt": result.access_token.expires_at(),
|
||||
})))
|
||||
Ok(Json(RefreshedResponse {
|
||||
access_token: result.access_token.token().to_string(),
|
||||
refresh_token: result.refresh_token,
|
||||
expires_at: *result.access_token.expires_at(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
@@ -75,11 +79,13 @@ pub struct LogoutRequest {
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/logout", tag = "auth",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LogoutRequest>,
|
||||
Body(body): Body<LogoutRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = logout::Deps {
|
||||
refresh_session_command: state.refresh_session_command,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::State;
|
||||
|
||||
use api_types::requests::DateSpanParams;
|
||||
use api_types::responses::CorrelationRowResponse;
|
||||
use api_types::params::DateSpanParams;
|
||||
use api_types::responses::{CorrelationRowResponse, ErrorResponse};
|
||||
use application::correlation::queries::CorrelationQuery;
|
||||
use application::correlation::use_cases::get_correlations;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::extractors::{JournalReader, Params};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[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 \
|
||||
carries its day count and no coefficient.",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateSpanParams>,
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<DateSpanParams>,
|
||||
) -> Result<Json<Vec<CorrelationRowResponse>>, ApiError> {
|
||||
let span = params.into_span()?;
|
||||
|
||||
@@ -33,6 +37,7 @@ pub async fn handle_list(
|
||||
activities: state.activity_query,
|
||||
cycles: state.cycle_query,
|
||||
weather_store: state.weather_store,
|
||||
activity_store: state.activity_store,
|
||||
preferences: state.preferences_query,
|
||||
users: state.user_query,
|
||||
};
|
||||
|
||||
@@ -4,23 +4,27 @@ use axum::http::StatusCode;
|
||||
|
||||
use api_types::mappers::parse_date;
|
||||
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::user::use_cases::set_preferences;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::extractors::{Body, JournalReader, JournalWriter, ProfileReader, SessionUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/cycle", tag = "cycle", security(("bearer" = [])),
|
||||
description = "The recorded cycle starts and the cycle day derived for today. Cycle day is \
|
||||
never stored: correcting a start corrects every day that depended on it. \
|
||||
Returns nothing at all while cycle tracking is off.",
|
||||
responses((status = 200, body = CycleViewResponse))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
) -> Result<Json<CycleViewResponse>, ApiError> {
|
||||
let deps = read_cycle::Deps {
|
||||
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 \
|
||||
records it once.",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Path(date): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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 \
|
||||
changes at once.",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Path(date): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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, \
|
||||
and turning it off hides the cycle without forgetting what was recorded.",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<SetPreferencesRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<SetPreferencesRequest>,
|
||||
) -> Result<Json<PreferencesResponse>, ApiError> {
|
||||
let deps = set_preferences::Deps {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
ProfileReader(user_id): ProfileReader,
|
||||
) -> Result<Json<PreferencesResponse>, ApiError> {
|
||||
let preferences =
|
||||
application::user::preferences::preferences_of(&user_id, &state.preferences_query).await?;
|
||||
|
||||
@@ -3,13 +3,13 @@ use axum::extract::{Multipart, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
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::restore::commands::RestoreBackupCommand;
|
||||
use application::restore::use_cases::restore_backup;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
|
||||
use crate::extractors::{SessionUser, extract_file_bytes};
|
||||
use crate::state::AppState;
|
||||
|
||||
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 \
|
||||
media. Restores through /data/restore. Keep it private — it holds everything \
|
||||
the account knows.",
|
||||
responses((status = 200, description = "A zip archive", content_type = "application/zip"))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = write_backup::Deps {
|
||||
entries: state.entry_query,
|
||||
@@ -48,11 +52,15 @@ pub async fn handle_backup(
|
||||
readable markdown document. It carries no places, no health readings, no \
|
||||
cycle records and no media, and it cannot be restored from. This is the one \
|
||||
to hand to someone.",
|
||||
responses((status = 200, description = "A markdown document", content_type = "text/markdown"))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = write_extract::Deps {
|
||||
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 \
|
||||
restore adds, it does not replace. Anything in the archive this build cannot \
|
||||
read is reported rather than silently dropped.",
|
||||
responses((status = 200, body = RestoreOutcomeResponse))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<RestoreOutcomeResponse>, ApiError> {
|
||||
let data = extract_file_bytes(multipart).await?;
|
||||
|
||||
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,
|
||||
entry_command: state.entry_command,
|
||||
dimensions: state.dimensions,
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::params::{DateRangeParams, ListEntriesParams};
|
||||
use api_types::requests::{
|
||||
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
|
||||
UpdateEntryRequest,
|
||||
CreateEntriesRequest, CreateEntryRequest, ReplaceActivityRequest, UpdateEntryRequest,
|
||||
};
|
||||
use api_types::responses::{
|
||||
BulkActionResponse, CalendarDayResponse, EntryResponse, MoodStatsResponse,
|
||||
BulkActionResponse, CalendarDayResponse, EntryPageResponse, EntryResponse, ErrorResponse,
|
||||
MoodStatsResponse,
|
||||
};
|
||||
use application::entry::composition::EntryComposer;
|
||||
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery, MoodStatsQuery};
|
||||
use application::entry::queries::MoodStatsQuery;
|
||||
use application::entry::use_cases::{
|
||||
create_entry, delete_entries_by_date_range, delete_entry, filter_by_activity, filter_by_mood,
|
||||
get_calendar, get_entry, get_mood_stats, list_entries, replace_activity, update_entry,
|
||||
create_entries, create_entry, delete_entries_by_date_range, delete_entry, get_calendar,
|
||||
get_entry, get_mood_stats, list_entries, replace_activity, update_entry,
|
||||
};
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::entry::{Mood, MoodEntryId};
|
||||
use domain::entry::{MoodEntry, MoodEntryId, Page};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::extractors::{Body, JournalReader, JournalWriter, Params, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn compose(
|
||||
@@ -31,6 +30,24 @@ async fn compose(
|
||||
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(
|
||||
dimensions: Vec<std::sync::Arc<dyn domain::ports::EntryDimensionPort>>,
|
||||
entry: MoodEntry,
|
||||
@@ -41,16 +58,21 @@ async fn compose_one(
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateEntryRequest>,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Body(body): Body<CreateEntryRequest>,
|
||||
) -> Result<(StatusCode, Json<EntryResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id, &state.entry_config)?;
|
||||
let dimensions = state.dimensions.clone();
|
||||
let deps = create_entry::Deps {
|
||||
activities: state.activity_query.clone(),
|
||||
entries: state.entry_command,
|
||||
dimensions: state.dimensions.clone(),
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
) -> Result<Json<EntryResponse>, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<ListEntriesParams>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let query = params.into_query(user_id)?;
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<ListEntriesParams>,
|
||||
) -> Result<Json<EntryPageResponse>, ApiError> {
|
||||
let query = params.into_query(user_id, &state.entry_config)?;
|
||||
let dimensions = state.dimensions.clone();
|
||||
let deps = list_entries::Deps {
|
||||
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" = [])),
|
||||
params(("id" = String, Path, description = "Entry ID")),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
Json(body): Json<UpdateEntryRequest>,
|
||||
Body(body): Body<UpdateEntryRequest>,
|
||||
) -> Result<Json<EntryResponse>, ApiError> {
|
||||
let cmd = body.into_command(entry_id, &state.entry_config)?;
|
||||
let dimensions = state.dimensions.clone();
|
||||
let deps = update_entry::Deps {
|
||||
activities: state.activity_query.clone(),
|
||||
command: state.entry_command,
|
||||
dimensions: state.dimensions.clone(),
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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" = [])),
|
||||
params(("mood" = u8, Path, description = "Mood value 1-5")),
|
||||
responses((status = 200, body = Vec<EntryResponse>))
|
||||
description = "A page of entries of one mood. The same selection is available on \
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
reader: JournalReader,
|
||||
Path(mood): Path<u8>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let mood = Mood::try_from(mood)?;
|
||||
let query = FilterByMoodQuery { user_id, mood };
|
||||
let dimensions = state.dimensions.clone();
|
||||
let deps = filter_by_mood::Deps {
|
||||
query: state.entry_query,
|
||||
Params(params): Params<ListEntriesParams>,
|
||||
) -> Result<Json<EntryPageResponse>, ApiError> {
|
||||
let narrowed = ListEntriesParams {
|
||||
mood: Some(mood),
|
||||
..params
|
||||
};
|
||||
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" = [])),
|
||||
params(("id" = String, Path, description = "Activity ID")),
|
||||
responses((status = 200, body = Vec<EntryResponse>))
|
||||
description = "A page of entries tagged with one activity. The same selection is available \
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let query = FilterByActivityQuery {
|
||||
user_id,
|
||||
activity_id,
|
||||
reader: JournalReader,
|
||||
Path(activity_id): Path<String>,
|
||||
Params(params): Params<ListEntriesParams>,
|
||||
) -> Result<Json<EntryPageResponse>, ApiError> {
|
||||
let narrowed = ListEntriesParams {
|
||||
activity: Some(activity_id),
|
||||
..params
|
||||
};
|
||||
let dimensions = state.dimensions.clone();
|
||||
let deps = filter_by_activity::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let entries = filter_by_activity::execute(query, &deps).await?;
|
||||
Ok(Json(compose(dimensions, entries).await?))
|
||||
|
||||
handle_list(State(state), reader, Params(narrowed)).await
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<ListEntriesParams>,
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<ListEntriesParams>,
|
||||
) -> Result<Json<MoodStatsResponse>, ApiError> {
|
||||
let range = match (params.from, params.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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateRangeParams>,
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<DateRangeParams>,
|
||||
) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> {
|
||||
let range = params.into_date_range()?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateRangeParams>,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Params(params): Params<DateRangeParams>,
|
||||
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||
let range = params.into_date_range()?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<ReplaceActivityRequest>,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Body(body): Body<ReplaceActivityRequest>,
|
||||
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||
let (user_id, old_id, new_id) = body.into_parts(user_id)?;
|
||||
let deps = replace_activity::Deps {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Multipart, State};
|
||||
|
||||
use api_types::responses::ImportResultResponse;
|
||||
use api_types::responses::{ErrorResponse, ImportResultResponse};
|
||||
use application::import::commands::ImportCommand;
|
||||
use application::import::use_cases::import_entries;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
|
||||
use crate::extractors::{SessionUser, extract_file_bytes};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<ImportResultResponse>, ApiError> {
|
||||
let data = extract_file_bytes(multipart).await?;
|
||||
|
||||
35
crates/adapters/http-axum/src/handlers/jobs.rs
Normal file
35
crates/adapters/http-axum/src/handlers/jobs.rs
Normal 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()))
|
||||
}
|
||||
@@ -3,55 +3,89 @@ use axum::extract::{Multipart, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use api_types::responses::MediaIdResponse;
|
||||
use api_types::responses::{ErrorResponse, MediaIdResponse, OwnedMediaResponse};
|
||||
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::errors::DomainError;
|
||||
|
||||
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;
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||
let upload = extract_media_upload(multipart).await?;
|
||||
let deps = upload_photo::Deps {
|
||||
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))))
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||
let upload = extract_media_upload(multipart).await?;
|
||||
let deps = upload_voice_memo::Deps {
|
||||
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((
|
||||
StatusCode::CREATED,
|
||||
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",
|
||||
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(
|
||||
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",
|
||||
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(
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(photo_id): PathId<PhotoId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_photo::Deps {
|
||||
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)
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/media/voice-memos/{id}", tag = "media", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(voice_memo_id): PathId<VoiceMemoId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_voice_memo::Deps {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::mappers::parse_date;
|
||||
use api_types::requests::{DateSpanParams, ImportDailyMetricsRequest, SetDailyMetricsRequest};
|
||||
use api_types::responses::{DailyMetricResponse, ImportOutcomeResponse, RejectionResponse};
|
||||
use api_types::params::DateSpanParams;
|
||||
use api_types::requests::{ImportDailyMetricsRequest, SetDailyMetricsRequest};
|
||||
use api_types::responses::{
|
||||
DailyMetricResponse, ErrorResponse, ImportOutcomeResponse, RejectionResponse,
|
||||
};
|
||||
use application::import::commands::ImportDailyMetricsCommand;
|
||||
use application::import::use_cases::import_daily_metrics;
|
||||
use application::metric::commands::SetDailyMetricsCommand;
|
||||
use application::metric::use_cases::{list_daily_metrics, set_daily_metrics};
|
||||
use application::metric::use_cases::{list_daily_metrics, list_rejections, set_daily_metrics};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, ImportingProvider, MetricWriter};
|
||||
use crate::extractors::{Body, ImportingProvider, JournalReader, MetricWriter, Params};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/metrics", tag = "metrics", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateSpanParams>,
|
||||
JournalReader(user_id): JournalReader,
|
||||
Params(params): Params<DateSpanParams>,
|
||||
) -> Result<Json<Vec<DailyMetricResponse>>, ApiError> {
|
||||
let span = params.into_span()?;
|
||||
|
||||
@@ -40,13 +47,17 @@ pub async fn handle_list(
|
||||
only once per request.",
|
||||
params(("date" = String, Path, description = "Calendar date, as YYYY-MM-DD")),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
writer: MetricWriter,
|
||||
Path(date): Path<String>,
|
||||
Json(body): Json<SetDailyMetricsRequest>,
|
||||
Body(body): Body<SetDailyMetricsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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 \
|
||||
refused outright.",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
importer: ImportingProvider,
|
||||
Json(body): Json<ImportDailyMetricsRequest>,
|
||||
Body(body): Body<ImportDailyMetricsRequest>,
|
||||
) -> Result<Json<ImportOutcomeResponse>, ApiError> {
|
||||
let deps = import_daily_metrics::Deps {
|
||||
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 \
|
||||
broken from an importer or were stored by an older build and can no longer be \
|
||||
read. Only the most recent are kept.",
|
||||
responses((status = 200, body = Vec<RejectionResponse>))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
) -> 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()))
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ pub mod cycle;
|
||||
pub mod data;
|
||||
pub mod entries;
|
||||
pub mod import_export;
|
||||
pub mod jobs;
|
||||
pub mod media;
|
||||
pub mod metrics;
|
||||
pub mod providers;
|
||||
pub mod push;
|
||||
pub mod reminders;
|
||||
pub mod server;
|
||||
pub mod tokens;
|
||||
pub mod users;
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::http::StatusCode;
|
||||
|
||||
use api_types::dimension::DimensionPayload;
|
||||
use api_types::requests::ConnectProviderRequest;
|
||||
use api_types::responses::ProviderConnectionResponse;
|
||||
use api_types::responses::{ErrorResponse, ProviderConnectionResponse};
|
||||
use application::provider::commands::ConnectProviderCommand;
|
||||
use application::provider::use_cases::{
|
||||
connect_provider, disconnect_provider, get_now_playing, list_connections,
|
||||
@@ -14,7 +14,7 @@ use domain::errors::DomainError;
|
||||
use domain::provider::ProviderName;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::extractors::{Body, JournalReader, ProfileReader, SessionUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
fn cipher(
|
||||
@@ -30,11 +30,15 @@ fn cipher(
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
ProfileReader(user_id): ProfileReader,
|
||||
) -> Result<Json<Vec<ProviderConnectionResponse>>, ApiError> {
|
||||
let deps = list_connections::Deps {
|
||||
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" = [])),
|
||||
params(("provider" = String, Path, description = "Provider name")),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Path(provider): Path<String>,
|
||||
Json(body): Json<ConnectProviderRequest>,
|
||||
Body(body): Body<ConnectProviderRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cipher = cipher(&state)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Path(provider): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
) -> Result<Json<Option<DimensionPayload>>, ApiError> {
|
||||
let cipher = cipher(&state)?;
|
||||
|
||||
|
||||
@@ -3,18 +3,21 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
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::extractors::AuthenticatedUser;
|
||||
use crate::extractors::{Body, SessionUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<VapidKeyResponse>, ApiError> {
|
||||
if !state.push_config.enabled {
|
||||
return Err(domain::errors::DomainError::NotFound(
|
||||
"push notifications are disabled".into(),
|
||||
@@ -22,17 +25,44 @@ pub async fn handle_vapid_key(
|
||||
.into());
|
||||
}
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<PushSubscribeRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<PushSubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(user_id);
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
Json(body): Json<PushUnsubscribeRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<PushUnsubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command();
|
||||
let cmd = body.into_command(user_id);
|
||||
let deps = unsubscribe::Deps {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let sender = state
|
||||
.reminder_sender
|
||||
|
||||
@@ -3,24 +3,28 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{CreateReminderRequest, UpdateReminderRequest};
|
||||
use api_types::responses::ReminderResponse;
|
||||
use api_types::responses::{ErrorResponse, ReminderResponse};
|
||||
use application::reminder::use_cases::{
|
||||
create_reminder, delete_reminder, get_reminder, list_reminders, update_reminder,
|
||||
};
|
||||
use domain::reminder::ReminderId;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::extractors::{Body, JournalReader, JournalWriter, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateReminderRequest>,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
Body(body): Body<CreateReminderRequest>,
|
||||
) -> Result<(StatusCode, Json<ReminderResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalReader(user_id): JournalReader,
|
||||
) -> Result<Json<Vec<ReminderResponse>>, ApiError> {
|
||||
let deps = list_reminders::Deps {
|
||||
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" = [])),
|
||||
params(("id" = String, Path)),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
Json(body): Json<UpdateReminderRequest>,
|
||||
Body(body): Body<UpdateReminderRequest>,
|
||||
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||
let cmd = body.into_command(reminder_id)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
JournalWriter(user_id): JournalWriter,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_reminder::Deps {
|
||||
|
||||
76
crates/adapters/http-axum/src/handlers/server.rs
Normal file
76
crates/adapters/http-axum/src/handlers/server.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -3,24 +3,28 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
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::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 crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::extractors::{Body, PathId, SessionUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/tokens", tag = "tokens", security(("bearer" = [])),
|
||||
description = "Lists this account's api tokens. Values are never returned; only the name, \
|
||||
when it was minted, and when it was last used.",
|
||||
responses((status = 200, body = Vec<ApiTokenResponse>))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<Json<Vec<ApiTokenResponse>>, ApiError> {
|
||||
let deps = list_api_tokens::Deps {
|
||||
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" = [])),
|
||||
description = "Mints a token for writing daily metrics. The value comes back once and is \
|
||||
never retrievable again. The name becomes the Provider that the token's \
|
||||
writes are attributed to, so it must be lowercase letters, digits and hyphens.",
|
||||
description = "Mints a token granting the scopes named in the request, and nothing else. \
|
||||
The value comes back once and is never retrievable again. The name becomes \
|
||||
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,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<MintApiTokenRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<MintApiTokenRequest>,
|
||||
) -> Result<(StatusCode, Json<MintedApiTokenResponse>), ApiError> {
|
||||
let deps = mint_api_token::Deps {
|
||||
command: state.api_token_command,
|
||||
@@ -52,6 +63,7 @@ pub async fn handle_mint(
|
||||
MintApiTokenCommand {
|
||||
user_id,
|
||||
name: ProviderName::new(body.name)?,
|
||||
scopes: TokenScopes::from_names(body.scopes.iter().map(String::as_str))?,
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
@@ -63,11 +75,15 @@ pub async fn handle_mint(
|
||||
#[utoipa::path(delete, path = "/api/v1/tokens/{id}", tag = "tokens", security(("bearer" = [])),
|
||||
description = "Revokes a token. It stops working at once.",
|
||||
params(("id" = String, Path, description = "Token ID")),
|
||||
responses((status = 204))
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
PathId(token_id): PathId<ApiTokenId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = revoke_api_token::Deps {
|
||||
|
||||
@@ -3,22 +3,24 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{ChangePasswordRequest, RegisterRequest, UpdateProfileRequest};
|
||||
use api_types::responses::UserResponse;
|
||||
use api_types::responses::{ErrorResponse, UserResponse};
|
||||
use application::user::use_cases::{
|
||||
change_password, clear_data, delete_user, get_profile, register, update_profile,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::extractors::{Body, ProfileReader, SessionUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/users/register", tag = "users",
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RegisterRequest>,
|
||||
Body(body): Body<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<UserResponse>), ApiError> {
|
||||
if !state.auth_config.allow_registration {
|
||||
return Err(
|
||||
@@ -39,11 +41,15 @@ pub async fn handle_register(
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
ProfileReader(user_id): ProfileReader,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let deps = get_profile::Deps {
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<UpdateProfileRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<UpdateProfileRequest>,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<ChangePasswordRequest>,
|
||||
SessionUser(user_id): SessionUser,
|
||||
Body(body): Body<ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(user_id);
|
||||
let deps = change_password::Deps {
|
||||
refresh_sessions: state.refresh_session_command.clone(),
|
||||
user_command: state.user_command,
|
||||
user_query: state.user_query,
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_user::Deps {
|
||||
dimensions: state.dimensions.clone(),
|
||||
user_query: state.user_query,
|
||||
entry_query: state.entry_query,
|
||||
cascade: state.cascade,
|
||||
media_storage: state.media_storage,
|
||||
media_ownership: state.media_ownership,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
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" = [])),
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
SessionUser(user_id): SessionUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = clear_data::Deps {
|
||||
dimensions: state.dimensions.clone(),
|
||||
entry_query: state.entry_query,
|
||||
cascade: state.cascade,
|
||||
media_storage: state.media_storage,
|
||||
media_ownership: state.media_ownership,
|
||||
};
|
||||
clear_data::execute(user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
|
||||
@@ -18,6 +18,7 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::handlers::auth::handle_refresh,
|
||||
crate::handlers::auth::handle_logout,
|
||||
crate::handlers::entries::handle_create,
|
||||
crate::handlers::entries::handle_create_many,
|
||||
crate::handlers::entries::handle_get,
|
||||
crate::handlers::entries::handle_list,
|
||||
crate::handlers::entries::handle_update,
|
||||
@@ -47,6 +48,7 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::handlers::reminders::handle_list,
|
||||
crate::handlers::reminders::handle_update,
|
||||
crate::handlers::reminders::handle_delete,
|
||||
crate::handlers::media::handle_list,
|
||||
crate::handlers::media::handle_upload_photo,
|
||||
crate::handlers::media::handle_upload_voice_memo,
|
||||
crate::handlers::media::handle_serve_photo,
|
||||
@@ -58,6 +60,7 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::handlers::data::handle_restore,
|
||||
crate::handlers::import_export::handle_import,
|
||||
crate::handlers::push::handle_vapid_key,
|
||||
crate::handlers::push::handle_list_subscriptions,
|
||||
crate::handlers::push::handle_subscribe,
|
||||
crate::handlers::push::handle_unsubscribe,
|
||||
crate::handlers::push::handle_test,
|
||||
@@ -74,14 +77,19 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::handlers::metrics::handle_import,
|
||||
crate::handlers::metrics::handle_rejections,
|
||||
crate::handlers::metrics::handle_set,
|
||||
crate::handlers::server::handle_info,
|
||||
crate::handlers::jobs::handle_list_exhausted,
|
||||
),
|
||||
components(schemas(
|
||||
api_types::requests::CreateEntryRequest,
|
||||
api_types::requests::CreateEntriesRequest,
|
||||
api_types::responses::EntryPageResponse,
|
||||
api_types::requests::UpdateEntryRequest,
|
||||
api_types::requests::ListEntriesParams,
|
||||
api_types::requests::DateRangeParams,
|
||||
api_types::params::ListEntriesParams,
|
||||
api_types::params::DateRangeParams,
|
||||
api_types::requests::ReplaceActivityRequest,
|
||||
api_types::requests::CreateActivityRequest,
|
||||
api_types::params::ListActivitiesParams,
|
||||
api_types::requests::RenameActivityRequest,
|
||||
api_types::requests::SetCategoryRequest,
|
||||
api_types::requests::RegisterRequest,
|
||||
@@ -98,15 +106,18 @@ use utoipa::{Modify, OpenApi};
|
||||
api_types::responses::ReminderResponse,
|
||||
api_types::responses::DayScheduleResponse,
|
||||
api_types::responses::MoodStatsResponse,
|
||||
api_types::responses::MoodFrequency,
|
||||
api_types::responses::MoodFrequencyResponse,
|
||||
api_types::responses::CalendarDayResponse,
|
||||
api_types::responses::BulkActionResponse,
|
||||
api_types::responses::ImportResultResponse,
|
||||
api_types::responses::RestoreOutcomeResponse,
|
||||
api_types::responses::MediaIdResponse,
|
||||
api_types::responses::OwnedMediaResponse,
|
||||
api_types::responses::ExhaustedJobResponse,
|
||||
api_types::responses::PushSubscriptionResponse,
|
||||
api_types::requests::SetDailyMetricsRequest,
|
||||
api_types::requests::MetricPayload,
|
||||
api_types::requests::DateSpanParams,
|
||||
api_types::params::DateSpanParams,
|
||||
api_types::responses::DailyMetricResponse,
|
||||
api_types::requests::MintApiTokenRequest,
|
||||
api_types::requests::SetPreferencesRequest,
|
||||
@@ -127,6 +138,16 @@ use utoipa::{Modify, OpenApi};
|
||||
api_types::responses::StrategyScoreResponse,
|
||||
api_types::requests::PushSubscribeRequest,
|
||||
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(
|
||||
(name = "auth", description = "Authentication"),
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
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::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
use crate::errors::{INTERNAL_ERROR, TOO_MANY_REQUESTS, refuse};
|
||||
use crate::handlers::{
|
||||
activities, auth, correlations, cycle, data, entries, import_export, media, metrics, providers,
|
||||
push, reminders, tokens, users,
|
||||
activities, auth, correlations, cycle, data, entries, import_export, jobs, media, metrics,
|
||||
providers, push, reminders, server, tokens, users,
|
||||
};
|
||||
use crate::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
@@ -18,11 +23,17 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let cors = build_cors(&state.server_config.cors);
|
||||
let body_limit = DefaultBodyLimit::max(state.server_config.max_body_size);
|
||||
|
||||
Router::new()
|
||||
let answered_here = Router::new()
|
||||
.nest("/api/v1", api_routes())
|
||||
.route("/health", get(health))
|
||||
.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))
|
||||
.layer(body_limit)
|
||||
.layer(cors)
|
||||
@@ -30,6 +41,98 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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> {
|
||||
Json(ApiDoc::openapi())
|
||||
}
|
||||
@@ -72,6 +175,8 @@ fn api_routes() -> Router<AppState> {
|
||||
.nest("/providers", provider_routes())
|
||||
.nest("/push", push_routes())
|
||||
.nest("/data", data_routes())
|
||||
.route("/server", get(server::handle_info))
|
||||
.route("/jobs/exhausted", get(jobs::handle_list_exhausted))
|
||||
}
|
||||
|
||||
fn cycle_routes() -> Router<AppState> {
|
||||
@@ -125,6 +230,7 @@ fn entry_routes() -> Router<AppState> {
|
||||
.patch(entries::handle_update)
|
||||
.delete(entries::handle_delete),
|
||||
)
|
||||
.route("/bulk", post(entries::handle_create_many))
|
||||
.route("/stats", get(entries::handle_stats))
|
||||
.route("/calendar", get(entries::handle_calendar))
|
||||
.route("/filter/mood/{mood}", get(entries::handle_filter_by_mood))
|
||||
@@ -188,6 +294,7 @@ fn reminder_routes() -> Router<AppState> {
|
||||
|
||||
fn media_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(media::handle_list))
|
||||
.route("/photos", post(media::handle_upload_photo))
|
||||
.route(
|
||||
"/photos/{id}",
|
||||
@@ -203,6 +310,7 @@ fn media_routes() -> Router<AppState> {
|
||||
fn push_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/vapid-key", get(push::handle_vapid_key))
|
||||
.route("/subscriptions", get(push::handle_list_subscriptions))
|
||||
.route("/subscribe", post(push::handle_subscribe))
|
||||
.route("/unsubscribe", post(push::handle_unsubscribe))
|
||||
.route("/test", post(push::handle_test))
|
||||
|
||||
@@ -7,12 +7,13 @@ use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, ApiTokenCommandPort, ApiTokenQueryPort,
|
||||
ApiTokenSecretPort, AuthServicePort, BackupReaderPort, BackupWriterPort, CascadeDeletePort,
|
||||
CycleStartCommandPort, CycleStartQueryPort, DailyMetricCommandPort, DailyMetricQueryPort,
|
||||
EntryDimensionPort, EventPublisherPort, ExtractWriterPort, ImportSourcePort, MediaStoragePort,
|
||||
MoodEntryCommandPort, MoodEntryQueryPort, PasswordHasherPort, ProviderConnectionCommandPort,
|
||||
ProviderConnectionQueryPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort,
|
||||
RefreshSessionCommandPort, RefreshSessionQueryPort, RejectionCommandPort, RejectionQueryPort,
|
||||
ReminderCommandPort, ReminderQueryPort, ReminderSenderPort, UserCommandPort,
|
||||
UserPreferencesCommandPort, UserPreferencesQueryPort, UserQueryPort,
|
||||
EntryDimensionPort, EventPublisherPort, ExtractWriterPort, ImportSourcePort,
|
||||
MediaOwnershipPort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
PasswordHasherPort, ProviderConnectionCommandPort, ProviderConnectionQueryPort,
|
||||
PushSubscriptionCommandPort, PushSubscriptionQueryPort, RefreshSessionCommandPort,
|
||||
RefreshSessionQueryPort, RejectionCommandPort, RejectionQueryPort, ReminderCommandPort,
|
||||
ReminderQueryPort, ReminderSenderPort, UserCommandPort, UserPreferencesCommandPort,
|
||||
UserPreferencesQueryPort, UserQueryPort,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -21,6 +22,7 @@ pub struct AppState {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub weather_store: Arc<dyn EntryDimensionPort>,
|
||||
pub activity_store: Arc<dyn EntryDimensionPort>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
@@ -45,6 +47,8 @@ pub struct AppState {
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
pub event_publisher: Arc<dyn EventPublisherPort>,
|
||||
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_reader: Arc<dyn BackupReaderPort>,
|
||||
pub extract_writer: Arc<dyn ExtractWriterPort>,
|
||||
@@ -53,6 +57,7 @@ pub struct AppState {
|
||||
pub provider_connection_query: Arc<dyn ProviderConnectionQueryPort>,
|
||||
pub credential_cipher: Option<Arc<dyn domain::provider::CredentialCipher>>,
|
||||
pub now_playing: Option<Arc<dyn domain::ports::NowPlayingPort>>,
|
||||
pub weather_enabled: bool,
|
||||
pub recording_lookup: Arc<dyn domain::ports::RecordingLookupPort>,
|
||||
pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
|
||||
129
crates/adapters/http-axum/tests/openapi_contract_test.rs
Normal file
129
crates/adapters/http-axum/tests/openapi_contract_test.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
119
crates/adapters/http-axum/tests/rate_limit_test.rs
Normal file
119
crates/adapters/http-axum/tests/rate_limit_test.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
use api_types::dimension::DimensionPayload;
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::errors::DomainError;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use domain::attachment::ContentType;
|
||||
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;
|
||||
|
||||
pub struct KmoodBackupAdapter;
|
||||
@@ -14,6 +20,7 @@ impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
|
||||
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError> {
|
||||
let read = KmoodBackupReader::read(data)?;
|
||||
let manifest = read.manifest;
|
||||
let types = declared_types(&manifest.media);
|
||||
|
||||
Ok(RestorableContents {
|
||||
entries: manifest
|
||||
@@ -63,8 +70,8 @@ impl domain::ports::BackupReaderPort for KmoodBackupAdapter {
|
||||
})
|
||||
.collect(),
|
||||
tracks_cycle: manifest.tracks_cycle,
|
||||
photos: read.photos.into_iter().collect(),
|
||||
voice_memos: read.voice_memos.into_iter().collect(),
|
||||
photos: restorable(read.photos, &types, PHOTO_KIND),
|
||||
voice_memos: restorable(read.voice_memos, &types, VOICE_MEMO_KIND),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,3 +88,30 @@ fn readable_dimensions(payloads: Vec<DimensionPayload>) -> Vec<DimensionValue> {
|
||||
})
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,22 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"012_entry_weather",
|
||||
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";
|
||||
@@ -57,6 +73,13 @@ const SCHEMA_MIGRATIONS_TABLE: &str = "CREATE TABLE IF NOT EXISTS schema_migrati
|
||||
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> {
|
||||
let options: SqliteConnectOptions = database_url
|
||||
.parse::<SqliteConnectOptions>()?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod db;
|
||||
pub mod repositories;
|
||||
|
||||
pub use db::{create_pool, run_migrations};
|
||||
pub use db::{create_pool, migrations_before, run_migrations};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reminders ADD COLUMN last_sent_at TEXT;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -35,14 +35,14 @@ impl domain::ports::ApiTokenCommandPort for SqliteApiTokenCommandRepository {
|
||||
}
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(token.id().value().to_string())
|
||||
.bind(token.user_id().value().to_string())
|
||||
.bind(token.name().value())
|
||||
.bind(token.digest().value())
|
||||
.bind(token.scope().name())
|
||||
.bind(token.scopes().to_persistence())
|
||||
.bind(token.created_at().to_rfc3339())
|
||||
.bind(token.last_used_at().map(|used| used.to_rfc3339()))
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -21,7 +21,7 @@ impl SqliteApiTokenQueryRepository {
|
||||
impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository {
|
||||
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
|
||||
let row: Option<ApiTokenRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, name, digest, scope, created_at, last_used_at
|
||||
"SELECT id, user_id, name, digest, scopes, created_at, last_used_at
|
||||
FROM api_tokens WHERE digest = ?",
|
||||
)
|
||||
.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> {
|
||||
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",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
|
||||
@@ -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::user::UserId;
|
||||
|
||||
@@ -8,7 +8,7 @@ pub struct ApiTokenRow {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub digest: String,
|
||||
pub scope: String,
|
||||
pub scopes: String,
|
||||
pub created_at: 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()?),
|
||||
name: ProviderName::from_persistence(row.name.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()?,
|
||||
last_used_at,
|
||||
}))
|
||||
@@ -36,7 +36,7 @@ pub fn readable(row: &ApiTokenRow) -> Option<ApiToken> {
|
||||
if token.is_none() {
|
||||
tracing::warn!(
|
||||
token_id = %row.id,
|
||||
scope = %row.scope,
|
||||
scopes = %row.scopes,
|
||||
"skipped a stored api token this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@ use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
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 {
|
||||
pool: SqlitePool,
|
||||
@@ -23,29 +33,15 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.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 = ?")
|
||||
for table in EVERY_TABLE_THAT_HOLDS_WHAT_A_USER_LOGGED {
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"DELETE FROM {table} WHERE user_id = ?"
|
||||
)))
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
@@ -85,6 +81,12 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||
.await
|
||||
.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 = ?")
|
||||
.bind(&uid)
|
||||
.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",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.bind(sortable_instant(range.start()))
|
||||
.bind(sortable_instant(range.end()))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.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 <= ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.bind(sortable_instant(range.start()))
|
||||
.bind(sortable_instant(range.end()))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use domain::entry::{DateRange, MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::super::shared::{db_err, sortable_instant};
|
||||
|
||||
pub struct SqliteEntryCommandRepository {
|
||||
pool: SqlitePool,
|
||||
@@ -30,7 +30,7 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
.bind(entry.id().value().to_string())
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(sortable_instant(entry.logged_at()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
@@ -56,7 +56,7 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
.bind(&entry_id)
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.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.updated_at().to_rfc3339())
|
||||
.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 <= ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.bind(sortable_instant(range.start()))
|
||||
.bind(sortable_instant(range.end()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
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::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::super::shared::{db_err, sortable_instant};
|
||||
use super::rows::{EntryRow, hydrate_batch, hydrate_single};
|
||||
|
||||
pub struct SqliteEntryQueryRepository {
|
||||
@@ -33,21 +33,11 @@ impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_by_user(
|
||||
&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);
|
||||
|
||||
async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
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(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -55,6 +45,47 @@ impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
|
||||
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(
|
||||
&self,
|
||||
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",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.bind(sortable_instant(range.start()))
|
||||
.bind(sortable_instant(range.end()))
|
||||
.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
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)?;
|
||||
async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError> {
|
||||
let (count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM entry_activities WHERE activity_id = ?")
|
||||
.bind(activity_id.value().to_string())
|
||||
.fetch_one(&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())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,24 @@ impl domain::ports::RecordingBackfillQueryPort for SqliteRecordingBackfillReposi
|
||||
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(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
|
||||
@@ -47,6 +47,25 @@ impl domain::ports::WeatherBacklogQueryPort for SqliteWeatherBacklogRepository {
|
||||
|
||||
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> {
|
||||
|
||||
3
crates/adapters/sqlite/src/repositories/media/mod.rs
Normal file
3
crates/adapters/sqlite/src/repositories/media/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod ownership;
|
||||
|
||||
pub use ownership::SqliteMediaOwnershipRepository;
|
||||
95
crates/adapters/sqlite/src/repositories/media/ownership.rs
Normal file
95
crates/adapters/sqlite/src/repositories/media/ownership.rs
Normal 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()?,
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod daily_metric;
|
||||
mod dimension;
|
||||
mod entry;
|
||||
mod job;
|
||||
mod media;
|
||||
mod provider_connection;
|
||||
mod push_subscription;
|
||||
mod refresh_session;
|
||||
@@ -29,6 +30,7 @@ pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
|
||||
pub use job::{
|
||||
SqliteJobQueueRepository, SqliteRecordingBackfillRepository, SqliteWeatherBacklogRepository,
|
||||
};
|
||||
pub use media::SqliteMediaOwnershipRepository;
|
||||
pub use provider_connection::{
|
||||
SqliteProviderConnectionCommandRepository, SqliteProviderConnectionQueryRepository,
|
||||
};
|
||||
|
||||
@@ -48,8 +48,13 @@ impl domain::ports::PushSubscriptionCommandPort for SqlitePushSubscriptionComman
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
||||
async fn delete_by_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)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
|
||||
@@ -22,13 +22,14 @@ impl SqliteReminderCommandRepository {
|
||||
impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository {
|
||||
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at, last_sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
monday = excluded.monday, tuesday = excluded.tuesday,
|
||||
wednesday = excluded.wednesday, thursday = excluded.thursday,
|
||||
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.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.is_enabled())
|
||||
.bind(reminder.created_at().to_rfc3339())
|
||||
.bind(reminder.last_sent_at().map(|sent| sent.to_rfc3339()))
|
||||
.execute(&self.pool).await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct ReminderRow {
|
||||
pub sunday: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_sent_at: Option<String>,
|
||||
}
|
||||
|
||||
impl ReminderRow {
|
||||
@@ -36,6 +37,7 @@ impl ReminderRow {
|
||||
schedule,
|
||||
self.enabled,
|
||||
self.created_at.parse().unwrap(),
|
||||
self.last_sent_at.and_then(|sent| sent.parse().ok()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
use chrono::{DateTime, FixedOffset, SecondsFormat, Utc};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub fn db_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("database error: {e}"))
|
||||
}
|
||||
|
||||
pub fn sortable_instant(instant: &DateTime<FixedOffset>) -> String {
|
||||
instant
|
||||
.with_timezone(&Utc)
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, false)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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::provider::ProviderName;
|
||||
use domain::testing::test_user;
|
||||
@@ -33,6 +33,7 @@ fn a_token(owner: &UserId, name: &str, digest: &str) -> ApiToken {
|
||||
owner.clone(),
|
||||
ProviderName::new(name).unwrap(),
|
||||
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;
|
||||
|
||||
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)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
|
||||
@@ -283,3 +283,170 @@ fn remove(path: &str) {
|
||||
let _ = std::fs::remove_file(format!("{path}-wal"));
|
||||
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);
|
||||
}
|
||||
|
||||
142
crates/adapters/sqlite/tests/entry_instant_test.rs
Normal file
142
crates/adapters/sqlite/tests/entry_instant_test.rs
Normal 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());
|
||||
}
|
||||
@@ -140,3 +140,106 @@ async fn a_process_joining_a_migrated_database_applies_nothing() {
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ impl WebPushSender {
|
||||
async fn forget(&self, sub: &PushSubscription) {
|
||||
if let Err(e) = self
|
||||
.subscription_command
|
||||
.delete_by_endpoint(sub.endpoint())
|
||||
.delete_by_endpoint(sub.user_id(), sub.endpoint())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(endpoint = sub.endpoint(), error = %e, "failed to drop push subscription");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod dimension;
|
||||
pub mod errors;
|
||||
pub mod mappers;
|
||||
pub mod params;
|
||||
pub mod requests;
|
||||
pub mod responses;
|
||||
|
||||
@@ -7,7 +7,12 @@ impl From<ApiToken> for ApiTokenResponse {
|
||||
Self {
|
||||
id: token.id().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(),
|
||||
last_used_at: token.last_used_at().copied(),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ impl From<CalendarDay> for CalendarDayResponse {
|
||||
date: day.date.value(),
|
||||
day_mood: day.day_mood.map(|mood| mood.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()),
|
||||
entries: day.entries.into_iter().map(EntryResponse::from).collect(),
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use config::EntryConfig;
|
||||
use domain::activity::ActivityId;
|
||||
use domain::dimension::{ComposedEntry, DimensionValue};
|
||||
use domain::entry::{DateRange, Mood, MoodEntryId};
|
||||
use domain::entry::{DateRange, EntrySelection, Mood, MoodEntryId, Pagination};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::commands::{CreateEntryCommand, UpdateEntryCommand};
|
||||
use application::entry::queries::{
|
||||
FilterByActivityQuery, FilterByMoodQuery, ListEntriesQuery, MoodStatsQuery,
|
||||
};
|
||||
use application::entry::queries::{ListEntriesQuery, MoodStatsQuery};
|
||||
|
||||
use crate::dimension::DimensionPayload;
|
||||
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 super::shared::{parse_datetime, validate_content_length};
|
||||
@@ -45,9 +45,13 @@ impl UpdateEntryRequest {
|
||||
entry_id: MoodEntryId,
|
||||
config: &EntryConfig,
|
||||
) -> 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 dimensions = parse_dimensions(self.dimensions, config)?;
|
||||
|
||||
let dimensions = match self.dimensions {
|
||||
Some(named) => Some(parse_dimensions(Some(named), config)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(UpdateEntryCommand {
|
||||
entry_id,
|
||||
@@ -59,7 +63,11 @@ impl UpdateEntryRequest {
|
||||
}
|
||||
|
||||
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) {
|
||||
(Some(from), Some(to)) => Some(DateRange::new(
|
||||
parse_datetime(&from)?,
|
||||
@@ -67,15 +75,43 @@ impl ListEntriesParams {
|
||||
)?),
|
||||
_ => None,
|
||||
};
|
||||
Ok(ListEntriesQuery {
|
||||
user_id,
|
||||
range,
|
||||
limit: self.limit,
|
||||
offset: self.offset,
|
||||
})
|
||||
|
||||
let activity = self
|
||||
.activity
|
||||
.as_deref()
|
||||
.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 {
|
||||
pub fn into_date_range(self) -> Result<DateRange, ApiValidationError> {
|
||||
let from = parse_datetime(&self.from)?;
|
||||
@@ -87,27 +123,6 @@ impl DateRangeParams {
|
||||
let range = Some(self.into_date_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 {
|
||||
@@ -118,7 +133,7 @@ impl From<ComposedEntry> for EntryResponse {
|
||||
id: entry.id().value(),
|
||||
user_id: entry.user_id().value(),
|
||||
mood: entry.mood().value(),
|
||||
mood_label: format!("{:?}", entry.mood()),
|
||||
mood_label: entry.mood().label().to_string(),
|
||||
logged_at: *entry.logged_at(),
|
||||
photo_urls: composed
|
||||
.photos()
|
||||
|
||||
19
crates/api-types/src/mappers/job.rs
Normal file
19
crates/api-types/src/mappers/job.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
fn from(id: PhotoId) -> Self {
|
||||
@@ -13,3 +13,18 @@ impl From<VoiceMemoId> for MediaIdResponse {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use domain::entry::{Date, DateSpan};
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue};
|
||||
|
||||
use crate::errors::ApiValidationError;
|
||||
use crate::requests::{DateSpanParams, MetricPayload};
|
||||
use crate::params::DateSpanParams;
|
||||
use crate::requests::MetricPayload;
|
||||
use crate::responses::DailyMetricResponse;
|
||||
|
||||
impl From<DailyMetric> for DailyMetricResponse {
|
||||
|
||||
@@ -6,9 +6,11 @@ mod correlation;
|
||||
mod cycle;
|
||||
mod entry;
|
||||
mod import_metrics;
|
||||
mod job;
|
||||
mod media;
|
||||
mod metric;
|
||||
mod provider;
|
||||
mod push;
|
||||
mod reminder;
|
||||
mod restore;
|
||||
pub mod shared;
|
||||
|
||||
13
crates/api-types/src/mappers/push.rs
Normal file
13
crates/api-types/src/mappers/push.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ impl From<RestoreOutcome> for RestoreOutcomeResponse {
|
||||
activities: outcome.activities,
|
||||
reminders: outcome.reminders,
|
||||
media: outcome.media,
|
||||
skipped: outcome.skipped,
|
||||
unreadable: outcome.unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use application::entry::use_cases::get_mood_stats::MoodStats;
|
||||
|
||||
use crate::responses::{MoodFrequency, MoodStatsResponse};
|
||||
use crate::responses::{MoodFrequencyResponse, MoodStatsResponse};
|
||||
|
||||
impl From<MoodStats> for MoodStatsResponse {
|
||||
fn from(stats: MoodStats) -> Self {
|
||||
@@ -9,14 +9,16 @@ impl From<MoodStats> for MoodStatsResponse {
|
||||
frequency: stats
|
||||
.frequency
|
||||
.into_iter()
|
||||
.map(|(mood, count)| MoodFrequency {
|
||||
.map(|(mood, count)| MoodFrequencyResponse {
|
||||
mood: mood.value(),
|
||||
mood_label: format!("{mood:?}"),
|
||||
mood_label: mood.label().to_string(),
|
||||
count,
|
||||
})
|
||||
.collect(),
|
||||
current_streak: stats.current_streak,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
crates/api-types/src/params/activity.rs
Normal file
5
crates/api-types/src/params/activity.rs
Normal 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>,
|
||||
}
|
||||
18
crates/api-types/src/params/entry.rs
Normal file
18
crates/api-types/src/params/entry.rs
Normal 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,
|
||||
}
|
||||
6
crates/api-types/src/params/metric.rs
Normal file
6
crates/api-types/src/params/metric.rs
Normal 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,
|
||||
}
|
||||
7
crates/api-types/src/params/mod.rs
Normal file
7
crates/api-types/src/params/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod activity;
|
||||
mod entry;
|
||||
mod metric;
|
||||
|
||||
pub use activity::ListActivitiesParams;
|
||||
pub use entry::{DateRangeParams, ListEntriesParams};
|
||||
pub use metric::DateSpanParams;
|
||||
@@ -2,4 +2,5 @@
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MintApiTokenRequest {
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
use crate::dimension::DimensionPayload;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateEntryRequest {
|
||||
pub mood: u8,
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateEntryRequest {
|
||||
pub mood: u8,
|
||||
pub mood: Option<u8>,
|
||||
pub logged_at: Option<String>,
|
||||
pub dimensions: Option<Vec<crate::dimension::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,
|
||||
pub dimensions: Option<Vec<DimensionPayload>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -10,10 +10,3 @@ pub struct MetricPayload {
|
||||
pub kind: String,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@ pub use activity::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequ
|
||||
pub use api_token::MintApiTokenRequest;
|
||||
pub use cycle::SetPreferencesRequest;
|
||||
pub use entry::{
|
||||
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
|
||||
UpdateEntryRequest,
|
||||
CreateEntriesRequest, CreateEntryRequest, ReplaceActivityRequest, UpdateEntryRequest,
|
||||
};
|
||||
pub use import::{ImportDailyMetricsRequest, ImportedDayPayload, ImportedMetricPayload};
|
||||
pub use metric::{DateSpanParams, MetricPayload, SetDailyMetricsRequest};
|
||||
pub use metric::{MetricPayload, SetDailyMetricsRequest};
|
||||
pub use provider::ConnectProviderRequest;
|
||||
pub use push::{PushSubscribeRequest, PushUnsubscribeRequest};
|
||||
pub use reminder::{CreateReminderRequest, UpdateReminderRequest};
|
||||
|
||||
@@ -25,8 +25,9 @@ pub struct PushUnsubscribeRequest {
|
||||
}
|
||||
|
||||
impl PushUnsubscribeRequest {
|
||||
pub fn into_command(self) -> UnsubscribePushCommand {
|
||||
pub fn into_command(self, user_id: UserId) -> UnsubscribePushCommand {
|
||||
UnsubscribePushCommand {
|
||||
user_id,
|
||||
endpoint: self.endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
|
||||
pub struct ApiTokenResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
26
crates/api-types/src/responses/auth.rs
Normal file
26
crates/api-types/src/responses/auth.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
5
crates/api-types/src/responses/bulk_action.rs
Normal file
5
crates/api-types/src/responses/bulk_action.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BulkActionResponse {
|
||||
pub affected_count: u64,
|
||||
}
|
||||
23
crates/api-types/src/responses/error.rs
Normal file
23
crates/api-types/src/responses/error.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/api-types/src/responses/import_result.rs
Normal file
7
crates/api-types/src/responses/import_result.rs
Normal 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>,
|
||||
}
|
||||
13
crates/api-types/src/responses/job.rs
Normal file
13
crates/api-types/src/responses/job.rs
Normal 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>,
|
||||
}
|
||||
13
crates/api-types/src/responses/media.rs
Normal file
13
crates/api-types/src/responses/media.rs
Normal 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,
|
||||
}
|
||||
@@ -1,31 +1,49 @@
|
||||
mod activity;
|
||||
mod api_token;
|
||||
mod bulk;
|
||||
mod auth;
|
||||
mod bulk_action;
|
||||
mod calendar;
|
||||
mod correlation;
|
||||
mod cycle;
|
||||
mod entry;
|
||||
mod error;
|
||||
mod import;
|
||||
mod import_result;
|
||||
mod job;
|
||||
mod media;
|
||||
mod metric;
|
||||
mod page;
|
||||
mod provider;
|
||||
mod push;
|
||||
mod reminder;
|
||||
mod restore;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod user;
|
||||
|
||||
pub use activity::ActivityResponse;
|
||||
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 correlation::{
|
||||
AgreementResponse, CorrelationInputResponse, CorrelationRowResponse, StrategyScoreResponse,
|
||||
};
|
||||
pub use cycle::{CyclePositionResponse, CycleViewResponse, PreferencesResponse};
|
||||
pub use entry::EntryResponse;
|
||||
pub use error::{ErrorDetailResponse, ErrorResponse};
|
||||
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 page::EntryPageResponse;
|
||||
pub use provider::ProviderConnectionResponse;
|
||||
pub use push::PushSubscriptionResponse;
|
||||
pub use reminder::{DayScheduleResponse, ReminderResponse};
|
||||
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;
|
||||
|
||||
11
crates/api-types/src/responses/page.rs
Normal file
11
crates/api-types/src/responses/page.rs
Normal 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,
|
||||
}
|
||||
9
crates/api-types/src/responses/push.rs
Normal file
9
crates/api-types/src/responses/push.rs
Normal 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>,
|
||||
}
|
||||
@@ -7,5 +7,6 @@ pub struct RestoreOutcomeResponse {
|
||||
pub activities: usize,
|
||||
pub reminders: usize,
|
||||
pub media: usize,
|
||||
pub skipped: usize,
|
||||
pub unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
50
crates/api-types/src/responses/server.rs
Normal file
50
crates/api-types/src/responses/server.rs
Normal 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,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MoodFrequency {
|
||||
pub struct MoodFrequencyResponse {
|
||||
pub mood: u8,
|
||||
pub mood_label: String,
|
||||
pub count: usize,
|
||||
@@ -10,7 +10,9 @@ pub struct MoodFrequency {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MoodStatsResponse {
|
||||
pub average: Option<f64>,
|
||||
pub frequency: Vec<MoodFrequency>,
|
||||
pub frequency: Vec<MoodFrequencyResponse>,
|
||||
pub current_streak: usize,
|
||||
pub total_entries: usize,
|
||||
pub first_logged_on: Option<String>,
|
||||
pub last_logged_on: Option<String>,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort};
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort, MoodEntryQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
@@ -11,6 +11,7 @@ use crate::errors::ApplicationError;
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
pub entries: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -26,8 +27,24 @@ pub async fn execute(
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
refuse_while_entries_still_wear_it(&activity_id, deps).await?;
|
||||
|
||||
deps.command.delete(&activity_id).await?;
|
||||
|
||||
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
Reference in New Issue
Block a user