10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
target/
|
||||||
|
data/
|
||||||
|
.git/
|
||||||
|
.DS_Store
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
config.toml
|
||||||
|
spa/node_modules/
|
||||||
|
spa/dist/
|
||||||
43
.gitea/workflows/ci.yml
Normal file
43
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd spa && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cd spa && bun run check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
- name: TypeScript
|
||||||
|
run: cd spa && bun run typecheck
|
||||||
77
.github/workflows/ci.yml
vendored
Normal file
77
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd spa && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cd spa && bun run check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
- name: TypeScript
|
||||||
|
run: cd spa && bun run typecheck
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: ci
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/target
|
||||||
|
/data
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
config.toml
|
||||||
|
.DS_Store
|
||||||
|
spa/node_modules/
|
||||||
|
spa/dist/
|
||||||
322
CODE_STYLE.md
Normal file
322
CODE_STYLE.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Code Style Guide
|
||||||
|
|
||||||
|
This document defines the coding standards for this project. All contributors (human and agent) must follow these rules. When in doubt, prefer clarity and correctness over cleverness and speed.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
- Code reads like prose
|
||||||
|
- Do things properly — no shortcuts
|
||||||
|
- SOLID principles — always, no exceptions
|
||||||
|
- KISS — simplest solution that solves the problem **correctly and properly**. KISS means no unnecessary complexity, NOT skipping proper architecture. If the design calls for a trait, a newtype, or a pattern — that IS the simple solution. Cutting corners is not simplicity, it's technical debt. When in doubt, follow the architecture (CONTEXT.md, ADRs) over perceived simplicity.
|
||||||
|
- DRY — one source of truth for every piece of knowledge
|
||||||
|
- Use design patterns where they fit (Strategy, Observer, Builder, etc.) — name them in code so intent is clear
|
||||||
|
- Single responsibility everywhere: files, functions, types, modules
|
||||||
|
- Single point of entry, single control flow — no parallel data/control paths
|
||||||
|
- Open/Closed — extend via new types, don't modify existing ones
|
||||||
|
- Depend on abstractions (traits), not concretions
|
||||||
|
- Keep things as private as possible
|
||||||
|
- Zero magic numbers, strings, or unnamed constants
|
||||||
|
- Newtypes for self-documenting code
|
||||||
|
- Descriptive variable names — `track_duration`, not `td`. `replication_policy`, not `rp`. `content_hash`, not `ch`. No abbreviations unless universally understood (e.g., `id`, `url`, `i` for a trivial loop index).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Module Organization
|
||||||
|
|
||||||
|
Deep module hierarchy, grouped by concern. No flat file dumps. Each file is its own small, enclosed thing.
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/domain/src/
|
||||||
|
├── track/
|
||||||
|
│ ├── track.rs
|
||||||
|
│ ├── track_id.rs
|
||||||
|
│ ├── duration.rs
|
||||||
|
│ ├── content_hash.rs
|
||||||
|
│ └── audio_format.rs
|
||||||
|
├── album/
|
||||||
|
│ ├── album.rs
|
||||||
|
│ ├── album_id.rs
|
||||||
|
│ └── track_listing.rs
|
||||||
|
├── artist/
|
||||||
|
│ ├── artist.rs
|
||||||
|
│ ├── artist_id.rs
|
||||||
|
│ └── artist_profile.rs
|
||||||
|
├── federation/
|
||||||
|
│ ├── activity.rs
|
||||||
|
│ ├── remote_instance.rs
|
||||||
|
│ └── follow.rs
|
||||||
|
├── streaming/
|
||||||
|
│ ├── playback_session.rs
|
||||||
|
│ ├── replication_policy.rs
|
||||||
|
│ └── content_availability.rs
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Size
|
||||||
|
|
||||||
|
Small files with a single concern. If a file grows past ~100-150 lines, it's probably doing too much — split it.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
**Never** in the same file as production code. Always in a sibling `tests/` directory mirroring the module structure.
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/domain/src/track/track.rs
|
||||||
|
crates/domain/tests/track/track_test.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- Files and modules: `snake_case`
|
||||||
|
- Types: `PascalCase` with **semantic suffixes** for clarity and navigation
|
||||||
|
- Functions and methods: `snake_case`, verb-first, descriptive
|
||||||
|
|
||||||
|
### Type Suffixes
|
||||||
|
|
||||||
|
| Suffix | Usage | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| (none) | Value objects, entities | `Track`, `Album`, `Artist` |
|
||||||
|
| `Id` | Identity newtypes | `TrackId`, `AlbumId`, `ArtistId` |
|
||||||
|
| `Config` | Configuration data | `ReplicationConfig`, `StorageConfig` |
|
||||||
|
| `Service` | Domain services | `ContentDiscoveryService` |
|
||||||
|
| `Port` | Application port traits | `TrackRepositoryPort`, `P2pTransportPort` |
|
||||||
|
| `Adapter` | Port implementations | `IrohTransportAdapter`, `SqliteTrackAdapter` |
|
||||||
|
| `Error` | Error types | `FederationError`, `StreamingError` |
|
||||||
|
| `UseCase` | Application use cases | `UploadTrackUseCase`, `StreamTrackUseCase` |
|
||||||
|
| `Strategy` | Strategy trait/impls | `ReplicationStrategy`, `EagerReplicationStrategy` |
|
||||||
|
|
||||||
|
### Newtypes
|
||||||
|
|
||||||
|
Use newtypes everywhere a primitive carries domain meaning:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES
|
||||||
|
struct TrackId(Uuid);
|
||||||
|
struct AlbumId(Uuid);
|
||||||
|
struct ArtistId(Uuid);
|
||||||
|
struct ContentHash(Blake3Hash);
|
||||||
|
struct Duration(u64); // milliseconds
|
||||||
|
struct ByteRange(u64, u64);
|
||||||
|
struct InstanceUrl(Url);
|
||||||
|
|
||||||
|
// NO
|
||||||
|
type TrackId = String;
|
||||||
|
fn stream_track(id: &str, start: u64, end: u64) -> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Newtypes are self-documenting and prevent accidental misuse (can't pass a `TrackId` where an `AlbumId` is expected).
|
||||||
|
|
||||||
|
## Functions and Methods
|
||||||
|
|
||||||
|
### Single Responsibility
|
||||||
|
|
||||||
|
Each function does ONE thing. Compose small functions into larger behaviors.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES
|
||||||
|
fn resolve_content_sources(...) -> Vec<ContentSource> { ... }
|
||||||
|
fn select_best_source(sources: &[ContentSource], ...) -> ContentSource { ... }
|
||||||
|
fn initiate_stream(source: &ContentSource, range: &ByteRange) -> StreamHandle { ... }
|
||||||
|
|
||||||
|
// NO
|
||||||
|
fn stream_track(...) -> StreamHandle {
|
||||||
|
// 80 lines doing source resolution, selection, and stream initiation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
Default to private. Only `pub` what the module's consumers actually need. Use `pub(crate)` for intra-crate sharing that shouldn't leak outside.
|
||||||
|
|
||||||
|
### impl Blocks
|
||||||
|
|
||||||
|
Split by concern:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Construction
|
||||||
|
impl Track {
|
||||||
|
pub fn new(title: TrackTitle, artist: ArtistId, duration: Duration, hash: ContentHash) -> Self { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
impl Track {
|
||||||
|
pub fn is_available_locally(&self) -> bool { ... }
|
||||||
|
pub fn content_hash(&self) -> &ContentHash { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
impl Track {
|
||||||
|
pub fn update_metadata(&mut self, metadata: TrackMetadata) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Domain and Application: `thiserror`
|
||||||
|
|
||||||
|
Typed, matchable error enums. Every error variant is meaningful.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FederationError {
|
||||||
|
#[error("instance {0} is unreachable")]
|
||||||
|
InstanceUnreachable(InstanceUrl),
|
||||||
|
#[error("activity rejected by remote instance: {0}")]
|
||||||
|
ActivityRejected(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adapters and Bootstrap: `anyhow`
|
||||||
|
|
||||||
|
Pragmatic error handling. Context strings for debugging.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fetch_track_blob(hash: &ContentHash) -> anyhow::Result<Bytes> {
|
||||||
|
let data = iroh_client.get(hash)
|
||||||
|
.context("failed to fetch track blob from P2P network")?;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Never `unwrap` or `expect` in production code
|
||||||
|
|
||||||
|
Errors propagate with `?` and bubble up to a single handler. No scattered panic points.
|
||||||
|
|
||||||
|
`unwrap` is allowed **only** in test code.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Secrets (API keys, JWT secrets) live in environment variables. Tunable settings (replication policy, storage limits, federation parameters) live in a TOML config file.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES — in config
|
||||||
|
[replication]
|
||||||
|
policy = "eager"
|
||||||
|
max_storage_bytes = 10_737_418_240
|
||||||
|
|
||||||
|
// NO — magic numbers in code
|
||||||
|
if storage.used_bytes() > 10_737_418_240 { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Zero magic numbers or strings. Everything named, everything explained by its name.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES
|
||||||
|
const MAX_TRACK_TITLE_LENGTH: usize = 256;
|
||||||
|
const DEFAULT_BYTE_RANGE_CHUNK: u64 = 1_048_576;
|
||||||
|
|
||||||
|
// NO
|
||||||
|
if title.len() > 256 { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Constants are only for true invariants that never change. Tunable values belong in config.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Zero comments unless explaining **why** something non-obvious is done.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES — explains a non-obvious constraint
|
||||||
|
// BLAKE3 hash, not SHA-256, because iroh uses BLAKE3 for content addressing
|
||||||
|
let hash = blake3::hash(&audio_data);
|
||||||
|
|
||||||
|
// NO — restates what the code does
|
||||||
|
// Create a new track with the given metadata
|
||||||
|
let track = Track::new(title, artist_id, duration, hash);
|
||||||
|
|
||||||
|
// NO — references the task/ticket
|
||||||
|
// Added for thesis requirement §3.2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Use good crates when they exist — don't reinvent
|
||||||
|
- Prefer crates with minimal transitive dependencies
|
||||||
|
- Aim for small memory footprint and executable size
|
||||||
|
- Domain crate: zero external dependencies (except `thiserror` and `serde` for derive)
|
||||||
|
- Application crate: depends only on domain
|
||||||
|
- Adapters: free to pull in platform crates (`axum`, `sqlx`, `iroh`, `nats`, etc.)
|
||||||
|
- Bootstrap: wiring only
|
||||||
|
|
||||||
|
## Generics Over Trait Objects
|
||||||
|
|
||||||
|
Use generics (static dispatch) for ports, not `dyn Trait`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES — zero-cost, monomorphized
|
||||||
|
pub struct StreamTrackUseCase<T: P2pTransportPort, R: TrackRepositoryPort> {
|
||||||
|
transport: T,
|
||||||
|
repository: R,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO — heap allocation, dynamic dispatch overhead
|
||||||
|
pub struct StreamTrackUseCase {
|
||||||
|
transport: Box<dyn P2pTransportPort>,
|
||||||
|
repository: Box<dyn TrackRepositoryPort>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Coverage Targets
|
||||||
|
|
||||||
|
| Crate | Approach | Target |
|
||||||
|
|---|---|---|
|
||||||
|
| Domain | TDD (red-green-refactor) | 100% |
|
||||||
|
| Application | TDD | High coverage |
|
||||||
|
| Adapters | Integration tests where practical | Best effort |
|
||||||
|
| Bootstrap | No tests | — |
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
Tests live in a separate `tests/` directory, never inline:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/domain/
|
||||||
|
├── src/
|
||||||
|
│ └── track/
|
||||||
|
│ └── track.rs
|
||||||
|
└── tests/
|
||||||
|
└── track/
|
||||||
|
└── track_test.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Naming
|
||||||
|
|
||||||
|
Tests read as specifications:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn uploading_track_stores_content_hash() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eager_replication_forwards_to_all_followers() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_range_request_returns_correct_audio_slice() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Flow
|
||||||
|
|
||||||
|
### Single Point of Entry
|
||||||
|
|
||||||
|
One path through the code. No parallel flows that do similar things in different places.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// YES — one function handles content resolution
|
||||||
|
let source = content_resolver.resolve(track_id, requester);
|
||||||
|
|
||||||
|
// NO — content resolution duplicated in two places
|
||||||
|
// stream_handler.rs: resolves content source
|
||||||
|
// federation_handler.rs: also resolves content source with slightly different logic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Call, Cascading Changes
|
||||||
|
|
||||||
|
When something changes, it should cascade from one point. Don't require updating multiple files for a single logical change.
|
||||||
|
|
||||||
|
### No God Objects
|
||||||
|
|
||||||
|
If a struct has more than 5-6 fields, question whether it's doing too much. If a function takes more than 4-5 parameters, consider grouping them into a dedicated type.
|
||||||
39
CONTEXT.md
Normal file
39
CONTEXT.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# k-mood
|
||||||
|
|
||||||
|
A personal mood tracking journal. Users log how they feel throughout the day, tag entries with activities, and review trends over time. Multi-user, self-hosted, single server with multiple clients.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
**MoodEntry**:
|
||||||
|
A single mood record — the aggregate root. Every MoodEntry has exactly one Mood and belongs to exactly one User. May optionally include Activities, Content, photos, and voice memos. Multiple MoodEntries per day are allowed.
|
||||||
|
_Avoid_: Log, journal entry, record, mood log
|
||||||
|
|
||||||
|
**Mood**:
|
||||||
|
One of five discrete states representing how the user feels, mapped to a 1–5 ordinal scale: Awful (1), Bad (2), Meh (3), Good (4), Rad (5). The ordering is a domain truth — Rad is better than Good. Required on every MoodEntry.
|
||||||
|
_Avoid_: Feeling, emotion, state, score
|
||||||
|
|
||||||
|
**Content**:
|
||||||
|
Optional markdown text attached to a MoodEntry. Serves both quick annotations ("went for a walk") and longer journal-style writing. No domain-level length limit — the application layer enforces configurable maximums.
|
||||||
|
_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.
|
||||||
|
_Avoid_: Tag, label, habit, tracker
|
||||||
|
|
||||||
|
**Category**:
|
||||||
|
A display-only grouping label stored as an optional string on an Activity (e.g., "social", "health", "sleep"). The domain stores it but never interprets it — no domain logic references categories. Exists purely for UI organization across multiple clients.
|
||||||
|
_Avoid_: Group, section, type
|
||||||
|
|
||||||
|
### People
|
||||||
|
|
||||||
|
**User**:
|
||||||
|
A registered account identified by username and email. Has a role (Admin or User) and an optional timezone for analytics display. Owns their own Activity catalog, MoodEntries, and Reminders.
|
||||||
|
_Avoid_: Account, member, profile
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
|
||||||
|
**Reminder**:
|
||||||
|
A per-user notification schedule. Each Reminder defines an `Option<Time>` for each day of the week — `Some(20:00)` means remind at 8 PM, `None` means skip that day. Can be enabled or disabled. A User can have multiple Reminders (e.g., one for morning check-in, one for evening). The domain defines when to remind; clients decide the message and delivery mechanism.
|
||||||
|
_Avoid_: Notification, alert, alarm, push
|
||||||
4570
Cargo.lock
generated
Normal file
4570
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
Normal file
66
Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/domain",
|
||||||
|
"crates/application",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/config",
|
||||||
|
"crates/adapters/http-axum",
|
||||||
|
"crates/adapters/sqlite",
|
||||||
|
"crates/adapters/auth",
|
||||||
|
"crates/adapters/storage",
|
||||||
|
"crates/adapters/event-publisher",
|
||||||
|
"crates/adapters/importer",
|
||||||
|
"crates/adapters/exporter",
|
||||||
|
"crates/adapters/web-push",
|
||||||
|
"crates/server",
|
||||||
|
]
|
||||||
|
default-members = ["crates/server"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
api-types = { path = "crates/api-types" }
|
||||||
|
config = { path = "crates/config" }
|
||||||
|
http-axum = { path = "crates/adapters/http-axum" }
|
||||||
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
auth = { path = "crates/adapters/auth" }
|
||||||
|
storage = { path = "crates/adapters/storage" }
|
||||||
|
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||||
|
importer = { path = "crates/adapters/importer" }
|
||||||
|
exporter = { path = "crates/adapters/exporter" }
|
||||||
|
web-push-adapter = { path = "crates/adapters/web-push" }
|
||||||
|
csv = "1"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
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-http = { version = "0.7", features = ["cors", "trace", "fs"] }
|
||||||
|
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
|
||||||
|
email_address = "0.2"
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
jsonwebtoken = { version = "11", features = ["aws_lc_rs"] }
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
object_store = { version = "0.14", features = ["aws"] }
|
||||||
|
bytes = "1"
|
||||||
|
base64 = "0.23"
|
||||||
|
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
||||||
|
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = 3
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM oven/bun:1 AS frontend
|
||||||
|
WORKDIR /app/spa
|
||||||
|
COPY spa/package.json spa/bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY spa/ .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM rust:1-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/ ./crates/
|
||||||
|
|
||||||
|
RUN cargo build --release --bin k-mood
|
||||||
|
|
||||||
|
FROM alpine:3
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=builder /app/target/release/k-mood /usr/local/bin/k-mood
|
||||||
|
COPY --from=frontend /app/spa/dist /spa/dist
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["k-mood"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gabriel Kaszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
43
Makefile
Normal file
43
Makefile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.PHONY: build dev check check-all test fmt run clean fix spa
|
||||||
|
|
||||||
|
build: spa
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
dev:
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
|
||||||
|
spa:
|
||||||
|
cd spa && bun install && bun run build
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
cargo test
|
||||||
|
cd spa && bun run check && bun run typecheck
|
||||||
|
|
||||||
|
check-all:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cd spa && bun run check && bun run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
cd spa && bun run format
|
||||||
|
|
||||||
|
run: spa
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
fix:
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --fix --allow-dirty --allow-staged
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
rm -rf spa/dist
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -t k-mood .
|
||||||
125
README.md
Normal file
125
README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# K-Mood
|
||||||
|
|
||||||
|
Self-hosted mood tracking journal. Log your mood, activities, photos, and voice memos. Track trends, streaks, and correlations over time.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Mood tracking** with 5 discrete states (Awful, Bad, Meh, Good, Rad)
|
||||||
|
- **Activities** with custom categories
|
||||||
|
- **Rich entries** with markdown notes, photos, and voice memos
|
||||||
|
- **Analytics** including mood trends, streaks, distribution, activity correlations, and calendar heatmap
|
||||||
|
- **Import/Export** with Daylio CSV preset, generic CSV wizard, and full ZIP backup
|
||||||
|
- **Multi-user** with JWT authentication and per-user data isolation
|
||||||
|
- **PWA** installable on mobile and desktop
|
||||||
|
- **Self-hosted** with SQLite and local or S3 media storage
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v k-mood-data:/data \
|
||||||
|
-e KMOOD_AUTH__JWT_SECRET=your-secret-here \
|
||||||
|
ghcr.io/gabrielkaszewski/k-mood:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000`, register an account, and start logging.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
k-mood:
|
||||||
|
image: ghcr.io/gabrielkaszewski/k-mood:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- k-mood-data:/data
|
||||||
|
environment:
|
||||||
|
- KMOOD_AUTH__JWT_SECRET=your-secret-here
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
k-mood-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
Requires Rust 1.85+ and Bun.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build frontend
|
||||||
|
cd spa && bun install && bun run build && cd ..
|
||||||
|
|
||||||
|
# Build and run
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `config.example.toml` to `config.toml` and adjust as needed.
|
||||||
|
|
||||||
|
| Section | Key | Default | Description |
|
||||||
|
|---------|-----|---------|-------------|
|
||||||
|
| `server` | `host` | `0.0.0.0` | Bind address |
|
||||||
|
| `server` | `port` | `3000` | HTTP port |
|
||||||
|
| `server.cors` | `allow_any_origin` | `true` | CORS policy |
|
||||||
|
| `auth` | `jwt_secret` | `change-me-in-production` | JWT signing key |
|
||||||
|
| `auth` | `allow_registration` | `true` | Enable new user registration |
|
||||||
|
| `storage` | `data_dir` | `./data` | SQLite and media storage path |
|
||||||
|
| `storage.media` | `backend` | `local` | `local` or `s3` |
|
||||||
|
|
||||||
|
## Push Notifications
|
||||||
|
|
||||||
|
K-Mood supports Web Push notifications (works on iOS 16.4+ when added to Home Screen, Android, and desktop browsers). No Firebase or third-party service required.
|
||||||
|
|
||||||
|
**1. Generate VAPID keys:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl ecparam -genkey -name prime256v1 -noout -out vapid_private.pem
|
||||||
|
openssl ec -in vapid_private.pem -outform PEM 2>/dev/null | base64
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add to `config.toml`:**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[push]
|
||||||
|
enabled = true
|
||||||
|
vapid_private_key = "<base64 output from step 1>"
|
||||||
|
vapid_subject = "mailto:you@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Enable in the app:** Go to Settings and tap "Enable" under Notifications. Use "Send test notification" to verify it works.
|
||||||
|
|
||||||
|
The server checks reminders every 60 seconds and sends push notifications to all subscribed devices for users with due reminders. Users must set a timezone in their profile for reminders to fire.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Rust workspace with DDD and hexagonal architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
domain/ Pure domain logic, entities, value objects, ports
|
||||||
|
application/ Use cases as free-standing functions
|
||||||
|
api-types/ Request/response DTOs and Zod-like validation
|
||||||
|
config/ Configuration types and defaults
|
||||||
|
adapters/
|
||||||
|
http-axum/ REST API (axum) + SPA serving
|
||||||
|
sqlite/ SQLite persistence (sqlx)
|
||||||
|
auth/ JWT + Argon2 authentication
|
||||||
|
storage/ Media storage (local filesystem / S3)
|
||||||
|
event-publisher/ Domain event bus (tokio mpsc)
|
||||||
|
importer/ Daylio CSV + generic import parsing
|
||||||
|
exporter/ ZIP export with media
|
||||||
|
server/ Composition root, startup, graceful shutdown
|
||||||
|
spa/ React 19 SPA (TanStack Router, shadcn/ui, Tailwind v4)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Interactive API docs are available at `/docs` (Scalar UI) when the server is running. The OpenAPI spec is at `/openapi.json`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
166
architecture.mmd
Normal file
166
architecture.mmd
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
graph TD
|
||||||
|
subgraph Clients
|
||||||
|
SPA[React SPA]
|
||||||
|
Mobile[Mobile App]
|
||||||
|
Desktop[Desktop App]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Server["server (composition root)"]
|
||||||
|
Main[main.rs]
|
||||||
|
Factory[factory.rs]
|
||||||
|
Scheduler[Reminder Scheduler<br/>tokio interval 60s]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Adapters
|
||||||
|
subgraph Driving["Driving (Primary)"]
|
||||||
|
HTTP[http-axum<br/>REST API + SPA serving]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Driven["Driven (Secondary)"]
|
||||||
|
SQLite[sqlite<br/>Persistence]
|
||||||
|
Auth[auth<br/>JWT + Argon2]
|
||||||
|
Storage[storage<br/>Local FS / S3]
|
||||||
|
WebPush[web-push<br/>VAPID + Web Push API]
|
||||||
|
EventPub[event-publisher<br/>tokio mpsc]
|
||||||
|
Importer[importer<br/>Daylio CSV / Generic]
|
||||||
|
Exporter[exporter<br/>ZIP + JSON]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Application["application (use cases)"]
|
||||||
|
EntryUC[Entry Use Cases<br/>create / update / delete / list<br/>filter / stats / calendar<br/>bulk delete / replace activity]
|
||||||
|
ActivityUC[Activity Use Cases<br/>CRUD / archive / rename<br/>set category]
|
||||||
|
UserUC[User Use Cases<br/>register / login / logout<br/>refresh / change password<br/>update profile / delete / clear data]
|
||||||
|
MediaUC[Media Use Cases<br/>upload / delete<br/>photo / voice memo]
|
||||||
|
PushUC[Push Use Cases<br/>subscribe / unsubscribe]
|
||||||
|
ImportUC[Import / Export<br/>import entries<br/>export user data]
|
||||||
|
ReminderUC[Reminder Use Cases<br/>CRUD / process due]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Domain["domain (pure business logic)"]
|
||||||
|
subgraph Entities
|
||||||
|
MoodEntry[MoodEntry<br/>aggregate root]
|
||||||
|
Activity[Activity]
|
||||||
|
User[User]
|
||||||
|
Reminder[Reminder]
|
||||||
|
PushSubscription[PushSubscription]
|
||||||
|
RefreshSession[RefreshSession]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ValueObjects["Value Objects"]
|
||||||
|
Mood[Mood<br/>1-5 scale]
|
||||||
|
Content[Content]
|
||||||
|
DateRange[DateRange]
|
||||||
|
ActivityName[ActivityName]
|
||||||
|
CategoryName[CategoryName]
|
||||||
|
DisplayName[DisplayName]
|
||||||
|
Timezone[Timezone]
|
||||||
|
ContentType[ContentType]
|
||||||
|
MediaUpload[MediaUpload]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Ports["Ports (async traits)"]
|
||||||
|
EntryPorts[MoodEntryCommandPort<br/>MoodEntryQueryPort]
|
||||||
|
ActivityPorts[ActivityCommandPort<br/>ActivityQueryPort]
|
||||||
|
UserPorts[UserCommandPort<br/>UserQueryPort]
|
||||||
|
ReminderPorts[ReminderCommandPort<br/>ReminderQueryPort<br/>ReminderSenderPort]
|
||||||
|
PushPorts[PushSubscriptionCommandPort<br/>PushSubscriptionQueryPort]
|
||||||
|
CascadePorts[CascadeDeletePort]
|
||||||
|
AuthPorts[AuthServicePort<br/>PasswordHasherPort<br/>RefreshSessionCommandPort<br/>RefreshSessionQueryPort]
|
||||||
|
MediaPort[MediaStoragePort]
|
||||||
|
EventPort[EventPublisherPort]
|
||||||
|
ImportExportPorts[ImportSourcePort<br/>ExportPort]
|
||||||
|
end
|
||||||
|
|
||||||
|
Services[MoodAnalyzerService<br/>average / frequency<br/>streak / correlation]
|
||||||
|
Events[Domain Events<br/>EntryCreated / Updated / Deleted<br/>ActivityCreated / Archived<br/>UserRegistered]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Config["config"]
|
||||||
|
ServerCfg[ServerConfig]
|
||||||
|
AuthCfg[AuthConfig]
|
||||||
|
PushCfg[PushConfig]
|
||||||
|
StorageCfg[StorageConfig]
|
||||||
|
EntryCfg[EntryConfig]
|
||||||
|
PresetCfg[PresetConfig]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ApiTypes["api-types"]
|
||||||
|
Requests[Request DTOs]
|
||||||
|
Responses[Response DTOs]
|
||||||
|
end
|
||||||
|
|
||||||
|
SPA -->|HTTP| HTTP
|
||||||
|
Mobile -->|HTTP| HTTP
|
||||||
|
Desktop -->|HTTP| HTTP
|
||||||
|
|
||||||
|
Main --> Factory
|
||||||
|
Main --> Scheduler
|
||||||
|
Factory --> HTTP
|
||||||
|
Factory --> SQLite
|
||||||
|
Factory --> Auth
|
||||||
|
Factory --> Storage
|
||||||
|
Factory --> WebPush
|
||||||
|
Factory --> EventPub
|
||||||
|
Factory --> Importer
|
||||||
|
Factory --> Exporter
|
||||||
|
|
||||||
|
Scheduler --> ReminderUC
|
||||||
|
|
||||||
|
HTTP --> EntryUC
|
||||||
|
HTTP --> ActivityUC
|
||||||
|
HTTP --> UserUC
|
||||||
|
HTTP --> MediaUC
|
||||||
|
HTTP --> PushUC
|
||||||
|
HTTP --> ImportUC
|
||||||
|
HTTP --> ReminderUC
|
||||||
|
HTTP --> ApiTypes
|
||||||
|
|
||||||
|
EntryUC --> EntryPorts
|
||||||
|
EntryUC --> MediaPort
|
||||||
|
EntryUC --> CascadePorts
|
||||||
|
EntryUC --> EventPort
|
||||||
|
ActivityUC --> ActivityPorts
|
||||||
|
ActivityUC --> EventPort
|
||||||
|
UserUC --> UserPorts
|
||||||
|
UserUC --> AuthPorts
|
||||||
|
UserUC --> MediaPort
|
||||||
|
UserUC --> CascadePorts
|
||||||
|
UserUC --> EntryPorts
|
||||||
|
MediaUC --> MediaPort
|
||||||
|
PushUC --> PushPorts
|
||||||
|
ImportUC --> ImportExportPorts
|
||||||
|
ImportUC --> EntryPorts
|
||||||
|
ImportUC --> ActivityPorts
|
||||||
|
ReminderUC --> ReminderPorts
|
||||||
|
|
||||||
|
EntryUC --> Services
|
||||||
|
|
||||||
|
SQLite -.->|implements| EntryPorts
|
||||||
|
SQLite -.->|implements| ActivityPorts
|
||||||
|
SQLite -.->|implements| UserPorts
|
||||||
|
SQLite -.->|implements| ReminderPorts
|
||||||
|
SQLite -.->|implements| PushPorts
|
||||||
|
SQLite -.->|implements| CascadePorts
|
||||||
|
SQLite -.->|implements| AuthPorts
|
||||||
|
Auth -.->|implements| AuthPorts
|
||||||
|
Storage -.->|implements| MediaPort
|
||||||
|
WebPush -.->|implements| ReminderPorts
|
||||||
|
EventPub -.->|implements| EventPort
|
||||||
|
Importer -.->|implements| ImportExportPorts
|
||||||
|
Exporter -.->|implements| ImportExportPorts
|
||||||
|
|
||||||
|
classDef domain fill:#2d4a22,stroke:#4a7c34,color:#fff
|
||||||
|
classDef application fill:#2a3d5c,stroke:#4a6fa5,color:#fff
|
||||||
|
classDef adapter fill:#4a3d2a,stroke:#a57a4a,color:#fff
|
||||||
|
classDef server fill:#3d2a4a,stroke:#7a4aa5,color:#fff
|
||||||
|
classDef client fill:#2a4a4a,stroke:#4aa5a5,color:#fff
|
||||||
|
classDef config fill:#4a4a2a,stroke:#a5a54a,color:#fff
|
||||||
|
|
||||||
|
class MoodEntry,Activity,User,Reminder,PushSubscription,RefreshSession,Mood,Content,DateRange,ActivityName,CategoryName,DisplayName,Timezone,ContentType,MediaUpload,EntryPorts,ActivityPorts,UserPorts,ReminderPorts,PushPorts,CascadePorts,AuthPorts,MediaPort,EventPort,ImportExportPorts,Services,Events domain
|
||||||
|
class EntryUC,ActivityUC,UserUC,MediaUC,PushUC,ImportUC,ReminderUC application
|
||||||
|
class HTTP,SQLite,Auth,Storage,WebPush,EventPub,Importer,Exporter adapter
|
||||||
|
class Main,Factory,Scheduler server
|
||||||
|
class SPA,Mobile,Desktop client
|
||||||
|
class ServerCfg,AuthCfg,PushCfg,StorageCfg,EntryCfg,PresetCfg config
|
||||||
|
class Requests,Responses adapter
|
||||||
21
config.dev.toml
Normal file
21
config.dev.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 3000
|
||||||
|
max_body_size = 10485760
|
||||||
|
spa_dir = "/spa/dist"
|
||||||
|
|
||||||
|
[server.cors]
|
||||||
|
allow_any_origin = true
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data_dir = "/data"
|
||||||
|
|
||||||
|
[storage.media]
|
||||||
|
backend = "local"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
jwt_secret = "dev-secret-do-not-use-in-prod"
|
||||||
|
access_token_ttl_seconds = 900
|
||||||
|
refresh_token_ttl_seconds = 2592000
|
||||||
|
allow_registration = true
|
||||||
42
config.example.toml
Normal file
42
config.example.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 3000
|
||||||
|
max_body_size = 10485760
|
||||||
|
# spa_dir = "spa/dist"
|
||||||
|
|
||||||
|
[server.cors]
|
||||||
|
allow_any_origin = true
|
||||||
|
# allowed_origins = ["https://app.example.com"]
|
||||||
|
|
||||||
|
[entry]
|
||||||
|
max_content_length = 65536
|
||||||
|
max_photos = 10
|
||||||
|
max_voice_memos = 5
|
||||||
|
max_activities_per_entry = 50
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data_dir = "./data"
|
||||||
|
# database_url = "sqlite://./data/k-mood.db"
|
||||||
|
|
||||||
|
[storage.media]
|
||||||
|
backend = "local"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
# [storage.media]
|
||||||
|
# backend = "s3"
|
||||||
|
# bucket = "k-mood-media"
|
||||||
|
# region = "us-east-1"
|
||||||
|
# endpoint = "http://localhost:9000"
|
||||||
|
# access_key = "minioadmin"
|
||||||
|
# secret_key = "minioadmin"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
jwt_secret = "change-me-in-production"
|
||||||
|
access_token_ttl_seconds = 900
|
||||||
|
refresh_token_ttl_seconds = 2592000
|
||||||
|
allow_registration = true
|
||||||
|
|
||||||
|
# [push]
|
||||||
|
# enabled = true
|
||||||
|
# vapid_private_key = "<base64-encoded PEM private key>"
|
||||||
|
# vapid_subject = "mailto:you@example.com"
|
||||||
14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "auth"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
config.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
71
crates/adapters/auth/src/jwt_service.rs
Normal file
71
crates/adapters/auth/src/jwt_service.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
|
||||||
|
use config::AuthConfig;
|
||||||
|
use domain::auth::GeneratedToken;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
secret: String,
|
||||||
|
ttl_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(config: &AuthConfig) -> Result<Self, DomainError> {
|
||||||
|
let secret = config
|
||||||
|
.jwt_secret
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("JWT secret must be configured".into()))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
secret,
|
||||||
|
ttl_seconds: config.access_token_ttl_seconds as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::AuthServicePort for JwtAuthService {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.value().to_string(),
|
||||||
|
exp: expires_at.timestamp() as u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = jsonwebtoken::encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to generate token: {e}")))?;
|
||||||
|
|
||||||
|
Ok(GeneratedToken::new(token, expires_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data = jsonwebtoken::decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
|
||||||
|
|
||||||
|
let uuid: uuid::Uuid = data
|
||||||
|
.claims
|
||||||
|
.sub
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
|
||||||
|
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/adapters/auth/src/lib.rs
Normal file
5
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod jwt_service;
|
||||||
|
mod password_hasher;
|
||||||
|
|
||||||
|
pub use jwt_service::JwtAuthService;
|
||||||
|
pub use password_hasher::Argon2PasswordHasher;
|
||||||
33
crates/adapters/auth/src/password_hasher.rs
Normal file
33
crates/adapters/auth/src/password_hasher.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use argon2::password_hash::SaltString;
|
||||||
|
use argon2::password_hash::rand_core::OsRng;
|
||||||
|
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
impl domain::ports::PasswordHasherPort for Argon2PasswordHasher {
|
||||||
|
fn hash(&self, raw_password: &str) -> Result<domain::user::PasswordHash, DomainError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(raw_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to hash password: {e}")))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(domain::user::PasswordHash::new(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(
|
||||||
|
&self,
|
||||||
|
raw_password: &str,
|
||||||
|
hash: &domain::user::PasswordHash,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid password hash: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(raw_password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/adapters/event-publisher/Cargo.toml
Normal file
10
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-publisher"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["sync"] }
|
||||||
|
tracing.workspace = true
|
||||||
35
crates/adapters/event-publisher/src/channel.rs
Normal file
35
crates/adapters/event-publisher/src/channel.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::events::EventEnvelope;
|
||||||
|
|
||||||
|
const DEFAULT_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
pub type EventReceiver = mpsc::Receiver<EventEnvelope>;
|
||||||
|
|
||||||
|
pub struct ChannelEventPublisher {
|
||||||
|
sender: mpsc::Sender<EventEnvelope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelEventPublisher {
|
||||||
|
fn new(sender: mpsc::Sender<EventEnvelope>) -> Self {
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::EventPublisherPort for ChannelEventPublisher {
|
||||||
|
async fn publish(&self, envelope: EventEnvelope) -> Result<(), DomainError> {
|
||||||
|
self.sender
|
||||||
|
.send(envelope)
|
||||||
|
.await
|
||||||
|
.map_err(|_| DomainError::InvalidInput("event channel closed".into()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_event_channel() -> (ChannelEventPublisher, EventReceiver) {
|
||||||
|
let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY);
|
||||||
|
(ChannelEventPublisher::new(sender), receiver)
|
||||||
|
}
|
||||||
5
crates/adapters/event-publisher/src/lib.rs
Normal file
5
crates/adapters/event-publisher/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod channel;
|
||||||
|
mod noop;
|
||||||
|
|
||||||
|
pub use channel::{ChannelEventPublisher, EventReceiver, create_event_channel};
|
||||||
|
pub use noop::NoopEventPublisher;
|
||||||
11
crates/adapters/event-publisher/src/noop.rs
Normal file
11
crates/adapters/event-publisher/src/noop.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::events::EventEnvelope;
|
||||||
|
|
||||||
|
pub struct NoopEventPublisher;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::EventPublisherPort for NoopEventPublisher {
|
||||||
|
async fn publish(&self, _envelope: EventEnvelope) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/adapters/exporter/Cargo.toml
Normal file
11
crates/adapters/exporter/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "exporter"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
127
crates/adapters/exporter/src/json_export.rs
Normal file
127
crates/adapters/exporter/src/json_export.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use std::io::{Cursor, Write};
|
||||||
|
|
||||||
|
use zip::ZipWriter;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::UserExport;
|
||||||
|
|
||||||
|
pub struct JsonExportAdapter;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ExportPort for JsonExportAdapter {
|
||||||
|
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||||
|
let buf = Cursor::new(Vec::new());
|
||||||
|
let mut zip = ZipWriter::new(buf);
|
||||||
|
let options =
|
||||||
|
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|
||||||
|
let json = build_data_json(data)?;
|
||||||
|
zip.start_file("data.json", options).map_err(zip_err)?;
|
||||||
|
zip.write_all(&json).map_err(io_err)?;
|
||||||
|
|
||||||
|
for photo in &data.photos {
|
||||||
|
zip.start_file(format!("photos/{}", photo.id), options)
|
||||||
|
.map_err(zip_err)?;
|
||||||
|
zip.write_all(&photo.data).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for memo in &data.voice_memos {
|
||||||
|
zip.start_file(format!("voice_memos/{}", memo.id), options)
|
||||||
|
.map_err(zip_err)?;
|
||||||
|
zip.write_all(&memo.data).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = zip.finish().map_err(zip_err)?;
|
||||||
|
Ok(cursor.into_inner())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_data_json(data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||||
|
let export = ExportData {
|
||||||
|
version: "1.0",
|
||||||
|
entries: data.entries.iter().map(EntryExport::from).collect(),
|
||||||
|
activities: data.activities.iter().map(ActivityExport::from).collect(),
|
||||||
|
reminder_count: data.reminders.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_vec_pretty(&export)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("json serialization failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_err(e: zip::result::ZipError) -> DomainError {
|
||||||
|
DomainError::InvalidInput(format!("zip error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_err(e: std::io::Error) -> DomainError {
|
||||||
|
DomainError::InvalidInput(format!("io error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExportData<'a> {
|
||||||
|
version: &'a str,
|
||||||
|
entries: Vec<EntryExport>,
|
||||||
|
activities: Vec<ActivityExport>,
|
||||||
|
reminder_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EntryExport {
|
||||||
|
id: String,
|
||||||
|
mood: u8,
|
||||||
|
mood_label: String,
|
||||||
|
logged_at: String,
|
||||||
|
activities: Vec<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
photos: Vec<String>,
|
||||||
|
voice_memos: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&domain::entry::MoodEntry> for EntryExport {
|
||||||
|
fn from(entry: &domain::entry::MoodEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id().value().to_string(),
|
||||||
|
mood: entry.mood().value(),
|
||||||
|
mood_label: format!("{:?}", entry.mood()),
|
||||||
|
logged_at: entry.logged_at().to_rfc3339(),
|
||||||
|
activities: entry
|
||||||
|
.activities()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.value().to_string())
|
||||||
|
.collect(),
|
||||||
|
content: entry.content().map(|c| c.value().to_string()),
|
||||||
|
photos: entry
|
||||||
|
.photos()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.value().to_string())
|
||||||
|
.collect(),
|
||||||
|
voice_memos: entry
|
||||||
|
.voice_memos()
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ActivityExport {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
category: Option<String>,
|
||||||
|
archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&domain::activity::Activity> for ActivityExport {
|
||||||
|
fn from(activity: &domain::activity::Activity) -> Self {
|
||||||
|
Self {
|
||||||
|
id: activity.id().value().to_string(),
|
||||||
|
name: activity.name().value().to_string(),
|
||||||
|
category: activity.category().map(|c| c.value().to_string()),
|
||||||
|
archived: activity.is_archived(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/adapters/exporter/src/lib.rs
Normal file
3
crates/adapters/exporter/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mod json_export;
|
||||||
|
|
||||||
|
pub use json_export::JsonExportAdapter;
|
||||||
20
crates/adapters/http-axum/Cargo.toml
Normal file
20
crates/adapters/http-axum/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "http-axum"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
application.workspace = true
|
||||||
|
api-types.workspace = true
|
||||||
|
config.workspace = true
|
||||||
|
web-push-adapter.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
utoipa-scalar.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
56
crates/adapters/http-axum/src/errors.rs
Normal file
56
crates/adapters/http-axum/src/errors.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
use api_types::errors::ApiValidationError;
|
||||||
|
use application::errors::ApplicationError;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub struct ApiError(pub ApplicationError);
|
||||||
|
|
||||||
|
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",
|
||||||
|
msg.clone(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::json!({ "error": { "code": code, "message": message } });
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApplicationError> for ApiError {
|
||||||
|
fn from(err: ApplicationError) -> Self {
|
||||||
|
Self(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiValidationError> for ApiError {
|
||||||
|
fn from(err: ApiValidationError) -> Self {
|
||||||
|
match err {
|
||||||
|
ApiValidationError::Domain(e) => Self(ApplicationError::Domain(e)),
|
||||||
|
ApiValidationError::Invalid(msg) => Self(ApplicationError::Validation(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainError> for ApiError {
|
||||||
|
fn from(err: DomainError) -> Self {
|
||||||
|
Self(ApplicationError::Domain(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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<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 = extract_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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_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;
|
||||||
7
crates/adapters/http-axum/src/extractors/mod.rs
Normal file
7
crates/adapters/http-axum/src/extractors/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod authenticated_user;
|
||||||
|
mod multipart;
|
||||||
|
mod path_id;
|
||||||
|
|
||||||
|
pub use authenticated_user::AuthenticatedUser;
|
||||||
|
pub use multipart::{extract_file_bytes, extract_media_upload};
|
||||||
|
pub use path_id::PathId;
|
||||||
66
crates/adapters/http-axum/src/extractors/multipart.rs
Normal file
66
crates/adapters/http-axum/src/extractors/multipart.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use axum::extract::Multipart;
|
||||||
|
|
||||||
|
use domain::attachment::{ContentType, MediaUpload};
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
|
||||||
|
pub async fn extract_media_upload(mut multipart: Multipart) -> Result<MediaUpload, ApiError> {
|
||||||
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
|
let mut content_type_str: Option<String> = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| validation_error(format!("invalid multipart data: {e}")))?
|
||||||
|
{
|
||||||
|
match field.name() {
|
||||||
|
Some("file") => {
|
||||||
|
if content_type_str.is_none() {
|
||||||
|
content_type_str = field.content_type().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| validation_error(format!("failed to read file: {e}")))?;
|
||||||
|
file_data = Some(bytes.to_vec());
|
||||||
|
}
|
||||||
|
Some("content_type") | Some("contentType") => {
|
||||||
|
let text = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| validation_error(format!("failed to read content type: {e}")))?;
|
||||||
|
content_type_str = Some(text);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = file_data.ok_or_else(|| validation_error("missing 'file' field".into()))?;
|
||||||
|
let content_type_str =
|
||||||
|
content_type_str.ok_or_else(|| validation_error("missing content type".into()))?;
|
||||||
|
let content_type = ContentType::new(content_type_str)?;
|
||||||
|
|
||||||
|
MediaUpload::new(data, content_type).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_file_bytes(mut multipart: Multipart) -> Result<Vec<u8>, ApiError> {
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| validation_error(format!("invalid multipart data: {e}")))?
|
||||||
|
{
|
||||||
|
if field.name() == Some("file") {
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| validation_error(format!("failed to read file: {e}")))?;
|
||||||
|
return Ok(bytes.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(validation_error("missing 'file' field".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validation_error(msg: String) -> ApiError {
|
||||||
|
ApiError(application::errors::ApplicationError::Validation(msg))
|
||||||
|
}
|
||||||
38
crates/adapters/http-axum/src/extractors/path_id.rs
Normal file
38
crates/adapters/http-axum/src/extractors/path_id.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
pub struct PathId<T>(pub T);
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, T> axum::extract::FromRequestParts<S> for PathId<T>
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
T: From<uuid::Uuid>,
|
||||||
|
{
|
||||||
|
type Rejection = PathIdRejection;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut axum::http::request::Parts,
|
||||||
|
state: &S,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let Path(id_str) = Path::<String>::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PathIdRejection(format!("invalid path parameter: {e}")))?;
|
||||||
|
|
||||||
|
let uuid: uuid::Uuid = id_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| PathIdRejection(format!("invalid UUID: {id_str}")))?;
|
||||||
|
|
||||||
|
Ok(PathId(T::from(uuid)))
|
||||||
|
}
|
||||||
|
}
|
||||||
159
crates/adapters/http-axum/src/handlers/activities.rs
Normal file
159
crates/adapters/http-axum/src/handlers/activities.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest};
|
||||||
|
use api_types::responses::ActivityResponse;
|
||||||
|
use application::activity::use_cases::{
|
||||||
|
archive_activity, create_activity, delete_activity, get_activity, list_activities,
|
||||||
|
rename_activity, set_category,
|
||||||
|
};
|
||||||
|
use domain::activity::ActivityId;
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::extractors::{AuthenticatedUser, PathId};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
|
||||||
|
request_body = CreateActivityRequest,
|
||||||
|
responses((status = 201, body = ActivityResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<CreateActivityRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<ActivityResponse>), ApiError> {
|
||||||
|
let cmd = body.into_command(user_id)?;
|
||||||
|
let deps = create_activity::Deps {
|
||||||
|
activities: state.activity_command,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
let activity = create_activity::execute(cmd, &deps).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(ActivityResponse::from(activity))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 200, body = ActivityResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
) -> Result<Json<ActivityResponse>, ApiError> {
|
||||||
|
let deps = get_activity::Deps {
|
||||||
|
query: state.activity_query,
|
||||||
|
};
|
||||||
|
let activity = get_activity::execute(activity_id, user_id, &deps).await?;
|
||||||
|
Ok(Json(ActivityResponse::from(activity)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
|
||||||
|
responses((status = 200, body = Vec<ActivityResponse>))
|
||||||
|
)]
|
||||||
|
pub async fn handle_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<Json<Vec<ActivityResponse>>, ApiError> {
|
||||||
|
let deps = list_activities::Deps {
|
||||||
|
query: state.activity_query,
|
||||||
|
};
|
||||||
|
let activities = list_activities::active_only(user_id, &deps).await?;
|
||||||
|
Ok(Json(
|
||||||
|
activities.into_iter().map(ActivityResponse::from).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(patch, path = "/api/v1/activities/{id}/name", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
request_body = RenameActivityRequest,
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_rename(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
Json(body): Json<RenameActivityRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let cmd = body.into_command(activity_id)?;
|
||||||
|
let deps = rename_activity::Deps {
|
||||||
|
command: state.activity_command,
|
||||||
|
query: state.activity_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
rename_activity::execute(cmd, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(patch, path = "/api/v1/activities/{id}/category", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
request_body = SetCategoryRequest,
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_set_category(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
Json(body): Json<SetCategoryRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let cmd = body.into_command(activity_id)?;
|
||||||
|
let deps = set_category::Deps {
|
||||||
|
command: state.activity_command,
|
||||||
|
query: state.activity_query,
|
||||||
|
};
|
||||||
|
set_category::execute(cmd, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/activities/{id}/archive", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_archive(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = archive_activity::Deps {
|
||||||
|
command: state.activity_command,
|
||||||
|
query: state.activity_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
archive_activity::archive(activity_id, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/activities/{id}/unarchive", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_unarchive(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = archive_activity::Deps {
|
||||||
|
command: state.activity_command,
|
||||||
|
query: state.activity_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
archive_activity::unarchive(activity_id, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_activity::Deps {
|
||||||
|
command: state.activity_command,
|
||||||
|
query: state.activity_query,
|
||||||
|
};
|
||||||
|
delete_activity::execute(activity_id, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
89
crates/adapters/http-axum/src/handlers/auth.rs
Normal file
89
crates/adapters/http-axum/src/handlers/auth.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::requests::LoginRequest;
|
||||||
|
use api_types::responses::UserResponse;
|
||||||
|
use application::auth::use_cases::{login, logout, refresh};
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/auth/login", tag = "auth",
|
||||||
|
request_body = LoginRequest,
|
||||||
|
responses((status = 200, description = "Login successful"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let cmd = body.into_command();
|
||||||
|
let deps = login::Deps {
|
||||||
|
user_query: state.user_query,
|
||||||
|
password_hasher: state.password_hasher,
|
||||||
|
auth_service: state.auth_service,
|
||||||
|
refresh_session_command: state.refresh_session_command,
|
||||||
|
refresh_token_ttl_seconds: state.auth_config.refresh_token_ttl_seconds as i64,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/auth/refresh", tag = "auth",
|
||||||
|
request_body = RefreshRequest,
|
||||||
|
responses((status = 200, description = "Token refreshed"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<RefreshRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let deps = refresh::Deps {
|
||||||
|
auth_service: state.auth_service,
|
||||||
|
refresh_session_command: state.refresh_session_command,
|
||||||
|
refresh_session_query: state.refresh_session_query,
|
||||||
|
refresh_token_ttl_seconds: state.auth_config.refresh_token_ttl_seconds as i64,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogoutRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/auth/logout", tag = "auth",
|
||||||
|
request_body = LogoutRequest,
|
||||||
|
responses((status = 204, description = "Logged out"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<LogoutRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = logout::Deps {
|
||||||
|
refresh_session_command: state.refresh_session_command,
|
||||||
|
};
|
||||||
|
logout::execute(&body.refresh_token, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
260
crates/adapters/http-axum/src/handlers/entries.rs
Normal file
260
crates/adapters/http-axum/src/handlers/entries.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::mappers::correlation_response;
|
||||||
|
use api_types::requests::{
|
||||||
|
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
|
||||||
|
UpdateEntryRequest,
|
||||||
|
};
|
||||||
|
use api_types::responses::{
|
||||||
|
BulkActionResponse, CalendarDayResponse, CorrelationResponse, EntryResponse, MoodStatsResponse,
|
||||||
|
};
|
||||||
|
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery, MoodStatsQuery};
|
||||||
|
use application::entry::use_cases::{
|
||||||
|
create_entry, delete_entries_by_date_range, delete_entry, filter_by_activity, filter_by_mood,
|
||||||
|
get_activity_correlation, get_calendar, get_entry, get_mood_stats, list_entries,
|
||||||
|
replace_activity, update_entry,
|
||||||
|
};
|
||||||
|
use domain::activity::ActivityId;
|
||||||
|
use domain::entry::{Mood, MoodEntryId};
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::extractors::{AuthenticatedUser, PathId};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
|
||||||
|
request_body = CreateEntryRequest,
|
||||||
|
responses((status = 201, body = EntryResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<CreateEntryRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<EntryResponse>), ApiError> {
|
||||||
|
let cmd = body.into_command(user_id, &state.entry_config)?;
|
||||||
|
let deps = create_entry::Deps {
|
||||||
|
entries: state.entry_command,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
let entry = create_entry::execute(cmd, &deps).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(EntryResponse::from(entry))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path, description = "Entry ID")),
|
||||||
|
responses((status = 200, body = EntryResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(entry_id): PathId<MoodEntryId>,
|
||||||
|
) -> Result<Json<EntryResponse>, ApiError> {
|
||||||
|
let deps = get_entry::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let entry = get_entry::execute(entry_id, user_id, &deps).await?;
|
||||||
|
Ok(Json(EntryResponse::from(entry)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(ListEntriesParams),
|
||||||
|
responses((status = 200, body = Vec<EntryResponse>))
|
||||||
|
)]
|
||||||
|
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)?;
|
||||||
|
let deps = list_entries::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let entries = list_entries::execute(query, &deps).await?;
|
||||||
|
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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))
|
||||||
|
)]
|
||||||
|
pub async fn handle_update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(entry_id): PathId<MoodEntryId>,
|
||||||
|
Json(body): Json<UpdateEntryRequest>,
|
||||||
|
) -> Result<Json<EntryResponse>, ApiError> {
|
||||||
|
let cmd = body.into_command(entry_id, &state.entry_config)?;
|
||||||
|
let deps = update_entry::Deps {
|
||||||
|
command: state.entry_command,
|
||||||
|
query: state.entry_query,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
let entry = update_entry::execute(cmd, user_id, &deps).await?;
|
||||||
|
Ok(Json(EntryResponse::from(entry)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path, description = "Entry ID")),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(entry_id): PathId<MoodEntryId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_entry::Deps {
|
||||||
|
command: state.entry_command,
|
||||||
|
query: state.entry_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
};
|
||||||
|
delete_entry::execute(entry_id, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>))
|
||||||
|
)]
|
||||||
|
pub async fn handle_filter_by_mood(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Path(mood): Path<u8>,
|
||||||
|
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||||
|
let mood = Mood::try_from(mood)?;
|
||||||
|
let query = FilterByMoodQuery { user_id, mood };
|
||||||
|
let deps = filter_by_mood::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let entries = filter_by_mood::execute(query, &deps).await?;
|
||||||
|
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>))
|
||||||
|
)]
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
let deps = filter_by_activity::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let entries = filter_by_activity::execute(query, &deps).await?;
|
||||||
|
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(ListEntriesParams),
|
||||||
|
responses((status = 200, body = MoodStatsResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Query(params): Query<ListEntriesParams>,
|
||||||
|
) -> Result<Json<MoodStatsResponse>, ApiError> {
|
||||||
|
let range = match (params.from, params.to) {
|
||||||
|
(Some(from), Some(to)) => {
|
||||||
|
let from = api_types::mappers::shared::parse_datetime(&from)?;
|
||||||
|
let to = api_types::mappers::shared::parse_datetime(&to)?;
|
||||||
|
Some(domain::entry::DateRange::new(from, to)?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let query = MoodStatsQuery { user_id, range };
|
||||||
|
let deps = get_mood_stats::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let stats = get_mood_stats::execute(query, &deps).await?;
|
||||||
|
Ok(Json(MoodStatsResponse::from(stats)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/entries/calendar", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(DateRangeParams),
|
||||||
|
responses((status = 200, body = Vec<CalendarDayResponse>))
|
||||||
|
)]
|
||||||
|
pub async fn handle_calendar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Query(params): Query<DateRangeParams>,
|
||||||
|
) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> {
|
||||||
|
let range = params.into_date_range()?;
|
||||||
|
let deps = get_calendar::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let days = get_calendar::execute(user_id, range, &deps).await?;
|
||||||
|
Ok(Json(
|
||||||
|
days.into_iter().map(CalendarDayResponse::from).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/entries/correlation/{id}", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path, description = "Activity ID"), ListEntriesParams),
|
||||||
|
responses((status = 200, body = CorrelationResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_activity_correlation(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(activity_id): PathId<ActivityId>,
|
||||||
|
Query(params): Query<ListEntriesParams>,
|
||||||
|
) -> Result<Json<CorrelationResponse>, ApiError> {
|
||||||
|
let range = match (params.from, params.to) {
|
||||||
|
(Some(from), Some(to)) => {
|
||||||
|
let from = api_types::mappers::shared::parse_datetime(&from)?;
|
||||||
|
let to = api_types::mappers::shared::parse_datetime(&to)?;
|
||||||
|
Some(domain::entry::DateRange::new(from, to)?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let deps = get_activity_correlation::Deps {
|
||||||
|
query: state.entry_query,
|
||||||
|
};
|
||||||
|
let correlation =
|
||||||
|
get_activity_correlation::execute(user_id, activity_id.clone(), range, &deps).await?;
|
||||||
|
Ok(Json(correlation_response(activity_id, correlation)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/entries/bulk/delete", tag = "entries", security(("bearer" = [])),
|
||||||
|
params(DateRangeParams),
|
||||||
|
responses((status = 200, body = BulkActionResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete_by_date_range(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Query(params): Query<DateRangeParams>,
|
||||||
|
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||||
|
let range = params.into_date_range()?;
|
||||||
|
let deps = delete_entries_by_date_range::Deps {
|
||||||
|
cascade: state.cascade,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
};
|
||||||
|
let affected_count = delete_entries_by_date_range::execute(user_id, &range, &deps).await?;
|
||||||
|
Ok(Json(BulkActionResponse { affected_count }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/entries/bulk/replace-activity", tag = "entries", security(("bearer" = [])),
|
||||||
|
request_body = ReplaceActivityRequest,
|
||||||
|
responses((status = 200, body = BulkActionResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_replace_activity(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<ReplaceActivityRequest>,
|
||||||
|
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||||
|
let (user_id, old_id, new_id) = body.into_parts(user_id)?;
|
||||||
|
let deps = replace_activity::Deps {
|
||||||
|
entry_command: state.entry_command,
|
||||||
|
activity_query: state.activity_query,
|
||||||
|
};
|
||||||
|
let affected_count = replace_activity::execute(user_id, old_id, new_id, &deps).await?;
|
||||||
|
Ok(Json(BulkActionResponse { affected_count }))
|
||||||
|
}
|
||||||
64
crates/adapters/http-axum/src/handlers/import_export.rs
Normal file
64
crates/adapters/http-axum/src/handlers/import_export.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Multipart, State};
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
use api_types::responses::ImportResultResponse;
|
||||||
|
use application::export::use_cases::export_user_data;
|
||||||
|
use application::import::commands::ImportCommand;
|
||||||
|
use application::import::use_cases::import_entries;
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/data/export", tag = "data", security(("bearer" = [])),
|
||||||
|
responses((status = 200, description = "ZIP archive with user data"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_export(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let deps = export_user_data::Deps {
|
||||||
|
entry_query: state.entry_query,
|
||||||
|
activity_query: state.activity_query,
|
||||||
|
reminder_query: state.reminder_query,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
exporter: state.export_port.clone(),
|
||||||
|
};
|
||||||
|
let data = export_user_data::execute(user_id, &deps).await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/zip"),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"k-mood-export.zip\"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/data/import", tag = "data", security(("bearer" = [])),
|
||||||
|
responses((status = 200, body = ImportResultResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_import(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Json<ImportResultResponse>, ApiError> {
|
||||||
|
let data = extract_file_bytes(multipart).await?;
|
||||||
|
|
||||||
|
let cmd = ImportCommand { user_id, data };
|
||||||
|
let deps = import_entries::Deps {
|
||||||
|
source: state.import_source.clone(),
|
||||||
|
entry_command: state.entry_command,
|
||||||
|
entry_query: state.entry_query,
|
||||||
|
activity_command: state.activity_command,
|
||||||
|
activity_query: state.activity_query,
|
||||||
|
preset: state.preset_config,
|
||||||
|
};
|
||||||
|
let result = import_entries::execute(cmd, &deps).await?;
|
||||||
|
Ok(Json(ImportResultResponse::from(result)))
|
||||||
|
}
|
||||||
122
crates/adapters/http-axum/src/handlers/media.rs
Normal file
122
crates/adapters/http-axum/src/handlers/media.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Multipart, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
use api_types::responses::MediaIdResponse;
|
||||||
|
use application::media::use_cases::{
|
||||||
|
delete_photo, delete_voice_memo, 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::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/media/photos", tag = "media", security(("bearer" = [])),
|
||||||
|
responses((status = 201, body = MediaIdResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_upload_photo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||||
|
let upload = extract_media_upload(multipart).await?;
|
||||||
|
let deps = upload_photo::Deps {
|
||||||
|
storage: state.media_storage,
|
||||||
|
};
|
||||||
|
let photo_id = upload_photo::execute(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))
|
||||||
|
)]
|
||||||
|
pub async fn handle_upload_voice_memo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||||
|
let upload = extract_media_upload(multipart).await?;
|
||||||
|
let deps = upload_voice_memo::Deps {
|
||||||
|
storage: state.media_storage,
|
||||||
|
};
|
||||||
|
let voice_memo_id = upload_voice_memo::execute(upload, &deps).await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(MediaIdResponse::from(voice_memo_id)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/media/photos/{id}", tag = "media",
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 200, description = "Photo binary"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_serve_photo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
PathId(photo_id): PathId<PhotoId>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let file = state
|
||||||
|
.media_storage
|
||||||
|
.get_photo(&photo_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("photo not found".into()))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[(header::CONTENT_TYPE, file.content_type.value().to_string())],
|
||||||
|
file.data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/media/voice-memos/{id}", tag = "media",
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 200, description = "Voice memo binary"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_serve_voice_memo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
PathId(voice_memo_id): PathId<VoiceMemoId>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let file = state
|
||||||
|
.media_storage
|
||||||
|
.get_voice_memo(&voice_memo_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("voice memo not found".into()))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[(header::CONTENT_TYPE, file.content_type.value().to_string())],
|
||||||
|
file.data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/media/photos/{id}", tag = "media", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete_photo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||||
|
PathId(photo_id): PathId<PhotoId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_photo::Deps {
|
||||||
|
storage: state.media_storage,
|
||||||
|
};
|
||||||
|
delete_photo::execute(photo_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))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete_voice_memo(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||||
|
PathId(voice_memo_id): PathId<VoiceMemoId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_voice_memo::Deps {
|
||||||
|
storage: state.media_storage,
|
||||||
|
};
|
||||||
|
delete_voice_memo::execute(voice_memo_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
8
crates/adapters/http-axum/src/handlers/mod.rs
Normal file
8
crates/adapters/http-axum/src/handlers/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod activities;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod entries;
|
||||||
|
pub mod import_export;
|
||||||
|
pub mod media;
|
||||||
|
pub mod push;
|
||||||
|
pub mod reminders;
|
||||||
|
pub mod users;
|
||||||
77
crates/adapters/http-axum/src/handlers/push.rs
Normal file
77
crates/adapters/http-axum/src/handlers/push.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::requests::{PushSubscribeRequest, PushUnsubscribeRequest};
|
||||||
|
use application::push::use_cases::{subscribe, unsubscribe};
|
||||||
|
|
||||||
|
use crate::errors::ApiError;
|
||||||
|
use crate::extractors::AuthenticatedUser;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/push/vapid-key", tag = "push",
|
||||||
|
responses((status = 200, description = "VAPID public key"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_vapid_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if !state.push_config.enabled {
|
||||||
|
return Err(domain::errors::DomainError::NotFound(
|
||||||
|
"push notifications are disabled".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let public_key = web_push_adapter::WebPushSender::public_key_base64(&state.push_config)?;
|
||||||
|
Ok(Json(serde_json::json!({ "publicKey": public_key })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/push/subscribe", tag = "push", security(("bearer" = [])),
|
||||||
|
request_body = PushSubscribeRequest,
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_subscribe(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<PushSubscribeRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let cmd = body.into_command(user_id);
|
||||||
|
let deps = subscribe::Deps {
|
||||||
|
push_command: state.push_subscription_command,
|
||||||
|
push_query: state.push_subscription_query,
|
||||||
|
};
|
||||||
|
subscribe::execute(cmd, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/push/unsubscribe", tag = "push", security(("bearer" = [])),
|
||||||
|
request_body = PushUnsubscribeRequest,
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_unsubscribe(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<PushUnsubscribeRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let cmd = body.into_command();
|
||||||
|
let deps = unsubscribe::Deps {
|
||||||
|
push_command: state.push_subscription_command,
|
||||||
|
};
|
||||||
|
unsubscribe::execute(cmd, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/push/test", tag = "push", security(("bearer" = [])),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_test(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let sender = state
|
||||||
|
.reminder_sender
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| domain::errors::DomainError::InvalidInput("push not enabled".into()))?;
|
||||||
|
|
||||||
|
sender.send_reminder(&user_id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
103
crates/adapters/http-axum/src/handlers/reminders.rs
Normal file
103
crates/adapters/http-axum/src/handlers/reminders.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::requests::{CreateReminderRequest, UpdateReminderRequest};
|
||||||
|
use api_types::responses::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::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
|
||||||
|
request_body = CreateReminderRequest,
|
||||||
|
responses((status = 201, body = ReminderResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<CreateReminderRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<ReminderResponse>), ApiError> {
|
||||||
|
let cmd = body.into_command(user_id)?;
|
||||||
|
let deps = create_reminder::Deps {
|
||||||
|
reminders: state.reminder_command,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
let reminder = create_reminder::execute(cmd, &deps).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(ReminderResponse::from(reminder))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 200, body = ReminderResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(reminder_id): PathId<ReminderId>,
|
||||||
|
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||||
|
let deps = get_reminder::Deps {
|
||||||
|
query: state.reminder_query,
|
||||||
|
};
|
||||||
|
let reminder = get_reminder::execute(reminder_id, user_id, &deps).await?;
|
||||||
|
Ok(Json(ReminderResponse::from(reminder)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
|
||||||
|
responses((status = 200, body = Vec<ReminderResponse>))
|
||||||
|
)]
|
||||||
|
pub async fn handle_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<Json<Vec<ReminderResponse>>, ApiError> {
|
||||||
|
let deps = list_reminders::Deps {
|
||||||
|
query: state.reminder_query,
|
||||||
|
};
|
||||||
|
let reminders = list_reminders::execute(user_id, &deps).await?;
|
||||||
|
Ok(Json(
|
||||||
|
reminders.into_iter().map(ReminderResponse::from).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(patch, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
request_body = UpdateReminderRequest,
|
||||||
|
responses((status = 200, body = ReminderResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(reminder_id): PathId<ReminderId>,
|
||||||
|
Json(body): Json<UpdateReminderRequest>,
|
||||||
|
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||||
|
let cmd = body.into_command(reminder_id)?;
|
||||||
|
let deps = update_reminder::Deps {
|
||||||
|
command: state.reminder_command,
|
||||||
|
query: state.reminder_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
let reminder = update_reminder::execute(cmd, user_id, &deps).await?;
|
||||||
|
Ok(Json(ReminderResponse::from(reminder)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||||
|
params(("id" = String, Path)),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
PathId(reminder_id): PathId<ReminderId>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_reminder::Deps {
|
||||||
|
command: state.reminder_command,
|
||||||
|
query: state.reminder_query,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
delete_reminder::execute(reminder_id, user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
124
crates/adapters/http-axum/src/handlers/users.rs
Normal file
124
crates/adapters/http-axum/src/handlers/users.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
use api_types::requests::{ChangePasswordRequest, RegisterRequest, UpdateProfileRequest};
|
||||||
|
use api_types::responses::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::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/v1/users/register", tag = "users",
|
||||||
|
request_body = RegisterRequest,
|
||||||
|
responses((status = 201, body = UserResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<RegisterRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<UserResponse>), ApiError> {
|
||||||
|
if !state.auth_config.allow_registration {
|
||||||
|
return Err(
|
||||||
|
domain::errors::DomainError::Forbidden("registration is disabled".into()).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cmd = body.into_command()?;
|
||||||
|
let deps = register::Deps {
|
||||||
|
user_command: state.user_command,
|
||||||
|
user_query: state.user_query,
|
||||||
|
activity_command: state.activity_command,
|
||||||
|
password_hasher: state.password_hasher,
|
||||||
|
events: state.event_publisher,
|
||||||
|
preset: state.preset_config,
|
||||||
|
};
|
||||||
|
let user = register::execute(cmd, &deps).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(UserResponse::from(user))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||||
|
responses((status = 200, body = UserResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_get_profile(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
let deps = get_profile::Deps {
|
||||||
|
user_query: state.user_query,
|
||||||
|
};
|
||||||
|
let user = get_profile::execute(user_id, &deps).await?;
|
||||||
|
Ok(Json(UserResponse::from(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(patch, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||||
|
request_body = UpdateProfileRequest,
|
||||||
|
responses((status = 200, body = UserResponse))
|
||||||
|
)]
|
||||||
|
pub async fn handle_update_profile(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<UpdateProfileRequest>,
|
||||||
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
let cmd = body.into_command(user_id)?;
|
||||||
|
let deps = update_profile::Deps {
|
||||||
|
user_command: state.user_command,
|
||||||
|
user_query: state.user_query,
|
||||||
|
};
|
||||||
|
let user = update_profile::execute(cmd, &deps).await?;
|
||||||
|
Ok(Json(UserResponse::from(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(patch, path = "/api/v1/users/me/password", tag = "users", security(("bearer" = [])),
|
||||||
|
request_body = ChangePasswordRequest,
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Json(body): Json<ChangePasswordRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let cmd = body.into_command(user_id);
|
||||||
|
let deps = change_password::Deps {
|
||||||
|
user_command: state.user_command,
|
||||||
|
user_query: state.user_query,
|
||||||
|
password_hasher: state.password_hasher,
|
||||||
|
};
|
||||||
|
change_password::execute(cmd, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||||
|
responses((status = 204))
|
||||||
|
)]
|
||||||
|
pub async fn handle_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = delete_user::Deps {
|
||||||
|
user_query: state.user_query,
|
||||||
|
entry_query: state.entry_query,
|
||||||
|
cascade: state.cascade,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
events: state.event_publisher,
|
||||||
|
};
|
||||||
|
delete_user::execute(user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/v1/users/me/data", tag = "users", security(("bearer" = [])),
|
||||||
|
responses((status = 204, description = "All user data cleared"))
|
||||||
|
)]
|
||||||
|
pub async fn handle_clear_data(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let deps = clear_data::Deps {
|
||||||
|
entry_query: state.entry_query,
|
||||||
|
cascade: state.cascade,
|
||||||
|
media_storage: state.media_storage,
|
||||||
|
};
|
||||||
|
clear_data::execute(user_id, &deps).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
7
crates/adapters/http-axum/src/lib.rs
Normal file
7
crates/adapters/http-axum/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod errors;
|
||||||
|
pub mod extractors;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod openapi;
|
||||||
|
pub mod router;
|
||||||
|
pub mod spa;
|
||||||
|
pub mod state;
|
||||||
120
crates/adapters/http-axum/src/openapi.rs
Normal file
120
crates/adapters/http-axum/src/openapi.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||||
|
use utoipa::{Modify, OpenApi};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "k-mood API",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = "Mood tracking journal API"
|
||||||
|
),
|
||||||
|
modifiers(&SecurityAddon),
|
||||||
|
paths(
|
||||||
|
crate::handlers::auth::handle_login,
|
||||||
|
crate::handlers::auth::handle_refresh,
|
||||||
|
crate::handlers::auth::handle_logout,
|
||||||
|
crate::handlers::entries::handle_create,
|
||||||
|
crate::handlers::entries::handle_get,
|
||||||
|
crate::handlers::entries::handle_list,
|
||||||
|
crate::handlers::entries::handle_update,
|
||||||
|
crate::handlers::entries::handle_delete,
|
||||||
|
crate::handlers::entries::handle_filter_by_mood,
|
||||||
|
crate::handlers::entries::handle_filter_by_activity,
|
||||||
|
crate::handlers::entries::handle_stats,
|
||||||
|
crate::handlers::entries::handle_calendar,
|
||||||
|
crate::handlers::entries::handle_activity_correlation,
|
||||||
|
crate::handlers::entries::handle_delete_by_date_range,
|
||||||
|
crate::handlers::entries::handle_replace_activity,
|
||||||
|
crate::handlers::activities::handle_create,
|
||||||
|
crate::handlers::activities::handle_get,
|
||||||
|
crate::handlers::activities::handle_list,
|
||||||
|
crate::handlers::activities::handle_rename,
|
||||||
|
crate::handlers::activities::handle_set_category,
|
||||||
|
crate::handlers::activities::handle_archive,
|
||||||
|
crate::handlers::activities::handle_unarchive,
|
||||||
|
crate::handlers::activities::handle_delete,
|
||||||
|
crate::handlers::users::handle_register,
|
||||||
|
crate::handlers::users::handle_get_profile,
|
||||||
|
crate::handlers::users::handle_update_profile,
|
||||||
|
crate::handlers::users::handle_change_password,
|
||||||
|
crate::handlers::users::handle_delete,
|
||||||
|
crate::handlers::users::handle_clear_data,
|
||||||
|
crate::handlers::reminders::handle_create,
|
||||||
|
crate::handlers::reminders::handle_get,
|
||||||
|
crate::handlers::reminders::handle_list,
|
||||||
|
crate::handlers::reminders::handle_update,
|
||||||
|
crate::handlers::reminders::handle_delete,
|
||||||
|
crate::handlers::media::handle_upload_photo,
|
||||||
|
crate::handlers::media::handle_upload_voice_memo,
|
||||||
|
crate::handlers::media::handle_serve_photo,
|
||||||
|
crate::handlers::media::handle_serve_voice_memo,
|
||||||
|
crate::handlers::media::handle_delete_photo,
|
||||||
|
crate::handlers::media::handle_delete_voice_memo,
|
||||||
|
crate::handlers::import_export::handle_export,
|
||||||
|
crate::handlers::import_export::handle_import,
|
||||||
|
crate::handlers::push::handle_vapid_key,
|
||||||
|
crate::handlers::push::handle_subscribe,
|
||||||
|
crate::handlers::push::handle_unsubscribe,
|
||||||
|
crate::handlers::push::handle_test,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
api_types::requests::CreateEntryRequest,
|
||||||
|
api_types::requests::UpdateEntryRequest,
|
||||||
|
api_types::requests::ListEntriesParams,
|
||||||
|
api_types::requests::DateRangeParams,
|
||||||
|
api_types::requests::ReplaceActivityRequest,
|
||||||
|
api_types::requests::CreateActivityRequest,
|
||||||
|
api_types::requests::RenameActivityRequest,
|
||||||
|
api_types::requests::SetCategoryRequest,
|
||||||
|
api_types::requests::RegisterRequest,
|
||||||
|
api_types::requests::LoginRequest,
|
||||||
|
api_types::requests::UpdateProfileRequest,
|
||||||
|
api_types::requests::ChangePasswordRequest,
|
||||||
|
api_types::requests::CreateReminderRequest,
|
||||||
|
api_types::requests::UpdateReminderRequest,
|
||||||
|
crate::handlers::auth::RefreshRequest,
|
||||||
|
crate::handlers::auth::LogoutRequest,
|
||||||
|
api_types::responses::EntryResponse,
|
||||||
|
api_types::responses::ActivityResponse,
|
||||||
|
api_types::responses::UserResponse,
|
||||||
|
api_types::responses::ReminderResponse,
|
||||||
|
api_types::responses::DayScheduleResponse,
|
||||||
|
api_types::responses::MoodStatsResponse,
|
||||||
|
api_types::responses::MoodFrequency,
|
||||||
|
api_types::responses::CalendarDayResponse,
|
||||||
|
api_types::responses::BulkActionResponse,
|
||||||
|
api_types::responses::CorrelationResponse,
|
||||||
|
api_types::responses::ImportResultResponse,
|
||||||
|
api_types::responses::MediaIdResponse,
|
||||||
|
api_types::requests::PushSubscribeRequest,
|
||||||
|
api_types::requests::PushUnsubscribeRequest,
|
||||||
|
)),
|
||||||
|
tags(
|
||||||
|
(name = "auth", description = "Authentication"),
|
||||||
|
(name = "entries", description = "Mood entries"),
|
||||||
|
(name = "activities", description = "Activity catalog"),
|
||||||
|
(name = "users", description = "User management"),
|
||||||
|
(name = "reminders", description = "Reminder schedules"),
|
||||||
|
(name = "media", description = "Photo and voice memo storage"),
|
||||||
|
(name = "data", description = "Import and export"),
|
||||||
|
(name = "push", description = "Push notifications"),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
|
|
||||||
|
struct SecurityAddon;
|
||||||
|
|
||||||
|
impl Modify for SecurityAddon {
|
||||||
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||||
|
let components = openapi.components.get_or_insert_with(Default::default);
|
||||||
|
components.add_security_scheme(
|
||||||
|
"bearer",
|
||||||
|
SecurityScheme::Http(
|
||||||
|
HttpBuilder::new()
|
||||||
|
.scheme(HttpAuthScheme::Bearer)
|
||||||
|
.bearer_format("JWT")
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
crates/adapters/http-axum/src/router.rs
Normal file
172
crates/adapters/http-axum/src/router.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use axum::routing::{delete, get, patch, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
|
|
||||||
|
use crate::handlers::{activities, auth, entries, import_export, media, push, reminders, users};
|
||||||
|
use crate::openapi::ApiDoc;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
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()
|
||||||
|
.nest("/api/v1", api_routes())
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/openapi.json", get(openapi_json))
|
||||||
|
.merge(Scalar::with_url("/docs", ApiDoc::openapi()))
|
||||||
|
.fallback_service(crate::spa::serve_spa(&state.server_config.spa_dir))
|
||||||
|
.layer(body_limit)
|
||||||
|
.layer(cors)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
|
||||||
|
Json(ApiDoc::openapi())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_cors(config: &config::CorsConfig) -> CorsLayer {
|
||||||
|
let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
|
||||||
|
|
||||||
|
if config.allow_any_origin {
|
||||||
|
return layer.allow_origin(Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
let origins: Vec<HeaderValue> = config
|
||||||
|
.allowed_origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.parse().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
layer.allow_origin(AllowOrigin::list(origins))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.nest("/auth", auth_routes())
|
||||||
|
.nest("/entries", entry_routes())
|
||||||
|
.nest("/activities", activity_routes())
|
||||||
|
.nest("/users", user_routes())
|
||||||
|
.nest("/reminders", reminder_routes())
|
||||||
|
.nest("/media", media_routes())
|
||||||
|
.nest("/push", push_routes())
|
||||||
|
.nest("/data", data_routes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/login", post(auth::handle_login))
|
||||||
|
.route("/refresh", post(auth::handle_refresh))
|
||||||
|
.route("/logout", post(auth::handle_logout))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(entries::handle_list).post(entries::handle_create))
|
||||||
|
.route(
|
||||||
|
"/{id}",
|
||||||
|
get(entries::handle_get)
|
||||||
|
.patch(entries::handle_update)
|
||||||
|
.delete(entries::handle_delete),
|
||||||
|
)
|
||||||
|
.route("/stats", get(entries::handle_stats))
|
||||||
|
.route("/calendar", get(entries::handle_calendar))
|
||||||
|
.route("/filter/mood/{mood}", get(entries::handle_filter_by_mood))
|
||||||
|
.route(
|
||||||
|
"/filter/activity/{id}",
|
||||||
|
get(entries::handle_filter_by_activity),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/correlation/{id}",
|
||||||
|
get(entries::handle_activity_correlation),
|
||||||
|
)
|
||||||
|
.route("/bulk/delete", delete(entries::handle_delete_by_date_range))
|
||||||
|
.route(
|
||||||
|
"/bulk/replace-activity",
|
||||||
|
post(entries::handle_replace_activity),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(activities::handle_list).post(activities::handle_create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}",
|
||||||
|
get(activities::handle_get).delete(activities::handle_delete),
|
||||||
|
)
|
||||||
|
.route("/{id}/name", patch(activities::handle_rename))
|
||||||
|
.route("/{id}/category", patch(activities::handle_set_category))
|
||||||
|
.route("/{id}/archive", post(activities::handle_archive))
|
||||||
|
.route("/{id}/unarchive", post(activities::handle_unarchive))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/register", post(users::handle_register))
|
||||||
|
.route(
|
||||||
|
"/me",
|
||||||
|
get(users::handle_get_profile)
|
||||||
|
.patch(users::handle_update_profile)
|
||||||
|
.delete(users::handle_delete),
|
||||||
|
)
|
||||||
|
.route("/me/password", patch(users::handle_change_password))
|
||||||
|
.route("/me/data", delete(users::handle_clear_data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reminder_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(reminders::handle_list).post(reminders::handle_create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}",
|
||||||
|
get(reminders::handle_get)
|
||||||
|
.patch(reminders::handle_update)
|
||||||
|
.delete(reminders::handle_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/photos", post(media::handle_upload_photo))
|
||||||
|
.route(
|
||||||
|
"/photos/{id}",
|
||||||
|
get(media::handle_serve_photo).delete(media::handle_delete_photo),
|
||||||
|
)
|
||||||
|
.route("/voice-memos", post(media::handle_upload_voice_memo))
|
||||||
|
.route(
|
||||||
|
"/voice-memos/{id}",
|
||||||
|
get(media::handle_serve_voice_memo).delete(media::handle_delete_voice_memo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/vapid-key", get(push::handle_vapid_key))
|
||||||
|
.route("/subscribe", post(push::handle_subscribe))
|
||||||
|
.route("/unsubscribe", post(push::handle_unsubscribe))
|
||||||
|
.route("/test", post(push::handle_test))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/export", get(import_export::handle_export))
|
||||||
|
.route("/import", post(import_export::handle_import))
|
||||||
|
}
|
||||||
5
crates/adapters/http-axum/src/spa.rs
Normal file
5
crates/adapters/http-axum/src/spa.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
|
pub fn serve_spa(spa_dir: &str) -> ServeDir<ServeFile> {
|
||||||
|
ServeDir::new(spa_dir).fallback(ServeFile::new(format!("{spa_dir}/index.html")))
|
||||||
|
}
|
||||||
39
crates/adapters/http-axum/src/state.rs
Normal file
39
crates/adapters/http-axum/src/state.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use config::{AuthConfig, EntryConfig, PresetConfig, PushConfig, ServerConfig};
|
||||||
|
use domain::ports::{
|
||||||
|
ActivityCommandPort, ActivityQueryPort, AuthServicePort, CascadeDeletePort, EventPublisherPort,
|
||||||
|
ExportPort, ImportSourcePort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||||
|
PasswordHasherPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort,
|
||||||
|
RefreshSessionCommandPort, RefreshSessionQueryPort, ReminderCommandPort, ReminderQueryPort,
|
||||||
|
ReminderSenderPort, UserCommandPort, UserQueryPort,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||||
|
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||||
|
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||||
|
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||||
|
pub user_command: Arc<dyn UserCommandPort>,
|
||||||
|
pub user_query: Arc<dyn UserQueryPort>,
|
||||||
|
pub reminder_command: Arc<dyn ReminderCommandPort>,
|
||||||
|
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||||
|
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
|
||||||
|
pub refresh_session_query: Arc<dyn RefreshSessionQueryPort>,
|
||||||
|
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||||
|
pub auth_service: Arc<dyn AuthServicePort>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisherPort>,
|
||||||
|
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||||
|
pub export_port: Arc<dyn ExportPort>,
|
||||||
|
pub import_source: Arc<dyn ImportSourcePort>,
|
||||||
|
pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||||
|
pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||||
|
pub reminder_sender: Option<Arc<dyn ReminderSenderPort>>,
|
||||||
|
pub server_config: ServerConfig,
|
||||||
|
pub entry_config: EntryConfig,
|
||||||
|
pub auth_config: AuthConfig,
|
||||||
|
pub push_config: PushConfig,
|
||||||
|
pub preset_config: PresetConfig,
|
||||||
|
}
|
||||||
13
crates/adapters/importer/Cargo.toml
Normal file
13
crates/adapters/importer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "importer"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
csv.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
133
crates/adapters/importer/src/csv_generic.rs
Normal file
133
crates/adapters/importer/src/csv_generic.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::ImportedRow;
|
||||||
|
|
||||||
|
pub struct CsvImportConfig {
|
||||||
|
pub date_column: usize,
|
||||||
|
pub time_column: usize,
|
||||||
|
pub mood_column: usize,
|
||||||
|
pub activities_column: Option<usize>,
|
||||||
|
pub note_column: Option<usize>,
|
||||||
|
pub activities_separator: String,
|
||||||
|
pub mood_mapping: Vec<(String, u8)>,
|
||||||
|
pub delimiter: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CsvImportConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
date_column: 0,
|
||||||
|
time_column: 1,
|
||||||
|
mood_column: 2,
|
||||||
|
activities_column: Some(3),
|
||||||
|
note_column: Some(4),
|
||||||
|
activities_separator: "|".into(),
|
||||||
|
mood_mapping: vec![
|
||||||
|
("1".into(), 1),
|
||||||
|
("2".into(), 2),
|
||||||
|
("3".into(), 3),
|
||||||
|
("4".into(), 4),
|
||||||
|
("5".into(), 5),
|
||||||
|
("awful".into(), 1),
|
||||||
|
("bad".into(), 2),
|
||||||
|
("meh".into(), 3),
|
||||||
|
("good".into(), 4),
|
||||||
|
("rad".into(), 5),
|
||||||
|
],
|
||||||
|
delimiter: b',',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CsvImportAdapter {
|
||||||
|
config: CsvImportConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CsvImportAdapter {
|
||||||
|
pub fn new(config: CsvImportConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_mood(&self, value: &str) -> Result<u8, DomainError> {
|
||||||
|
let normalized = value.trim().to_lowercase();
|
||||||
|
|
||||||
|
if let Ok(num) = normalized.parse::<u8>()
|
||||||
|
&& (1..=5).contains(&num)
|
||||||
|
{
|
||||||
|
return Ok(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config
|
||||||
|
.mood_mapping
|
||||||
|
.iter()
|
||||||
|
.find(|(label, _)| label.to_lowercase() == normalized)
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput(format!("unknown mood value: {value}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ImportSourcePort for CsvImportAdapter {
|
||||||
|
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||||
|
let content = std::str::from_utf8(data)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
|
||||||
|
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.delimiter(self.config.delimiter)
|
||||||
|
.from_reader(content.as_bytes());
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
|
||||||
|
for result in reader.records() {
|
||||||
|
let record =
|
||||||
|
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||||
|
|
||||||
|
let mood_str = record.get(self.config.mood_column).unwrap_or("").trim();
|
||||||
|
let mood = self.map_mood(mood_str)?;
|
||||||
|
|
||||||
|
let date = record
|
||||||
|
.get(self.config.date_column)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let time = record
|
||||||
|
.get(self.config.time_column)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let activities = match self.config.activities_column {
|
||||||
|
Some(col) => {
|
||||||
|
let raw = record.get(col).unwrap_or("").trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
raw.split(&self.config.activities_separator)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = self
|
||||||
|
.config
|
||||||
|
.note_column
|
||||||
|
.and_then(|col| record.get(col))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
rows.push(ImportedRow {
|
||||||
|
mood,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
activities,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(row_count = rows.len(), "parsed CSV import");
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
crates/adapters/importer/src/daylio.rs
Normal file
69
crates/adapters/importer/src/daylio.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::ImportedRow;
|
||||||
|
|
||||||
|
pub struct DaylioImportAdapter;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ImportSourcePort for DaylioImportAdapter {
|
||||||
|
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||||
|
let content = std::str::from_utf8(data)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
|
||||||
|
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.from_reader(content.as_bytes());
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
|
||||||
|
for result in reader.records() {
|
||||||
|
let record =
|
||||||
|
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||||
|
|
||||||
|
let mood_str = record.get(4).unwrap_or("").trim();
|
||||||
|
let mood = map_daylio_mood(mood_str)?;
|
||||||
|
|
||||||
|
let date = record.get(0).unwrap_or("").trim().to_string();
|
||||||
|
let time = record.get(3).unwrap_or("").trim().to_string();
|
||||||
|
|
||||||
|
let activities_str = record.get(5).unwrap_or("").trim();
|
||||||
|
let activities = if activities_str.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
activities_str
|
||||||
|
.split('|')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = record
|
||||||
|
.get(7)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
rows.push(ImportedRow {
|
||||||
|
mood,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
activities,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(row_count = rows.len(), "parsed Daylio export");
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_daylio_mood(mood: &str) -> Result<u8, DomainError> {
|
||||||
|
match mood.to_lowercase().as_str() {
|
||||||
|
"awful" => Ok(1),
|
||||||
|
"bad" => Ok(2),
|
||||||
|
"meh" => Ok(3),
|
||||||
|
"good" => Ok(4),
|
||||||
|
"rad" => Ok(5),
|
||||||
|
other => Err(DomainError::InvalidInput(format!(
|
||||||
|
"unknown Daylio mood: {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::ImportedRow;
|
||||||
|
|
||||||
|
pub struct KmoodZipImportAdapter;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExportData {
|
||||||
|
entries: Vec<EntryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EntryData {
|
||||||
|
mood: u8,
|
||||||
|
logged_at: String,
|
||||||
|
activities: Vec<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
photos: Vec<String>,
|
||||||
|
voice_memos: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KmoodImportEntry {
|
||||||
|
pub row: ImportedRow,
|
||||||
|
pub photo_ids: Vec<String>,
|
||||||
|
pub voice_memo_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KmoodImportResult {
|
||||||
|
pub entries: Vec<KmoodImportEntry>,
|
||||||
|
pub photos: HashMap<String, Vec<u8>>,
|
||||||
|
pub voice_memos: HashMap<String, Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KmoodZipImportAdapter {
|
||||||
|
pub fn extract(data: &[u8]) -> Result<KmoodImportResult, DomainError> {
|
||||||
|
let cursor = Cursor::new(data);
|
||||||
|
let mut archive = ZipArchive::new(cursor)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid zip file: {e}")))?;
|
||||||
|
|
||||||
|
let json_data = read_file_from_zip(&mut archive, "data.json")?
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("missing data.json in archive".into()))?;
|
||||||
|
|
||||||
|
let export: ExportData = serde_json::from_slice(&json_data)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid data.json: {e}")))?;
|
||||||
|
|
||||||
|
let mut photos = HashMap::new();
|
||||||
|
let mut voice_memos = HashMap::new();
|
||||||
|
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("zip read error: {e}")))?;
|
||||||
|
|
||||||
|
let name = file.name().to_string();
|
||||||
|
|
||||||
|
if let Some(id) = name.strip_prefix("photos/")
|
||||||
|
&& !id.is_empty()
|
||||||
|
{
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to read photo: {e}")))?;
|
||||||
|
photos.insert(id.to_string(), buf);
|
||||||
|
} else if let Some(id) = name.strip_prefix("voice_memos/")
|
||||||
|
&& !id.is_empty()
|
||||||
|
{
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf).map_err(|e| {
|
||||||
|
DomainError::InvalidInput(format!("failed to read voice memo: {e}"))
|
||||||
|
})?;
|
||||||
|
voice_memos.insert(id.to_string(), buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Vec<KmoodImportEntry> = export
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let (date, time) = split_datetime(&e.logged_at);
|
||||||
|
KmoodImportEntry {
|
||||||
|
row: ImportedRow {
|
||||||
|
mood: e.mood,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
activities: e.activities,
|
||||||
|
note: e.content,
|
||||||
|
},
|
||||||
|
photo_ids: e.photos,
|
||||||
|
voice_memo_ids: e.voice_memos,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
entries = entries.len(),
|
||||||
|
photos = photos.len(),
|
||||||
|
voice_memos = voice_memos.len(),
|
||||||
|
"extracted k-mood archive"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(KmoodImportResult {
|
||||||
|
entries,
|
||||||
|
photos,
|
||||||
|
voice_memos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_from_zip(
|
||||||
|
archive: &mut ZipArchive<Cursor<&[u8]>>,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, DomainError> {
|
||||||
|
let mut file = match archive.by_name(name) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||||
|
Err(e) => return Err(DomainError::InvalidInput(format!("zip error: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to read {name}: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Some(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_datetime(rfc3339: &str) -> (String, String) {
|
||||||
|
if let Some(t_pos) = rfc3339.find('T') {
|
||||||
|
let date = rfc3339[..t_pos].to_string();
|
||||||
|
let time = rfc3339[t_pos + 1..].to_string();
|
||||||
|
(date, time)
|
||||||
|
} else {
|
||||||
|
(rfc3339.to_string(), "00:00".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/adapters/importer/src/lib.rs
Normal file
7
crates/adapters/importer/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod csv_generic;
|
||||||
|
mod daylio;
|
||||||
|
mod kmood_zip;
|
||||||
|
|
||||||
|
pub use csv_generic::{CsvImportAdapter, CsvImportConfig};
|
||||||
|
pub use daylio::DaylioImportAdapter;
|
||||||
|
pub use kmood_zip::{KmoodImportEntry, KmoodImportResult, KmoodZipImportAdapter};
|
||||||
13
crates/adapters/sqlite/Cargo.toml
Normal file
13
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "sqlite"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
config.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
30
crates/adapters/sqlite/src/db.rs
Normal file
30
crates/adapters/sqlite/src/db.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
|
||||||
|
const MIGRATIONS: &[&str] = &[
|
||||||
|
include_str!("migrations/001_initial.sql"),
|
||||||
|
include_str!("migrations/002_push_subscriptions.sql"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
let options: SqliteConnectOptions = database_url
|
||||||
|
.parse::<SqliteConnectOptions>()?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true);
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||||
|
for migration in MIGRATIONS {
|
||||||
|
sqlx::raw_sql(*migration).execute(pool).await?;
|
||||||
|
}
|
||||||
|
tracing::info!("database migrations completed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
crates/adapters/sqlite/src/lib.rs
Normal file
4
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mod db;
|
||||||
|
pub mod repositories;
|
||||||
|
|
||||||
|
pub use db::{create_pool, run_migrations};
|
||||||
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
timezone TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'User',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mood_entries (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
mood INTEGER NOT NULL,
|
||||||
|
logged_at TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_activities (
|
||||||
|
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||||
|
activity_id TEXT NOT NULL REFERENCES activities(id),
|
||||||
|
PRIMARY KEY (entry_id, activity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||||
|
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||||
|
photo_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entry_id, photo_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_voice_memos (
|
||||||
|
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||||
|
voice_memo_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entry_id, voice_memo_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
monday TEXT,
|
||||||
|
tuesday TEXT,
|
||||||
|
wednesday TEXT,
|
||||||
|
thursday TEXT,
|
||||||
|
friday TEXT,
|
||||||
|
saturday TEXT,
|
||||||
|
sunday TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activities_user_id ON activities(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mood_entries_user_id ON mood_entries(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mood_entries_logged_at ON mood_entries(logged_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_user_id ON reminders(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||||
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::activity::{Activity, ActivityId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqliteActivityCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteActivityCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ActivityCommandPort for SqliteActivityCommandRepository {
|
||||||
|
async fn save(&self, activity: &Activity) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO activities (id, user_id, name, category, archived, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name, category = excluded.category,
|
||||||
|
archived = excluded.archived",
|
||||||
|
)
|
||||||
|
.bind(activity.id().value().to_string())
|
||||||
|
.bind(activity.user_id().value().to_string())
|
||||||
|
.bind(activity.name().value())
|
||||||
|
.bind(activity.category().map(|c| c.value().to_string()))
|
||||||
|
.bind(activity.is_archived())
|
||||||
|
.bind(activity.created_at().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM activities WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
pub use command::SqliteActivityCommandRepository;
|
||||||
|
pub use query::SqliteActivityQueryRepository;
|
||||||
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::activity::{Activity, ActivityId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::ActivityRow;
|
||||||
|
|
||||||
|
pub struct SqliteActivityQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteActivityQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ActivityQueryPort for SqliteActivityQueryRepository {
|
||||||
|
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, ActivityRow>("SELECT * FROM activities WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(ActivityRow::into_domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||||
|
"SELECT * FROM activities WHERE user_id = ? ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||||
|
"SELECT * FROM activities WHERE user_id = ? AND archived = 0 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use domain::activity::{Activity, ActivityId, ActivityName, CategoryName};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct ActivityRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub archived: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityRow {
|
||||||
|
pub fn into_domain(self) -> Activity {
|
||||||
|
Activity::from_persistence(
|
||||||
|
ActivityId::from_uuid(self.id.parse().unwrap()),
|
||||||
|
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||||
|
ActivityName::from_persistence(self.name),
|
||||||
|
self.category.map(CategoryName::from_persistence),
|
||||||
|
self.archived,
|
||||||
|
self.created_at.parse().unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::entry::{DateRange, MoodEntry};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::entry::rows::{EntryRow, hydrate_batch};
|
||||||
|
use super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqliteCascadeDeleteRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteCascadeDeleteRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||||
|
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
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 refresh_sessions WHERE user_id = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM push_subscriptions 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)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_entries_in_range(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
range: &DateRange,
|
||||||
|
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, EntryRow>(
|
||||||
|
"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())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
let entries = hydrate_batch(&self.pool, rows).await?;
|
||||||
|
|
||||||
|
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"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())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::activity::ActivityId;
|
||||||
|
use domain::entry::{DateRange, MoodEntry, MoodEntryId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqliteEntryCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteEntryCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_relations(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||||
|
let entry_id = entry.id().value().to_string();
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM entry_activities WHERE entry_id = ?")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
for activity_id in entry.activities() {
|
||||||
|
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(activity_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM entry_photos WHERE entry_id = ?")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
for photo_id in entry.photos() {
|
||||||
|
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(photo_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM entry_voice_memos WHERE entry_id = ?")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
for voice_memo_id in entry.voice_memos() {
|
||||||
|
sqlx::query("INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(voice_memo_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||||
|
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||||
|
content = excluded.content, updated_at = excluded.updated_at"
|
||||||
|
)
|
||||||
|
.bind(entry.id().value().to_string())
|
||||||
|
.bind(entry.user_id().value().to_string())
|
||||||
|
.bind(entry.mood().value() as i32)
|
||||||
|
.bind(entry.logged_at().to_rfc3339())
|
||||||
|
.bind(entry.content().map(|c| c.value().to_string()))
|
||||||
|
.bind(entry.created_at().to_rfc3339())
|
||||||
|
.bind(entry.updated_at().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
self.save_relations(entry).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry_id = entry.id().value().to_string();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||||
|
content = excluded.content, updated_at = excluded.updated_at"
|
||||||
|
)
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(entry.user_id().value().to_string())
|
||||||
|
.bind(entry.mood().value() as i32)
|
||||||
|
.bind(entry.logged_at().to_rfc3339())
|
||||||
|
.bind(entry.content().map(|c| c.value().to_string()))
|
||||||
|
.bind(entry.created_at().to_rfc3339())
|
||||||
|
.bind(entry.updated_at().to_rfc3339())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
for activity_id in entry.activities() {
|
||||||
|
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(activity_id.value().to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for photo_id in entry.photos() {
|
||||||
|
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(photo_id.value().to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for voice_memo_id in entry.voice_memos() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&entry_id)
|
||||||
|
.bind(voice_memo_id.value().to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM mood_entries WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_date_range(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
range: &DateRange,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"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())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_activity(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
old_activity_id: &ActivityId,
|
||||||
|
new_activity_id: &ActivityId,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE entry_activities SET activity_id = ?
|
||||||
|
WHERE activity_id = ? AND entry_id IN (SELECT id FROM mood_entries WHERE user_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(new_activity_id.value().to_string())
|
||||||
|
.bind(old_activity_id.value().to_string())
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
pub(crate) mod rows;
|
||||||
|
|
||||||
|
pub use command::SqliteEntryCommandRepository;
|
||||||
|
pub use query::SqliteEntryQueryRepository;
|
||||||
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::activity::ActivityId;
|
||||||
|
use domain::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::{EntryRow, hydrate_batch, hydrate_single};
|
||||||
|
|
||||||
|
pub struct SqliteEntryQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteEntryQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
|
||||||
|
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, EntryRow>("SELECT * FROM mood_entries WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(Some(hydrate_single(&self.pool, r).await?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, EntryRow>(
|
||||||
|
"SELECT * FROM mood_entries WHERE user_id = ? ORDER BY logged_at DESC LIMIT ? OFFSET ?",
|
||||||
|
)
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
hydrate_batch(&self.pool, rows).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_date_range(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
range: &DateRange,
|
||||||
|
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, EntryRow>(
|
||||||
|
"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())
|
||||||
|
.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)?;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::activity::ActivityId;
|
||||||
|
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||||
|
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData, MoodEntryId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct EntryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub mood: i32,
|
||||||
|
pub logged_at: String,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RelationRow {
|
||||||
|
entry_id: String,
|
||||||
|
related_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_to_entry(
|
||||||
|
row: EntryRow,
|
||||||
|
activity_ids: Vec<String>,
|
||||||
|
photo_ids: Vec<String>,
|
||||||
|
voice_memo_ids: Vec<String>,
|
||||||
|
) -> Result<MoodEntry, DomainError> {
|
||||||
|
Ok(MoodEntry::from_persistence(MoodEntryData {
|
||||||
|
id: MoodEntryId::from_uuid(row.id.parse().unwrap()),
|
||||||
|
user_id: UserId::from_uuid(row.user_id.parse().unwrap()),
|
||||||
|
mood: Mood::try_from(row.mood as u8)?,
|
||||||
|
logged_at: row.logged_at.parse().unwrap(),
|
||||||
|
activities: activity_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| ActivityId::from_uuid(id.parse().unwrap()))
|
||||||
|
.collect(),
|
||||||
|
content: row.content.map(Content::from_persistence),
|
||||||
|
photos: photo_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| PhotoId::from_uuid(id.parse().unwrap()))
|
||||||
|
.collect(),
|
||||||
|
voice_memos: voice_memo_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| VoiceMemoId::from_uuid(id.parse().unwrap()))
|
||||||
|
.collect(),
|
||||||
|
created_at: row.created_at.parse().unwrap(),
|
||||||
|
updated_at: row.updated_at.parse().unwrap(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn hydrate_single(pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||||
|
let entry_id = row.id.clone();
|
||||||
|
|
||||||
|
let activities: Vec<RelationRow> = sqlx::query_as(
|
||||||
|
"SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&entry_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
let photos: Vec<RelationRow> = sqlx::query_as(
|
||||||
|
"SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&entry_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
let voice_memos: Vec<RelationRow> = sqlx::query_as(
|
||||||
|
"SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&entry_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
row_to_entry(
|
||||||
|
row,
|
||||||
|
activities.into_iter().map(|r| r.related_id).collect(),
|
||||||
|
photos.into_iter().map(|r| r.related_id).collect(),
|
||||||
|
voice_memos.into_iter().map(|r| r.related_id).collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn hydrate_batch(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
rows: Vec<EntryRow>,
|
||||||
|
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||||
|
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||||
|
|
||||||
|
let activities = batch_load(
|
||||||
|
pool,
|
||||||
|
&format!("SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id IN ({placeholders})"),
|
||||||
|
&entry_ids,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let photos = batch_load(
|
||||||
|
pool,
|
||||||
|
&format!("SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id IN ({placeholders})"),
|
||||||
|
&entry_ids,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let voice_memos = batch_load(
|
||||||
|
pool,
|
||||||
|
&format!("SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id IN ({placeholders})"),
|
||||||
|
&entry_ids,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let mut entries = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let id = row.id.clone();
|
||||||
|
entries.push(row_to_entry(
|
||||||
|
row,
|
||||||
|
activities.get(&id).cloned().unwrap_or_default(),
|
||||||
|
photos.get(&id).cloned().unwrap_or_default(),
|
||||||
|
voice_memos.get(&id).cloned().unwrap_or_default(),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_load(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
sql: &str,
|
||||||
|
entry_ids: &[String],
|
||||||
|
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||||
|
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||||
|
for id in entry_ids {
|
||||||
|
query = query.bind(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = query.fetch_all(pool).await.map_err(db_err)?;
|
||||||
|
|
||||||
|
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
map.entry(row.entry_id).or_default().push(row.related_id);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
pub mod shared;
|
||||||
|
|
||||||
|
mod activity;
|
||||||
|
mod cascade;
|
||||||
|
mod entry;
|
||||||
|
mod push_subscription;
|
||||||
|
mod refresh_session;
|
||||||
|
mod reminder;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use activity::{SqliteActivityCommandRepository, SqliteActivityQueryRepository};
|
||||||
|
pub use cascade::SqliteCascadeDeleteRepository;
|
||||||
|
pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
|
||||||
|
pub use push_subscription::{
|
||||||
|
SqlitePushSubscriptionCommandRepository, SqlitePushSubscriptionQueryRepository,
|
||||||
|
};
|
||||||
|
pub use refresh_session::{
|
||||||
|
SqliteRefreshSessionCommandRepository, SqliteRefreshSessionQueryRepository,
|
||||||
|
};
|
||||||
|
pub use reminder::{SqliteReminderCommandRepository, SqliteReminderQueryRepository};
|
||||||
|
pub use user::{SqliteUserCommandRepository, SqliteUserQueryRepository};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqlitePushSubscriptionCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlitePushSubscriptionCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::PushSubscriptionCommandPort for SqlitePushSubscriptionCommandRepository {
|
||||||
|
async fn save(&self, sub: &PushSubscription) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
|
p256dh = excluded.p256dh,
|
||||||
|
auth = excluded.auth",
|
||||||
|
)
|
||||||
|
.bind(sub.id().value().to_string())
|
||||||
|
.bind(sub.user_id().value().to_string())
|
||||||
|
.bind(sub.endpoint())
|
||||||
|
.bind(sub.p256dh())
|
||||||
|
.bind(sub.auth())
|
||||||
|
.bind(sub.created_at().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM push_subscriptions WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
||||||
|
.bind(endpoint)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
pub use command::SqlitePushSubscriptionCommandRepository;
|
||||||
|
pub use query::SqlitePushSubscriptionQueryRepository;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::push::PushSubscription;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::PushSubscriptionRow;
|
||||||
|
|
||||||
|
pub struct SqlitePushSubscriptionQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlitePushSubscriptionQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::PushSubscriptionQueryPort for SqlitePushSubscriptionQueryRepository {
|
||||||
|
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||||
|
"SELECT * FROM push_subscriptions WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(|r| r.into_entity()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_endpoint(
|
||||||
|
&self,
|
||||||
|
endpoint: &str,
|
||||||
|
) -> Result<Option<PushSubscription>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||||
|
"SELECT * FROM push_subscriptions WHERE endpoint = ?",
|
||||||
|
)
|
||||||
|
.bind(endpoint)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(row.map(|r| r.into_entity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct PushSubscriptionRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub p256dh: String,
|
||||||
|
pub auth: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PushSubscriptionRow {
|
||||||
|
pub fn into_entity(self) -> PushSubscription {
|
||||||
|
PushSubscription::from_persistence(
|
||||||
|
PushSubscriptionId::from_uuid(self.id.parse().unwrap()),
|
||||||
|
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||||
|
self.endpoint,
|
||||||
|
self.p256dh,
|
||||||
|
self.auth,
|
||||||
|
self.created_at.parse().unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::auth::RefreshSession;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqliteRefreshSessionCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteRefreshSessionCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::RefreshSessionCommandPort for SqliteRefreshSessionCommandRepository {
|
||||||
|
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(session.id().value().to_string())
|
||||||
|
.bind(session.user_id().value().to_string())
|
||||||
|
.bind(session.token())
|
||||||
|
.bind(session.expires_at().to_rfc3339())
|
||||||
|
.bind(session.created_at().to_rfc3339())
|
||||||
|
.execute(&self.pool).await.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||||
|
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
pub use command::SqliteRefreshSessionCommandRepository;
|
||||||
|
pub use query::SqliteRefreshSessionQueryRepository;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::auth::RefreshSession;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::RefreshSessionRow;
|
||||||
|
|
||||||
|
pub struct SqliteRefreshSessionQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteRefreshSessionQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::RefreshSessionQueryPort for SqliteRefreshSessionQueryRepository {
|
||||||
|
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||||
|
"SELECT * FROM refresh_sessions WHERE token = ?",
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(RefreshSessionRow::into_domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use domain::auth::{RefreshSession, RefreshSessionId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct RefreshSessionRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefreshSessionRow {
|
||||||
|
pub fn into_domain(self) -> RefreshSession {
|
||||||
|
RefreshSession::from_persistence(
|
||||||
|
RefreshSessionId::from_uuid(self.id.parse().unwrap()),
|
||||||
|
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||||
|
self.token,
|
||||||
|
self.expires_at.parse().unwrap(),
|
||||||
|
self.created_at.parse().unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use chrono::Weekday;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::reminder::{Reminder, ReminderId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::format_time;
|
||||||
|
|
||||||
|
pub struct SqliteReminderCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteReminderCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
.bind(reminder.id().value().to_string())
|
||||||
|
.bind(reminder.user_id().value().to_string())
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Mon).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Tue).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Wed).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Thu).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Fri).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Sat).map(format_time))
|
||||||
|
.bind(reminder.schedule().time_for(Weekday::Sun).map(format_time))
|
||||||
|
.bind(reminder.is_enabled())
|
||||||
|
.bind(reminder.created_at().to_rfc3339())
|
||||||
|
.execute(&self.pool).await.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM reminders WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
pub use command::SqliteReminderCommandRepository;
|
||||||
|
pub use query::SqliteReminderQueryRepository;
|
||||||
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::reminder::{Reminder, ReminderId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::ReminderRow;
|
||||||
|
|
||||||
|
pub struct SqliteReminderQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteReminderQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ReminderQueryPort for SqliteReminderQueryRepository {
|
||||||
|
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(ReminderRow::into_domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE enabled = 1")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use chrono::NaiveTime;
|
||||||
|
|
||||||
|
use domain::reminder::{DaySchedule, Reminder, ReminderId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct ReminderRow {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub monday: Option<String>,
|
||||||
|
pub tuesday: Option<String>,
|
||||||
|
pub wednesday: Option<String>,
|
||||||
|
pub thursday: Option<String>,
|
||||||
|
pub friday: Option<String>,
|
||||||
|
pub saturday: Option<String>,
|
||||||
|
pub sunday: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReminderRow {
|
||||||
|
pub fn into_domain(self) -> Reminder {
|
||||||
|
let schedule = DaySchedule::from_persistence(
|
||||||
|
self.monday.and_then(|s| parse_time(&s)),
|
||||||
|
self.tuesday.and_then(|s| parse_time(&s)),
|
||||||
|
self.wednesday.and_then(|s| parse_time(&s)),
|
||||||
|
self.thursday.and_then(|s| parse_time(&s)),
|
||||||
|
self.friday.and_then(|s| parse_time(&s)),
|
||||||
|
self.saturday.and_then(|s| parse_time(&s)),
|
||||||
|
self.sunday.and_then(|s| parse_time(&s)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Reminder::from_persistence(
|
||||||
|
ReminderId::from_uuid(self.id.parse().unwrap()),
|
||||||
|
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||||
|
schedule,
|
||||||
|
self.enabled,
|
||||||
|
self.created_at.parse().unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_time(s: &str) -> Option<NaiveTime> {
|
||||||
|
NaiveTime::parse_from_str(s, "%H:%M").ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_time(t: NaiveTime) -> String {
|
||||||
|
t.format("%H:%M").to_string()
|
||||||
|
}
|
||||||
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub fn db_err(e: sqlx::Error) -> DomainError {
|
||||||
|
DomainError::InvalidInput(format!("database error: {e}"))
|
||||||
|
}
|
||||||
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::{User, UserId};
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
|
||||||
|
pub struct SqliteUserCommandRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteUserCommandRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::UserCommandPort for SqliteUserCommandRepository {
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, display_name, timezone, role, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
username = excluded.username, email = excluded.email,
|
||||||
|
password_hash = excluded.password_hash, display_name = excluded.display_name,
|
||||||
|
timezone = excluded.timezone, role = excluded.role,
|
||||||
|
updated_at = excluded.updated_at"
|
||||||
|
)
|
||||||
|
.bind(user.id().value().to_string())
|
||||||
|
.bind(user.username().value())
|
||||||
|
.bind(user.email().value())
|
||||||
|
.bind(user.password_hash().value())
|
||||||
|
.bind(user.display_name().map(|d| d.value().to_string()))
|
||||||
|
.bind(user.timezone().map(|t| t.value().to_string()))
|
||||||
|
.bind(format!("{:?}", user.role()))
|
||||||
|
.bind(user.created_at().to_rfc3339())
|
||||||
|
.bind(user.updated_at().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod command;
|
||||||
|
mod query;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
pub use command::SqliteUserCommandRepository;
|
||||||
|
pub use query::SqliteUserQueryRepository;
|
||||||
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::user::{Email, User, UserId, Username};
|
||||||
|
|
||||||
|
use super::super::shared::db_err;
|
||||||
|
use super::rows::UserRow;
|
||||||
|
|
||||||
|
pub struct SqliteUserQueryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteUserQueryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(UserRow::into_domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE username = ?")
|
||||||
|
.bind(username.value())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(UserRow::into_domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?")
|
||||||
|
.bind(email.value())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
Ok(row.map(UserRow::into_domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use domain::user::{
|
||||||
|
DisplayName, Email, PasswordHash, Timezone, User, UserData, UserId, UserRole, Username,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct UserRow {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRow {
|
||||||
|
pub fn into_domain(self) -> User {
|
||||||
|
let role = match self.role.as_str() {
|
||||||
|
"Admin" => UserRole::Admin,
|
||||||
|
_ => UserRole::User,
|
||||||
|
};
|
||||||
|
|
||||||
|
User::from_persistence(UserData {
|
||||||
|
id: UserId::from_uuid(self.id.parse().unwrap()),
|
||||||
|
username: Username::from_persistence(self.username),
|
||||||
|
email: Email::from_persistence(self.email),
|
||||||
|
password_hash: PasswordHash::new(self.password_hash),
|
||||||
|
display_name: self.display_name.map(DisplayName::from_persistence),
|
||||||
|
timezone: self.timezone.map(Timezone::from_persistence),
|
||||||
|
role,
|
||||||
|
created_at: self.created_at.parse().unwrap(),
|
||||||
|
updated_at: self.updated_at.parse().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/adapters/storage/Cargo.toml
Normal file
14
crates/adapters/storage/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "storage"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
config.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
object_store.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
futures-util = "0.3"
|
||||||
|
tracing.workspace = true
|
||||||
30
crates/adapters/storage/src/lib.rs
Normal file
30
crates/adapters/storage/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
mod media_storage;
|
||||||
|
|
||||||
|
pub use media_storage::ObjectStoreMediaStorage;
|
||||||
|
|
||||||
|
use config::{MediaBackend, StorageConfig};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub fn create_media_storage(
|
||||||
|
config: &StorageConfig,
|
||||||
|
) -> Result<ObjectStoreMediaStorage, DomainError> {
|
||||||
|
match &config.media {
|
||||||
|
MediaBackend::Local { media_dir } => {
|
||||||
|
let base = std::path::Path::new(&config.data_dir).join(media_dir);
|
||||||
|
ObjectStoreMediaStorage::local(base)
|
||||||
|
}
|
||||||
|
MediaBackend::S3 {
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
} => ObjectStoreMediaStorage::s3(
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
endpoint.as_deref(),
|
||||||
|
access_key.as_deref(),
|
||||||
|
secret_key.as_deref(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
177
crates/adapters/storage/src/media_storage.rs
Normal file
177
crates/adapters/storage/src/media_storage.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use object_store::aws::AmazonS3Builder;
|
||||||
|
use object_store::local::LocalFileSystem;
|
||||||
|
use object_store::path::Path;
|
||||||
|
use object_store::{GetOptions, ObjectStore, PutOptions, PutPayload};
|
||||||
|
|
||||||
|
use domain::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::MediaFile;
|
||||||
|
|
||||||
|
pub struct ObjectStoreMediaStorage {
|
||||||
|
store: Arc<dyn ObjectStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectStoreMediaStorage {
|
||||||
|
pub fn local(base_path: std::path::PathBuf) -> Result<Self, DomainError> {
|
||||||
|
std::fs::create_dir_all(&base_path).map_err(|e| {
|
||||||
|
DomainError::InvalidInput(format!("failed to create media directory: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let store = LocalFileSystem::new_with_prefix(base_path)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to init local storage: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
store: Arc::new(store),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn s3(
|
||||||
|
bucket: &str,
|
||||||
|
region: &str,
|
||||||
|
endpoint: Option<&str>,
|
||||||
|
access_key: Option<&str>,
|
||||||
|
secret_key: Option<&str>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let mut builder = AmazonS3Builder::new()
|
||||||
|
.with_bucket_name(bucket)
|
||||||
|
.with_region(region);
|
||||||
|
|
||||||
|
if let Some(endpoint) = endpoint {
|
||||||
|
builder = builder
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_virtual_hosted_style_request(false);
|
||||||
|
}
|
||||||
|
if let Some(key) = access_key {
|
||||||
|
builder = builder.with_access_key_id(key);
|
||||||
|
}
|
||||||
|
if let Some(secret) = secret_key {
|
||||||
|
builder = builder.with_secret_access_key(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to init S3 storage: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
store: Arc::new(store),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_blob(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
data: &[u8],
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<uuid::Uuid, DomainError> {
|
||||||
|
let id = uuid::Uuid::new_v4();
|
||||||
|
|
||||||
|
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||||
|
let payload = PutPayload::from(Bytes::copy_from_slice(data));
|
||||||
|
self.store
|
||||||
|
.put_opts(&blob_path, payload, PutOptions::default())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to store media: {e}")))?;
|
||||||
|
|
||||||
|
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||||
|
let meta_payload = PutPayload::from(Bytes::from(content_type.to_string()));
|
||||||
|
self.store
|
||||||
|
.put_opts(&meta_path, meta_payload, PutOptions::default())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to store metadata: {e}")))?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_blob(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
id: uuid::Uuid,
|
||||||
|
) -> Result<Option<MediaFile>, DomainError> {
|
||||||
|
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||||
|
let data = match self.store.get_opts(&blob_path, GetOptions::default()).await {
|
||||||
|
Ok(result) => result
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to read media: {e}")))?
|
||||||
|
.to_vec(),
|
||||||
|
Err(object_store::Error::NotFound { .. }) => return Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(DomainError::InvalidInput(format!(
|
||||||
|
"failed to get media: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||||
|
let content_type = match self.store.get_opts(&meta_path, GetOptions::default()).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let bytes = result.bytes().await.unwrap_or_default();
|
||||||
|
let ct_str = String::from_utf8(bytes.to_vec()).unwrap_or_default();
|
||||||
|
ContentType::from_persistence(ct_str)
|
||||||
|
}
|
||||||
|
_ => ContentType::from_persistence("application/octet-stream".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(MediaFile { data, content_type }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_blob(&self, prefix: &str, id: uuid::Uuid) -> Result<(), DomainError> {
|
||||||
|
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||||
|
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||||
|
|
||||||
|
let stream = futures_util::stream::iter(vec![Ok(blob_path), Ok(meta_path)]);
|
||||||
|
let results: Vec<_> = self.store.delete_stream(Box::pin(stream)).collect().await;
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Err(object_store::Error::NotFound { .. }) => {}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(DomainError::InvalidInput(format!(
|
||||||
|
"failed to delete media: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::MediaStoragePort for ObjectStoreMediaStorage {
|
||||||
|
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError> {
|
||||||
|
let id = self
|
||||||
|
.store_blob("photos", upload.data(), upload.content_type().value())
|
||||||
|
.await?;
|
||||||
|
Ok(PhotoId::from_uuid(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
|
||||||
|
let id = self
|
||||||
|
.store_blob("voice_memos", upload.data(), upload.content_type().value())
|
||||||
|
.await?;
|
||||||
|
Ok(VoiceMemoId::from_uuid(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError> {
|
||||||
|
self.get_blob("photos", id.value()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
|
||||||
|
self.get_blob("voice_memos", id.value()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError> {
|
||||||
|
self.remove_blob("photos", id.value()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError> {
|
||||||
|
self.remove_blob("voice_memos", id.value()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/adapters/web-push/Cargo.toml
Normal file
13
crates/adapters/web-push/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "web-push-adapter"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
config = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
web-push = "0.11"
|
||||||
|
serde_json = { workspace = true }
|
||||||
129
crates/adapters/web-push/src/lib.rs
Normal file
129
crates/adapters/web-push/src/lib.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use web_push::{
|
||||||
|
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient,
|
||||||
|
WebPushMessageBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
use config::PushConfig;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::PushSubscriptionQueryPort;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
pub struct WebPushSender {
|
||||||
|
client: IsahcWebPushClient,
|
||||||
|
vapid_private_key: Vec<u8>,
|
||||||
|
vapid_subject: String,
|
||||||
|
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebPushSender {
|
||||||
|
pub fn new(
|
||||||
|
config: &PushConfig,
|
||||||
|
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let private_key = config
|
||||||
|
.vapid_private_key
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?;
|
||||||
|
|
||||||
|
let subject = config
|
||||||
|
.vapid_subject
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("vapid_subject is required".into()))?;
|
||||||
|
|
||||||
|
let decoded = base64_decode(private_key)?;
|
||||||
|
|
||||||
|
let client = IsahcWebPushClient::new()
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("failed to create push client: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
vapid_private_key: decoded,
|
||||||
|
vapid_subject: subject.to_string(),
|
||||||
|
subscription_query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key_base64(config: &PushConfig) -> Result<String, DomainError> {
|
||||||
|
let private_key = config
|
||||||
|
.vapid_private_key
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?;
|
||||||
|
|
||||||
|
let decoded = base64_decode(private_key)?;
|
||||||
|
|
||||||
|
let sig_builder = VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&decoded))
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid VAPID key: {e}")))?;
|
||||||
|
|
||||||
|
let public_key = sig_builder.get_public_key();
|
||||||
|
Ok(base64_url_encode(&public_key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_decode(input: &str) -> Result<Vec<u8>, DomainError> {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(input)
|
||||||
|
.map_err(|e| DomainError::InvalidInput(format!("invalid base64: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_url_encode(input: &[u8]) -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::ReminderSenderPort for WebPushSender {
|
||||||
|
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
let subscriptions = self.subscription_query.find_by_user(user_id).await?;
|
||||||
|
|
||||||
|
if subscriptions.is_empty() {
|
||||||
|
tracing::debug!(%user_id, "no push subscriptions, skipping");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"title": "K-Mood",
|
||||||
|
"body": "How are you feeling right now?",
|
||||||
|
"url": "/"
|
||||||
|
});
|
||||||
|
let payload_str = payload.to_string();
|
||||||
|
|
||||||
|
for sub in &subscriptions {
|
||||||
|
let subscription_info = SubscriptionInfo::new(sub.endpoint(), sub.p256dh(), sub.auth());
|
||||||
|
|
||||||
|
let mut sig_builder = VapidSignatureBuilder::from_pem(
|
||||||
|
std::io::Cursor::new(&self.vapid_private_key),
|
||||||
|
&subscription_info,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InvalidInput(format!("failed to build VAPID signature: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sig_builder.add_claim("sub", &*self.vapid_subject);
|
||||||
|
let signature = sig_builder.build().map_err(|e| {
|
||||||
|
DomainError::InvalidInput(format!("failed to sign push message: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||||
|
builder.set_payload(ContentEncoding::Aes128Gcm, payload_str.as_bytes());
|
||||||
|
builder.set_vapid_signature(signature);
|
||||||
|
|
||||||
|
let message = builder.build().map_err(|e| {
|
||||||
|
DomainError::InvalidInput(format!("failed to build push message: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match self.client.send(message).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(%user_id, endpoint = sub.endpoint(), "push notification sent");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%user_id, endpoint = sub.endpoint(), error = %e, "failed to send push");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/api-types/Cargo.toml
Normal file
14
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-types"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
application.workspace = true
|
||||||
|
config.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
10
crates/api-types/src/errors.rs
Normal file
10
crates/api-types/src/errors.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiValidationError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Domain(#[from] DomainError),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
4
crates/api-types/src/lib.rs
Normal file
4
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod errors;
|
||||||
|
pub mod mappers;
|
||||||
|
pub mod requests;
|
||||||
|
pub mod responses;
|
||||||
66
crates/api-types/src/mappers/activity.rs
Normal file
66
crates/api-types/src/mappers/activity.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use domain::activity::{Activity, ActivityId, ActivityName, CategoryName};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use application::activity::commands::{
|
||||||
|
CreateActivityCommand, RenameActivityCommand, SetCategoryCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::errors::ApiValidationError;
|
||||||
|
use crate::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest};
|
||||||
|
use crate::responses::ActivityResponse;
|
||||||
|
|
||||||
|
impl CreateActivityRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<CreateActivityCommand, ApiValidationError> {
|
||||||
|
let name = ActivityName::new(self.name)?;
|
||||||
|
let category = self.category.map(CategoryName::new).transpose()?;
|
||||||
|
|
||||||
|
Ok(CreateActivityCommand {
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameActivityRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
activity_id: ActivityId,
|
||||||
|
) -> Result<RenameActivityCommand, ApiValidationError> {
|
||||||
|
let new_name = ActivityName::new(self.new_name)?;
|
||||||
|
|
||||||
|
Ok(RenameActivityCommand {
|
||||||
|
activity_id,
|
||||||
|
new_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetCategoryRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
activity_id: ActivityId,
|
||||||
|
) -> Result<SetCategoryCommand, ApiValidationError> {
|
||||||
|
let category = self.category.map(CategoryName::new).transpose()?;
|
||||||
|
|
||||||
|
Ok(SetCategoryCommand {
|
||||||
|
activity_id,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Activity> for ActivityResponse {
|
||||||
|
fn from(activity: Activity) -> Self {
|
||||||
|
Self {
|
||||||
|
id: activity.id().value(),
|
||||||
|
name: activity.name().value().to_string(),
|
||||||
|
category: activity.category().map(|c| c.value().to_string()),
|
||||||
|
archived: activity.is_archived(),
|
||||||
|
created_at: *activity.created_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
crates/api-types/src/mappers/bulk.rs
Normal file
39
crates/api-types/src/mappers/bulk.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use domain::activity::ActivityId;
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use application::import::use_cases::import_entries::ImportResult;
|
||||||
|
|
||||||
|
use crate::errors::ApiValidationError;
|
||||||
|
use crate::requests::ReplaceActivityRequest;
|
||||||
|
use crate::responses::{CorrelationResponse, ImportResultResponse};
|
||||||
|
|
||||||
|
impl ReplaceActivityRequest {
|
||||||
|
pub fn into_parts(
|
||||||
|
self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<(UserId, ActivityId, ActivityId), ApiValidationError> {
|
||||||
|
let old = super::shared::parse_uuid(&self.old_activity_id)?.into();
|
||||||
|
let new = super::shared::parse_uuid(&self.new_activity_id)?.into();
|
||||||
|
Ok((user_id, old, new))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correlation_response(
|
||||||
|
activity_id: ActivityId,
|
||||||
|
correlation: Option<f64>,
|
||||||
|
) -> CorrelationResponse {
|
||||||
|
CorrelationResponse {
|
||||||
|
activity_id: activity_id.value(),
|
||||||
|
correlation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ImportResult> for ImportResultResponse {
|
||||||
|
fn from(result: ImportResult) -> Self {
|
||||||
|
Self {
|
||||||
|
imported: result.imported,
|
||||||
|
skipped: result.skipped,
|
||||||
|
errors: result.errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/api-types/src/mappers/calendar.rs
Normal file
14
crates/api-types/src/mappers/calendar.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use application::entry::use_cases::get_calendar::CalendarDay;
|
||||||
|
|
||||||
|
use crate::responses::{CalendarDayResponse, EntryResponse};
|
||||||
|
|
||||||
|
impl From<CalendarDay> for CalendarDayResponse {
|
||||||
|
fn from(day: CalendarDay) -> Self {
|
||||||
|
Self {
|
||||||
|
date: day.date,
|
||||||
|
dominant_mood: day.dominant_mood.map(|m| m.value()),
|
||||||
|
dominant_mood_label: day.dominant_mood.map(|m| format!("{m:?}")),
|
||||||
|
entries: day.entries.into_iter().map(EntryResponse::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
crates/api-types/src/mappers/entry.rs
Normal file
191
crates/api-types/src/mappers/entry.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use config::EntryConfig;
|
||||||
|
use domain::entry::{Content, DateRange, Mood, MoodEntry, MoodEntryId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use application::entry::commands::{CreateEntryCommand, UpdateEntryCommand};
|
||||||
|
use application::entry::queries::{
|
||||||
|
FilterByActivityQuery, FilterByMoodQuery, ListEntriesQuery, MoodStatsQuery,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::errors::ApiValidationError;
|
||||||
|
use crate::requests::{CreateEntryRequest, DateRangeParams, ListEntriesParams, UpdateEntryRequest};
|
||||||
|
use crate::responses::EntryResponse;
|
||||||
|
|
||||||
|
use super::shared::{parse_datetime, parse_uuids_as, validate_content_length};
|
||||||
|
|
||||||
|
impl CreateEntryRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
user_id: UserId,
|
||||||
|
config: &EntryConfig,
|
||||||
|
) -> Result<CreateEntryCommand, ApiValidationError> {
|
||||||
|
let mood = Mood::try_from(self.mood)?;
|
||||||
|
let logged_at = match self.logged_at {
|
||||||
|
Some(dt) => parse_datetime(&dt)?,
|
||||||
|
None => Utc::now().fixed_offset(),
|
||||||
|
};
|
||||||
|
let content = parse_content(self.content, config)?;
|
||||||
|
let activities = parse_uuids_as(
|
||||||
|
&self.activity_ids.unwrap_or_default(),
|
||||||
|
config.max_activities_per_entry,
|
||||||
|
"activities",
|
||||||
|
)?;
|
||||||
|
let photos = parse_uuids_as(
|
||||||
|
&self.photo_ids.unwrap_or_default(),
|
||||||
|
config.max_photos,
|
||||||
|
"photos",
|
||||||
|
)?;
|
||||||
|
let voice_memos = parse_uuids_as(
|
||||||
|
&self.voice_memo_ids.unwrap_or_default(),
|
||||||
|
config.max_voice_memos,
|
||||||
|
"voice memos",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(CreateEntryCommand {
|
||||||
|
user_id,
|
||||||
|
mood,
|
||||||
|
logged_at,
|
||||||
|
activities,
|
||||||
|
content,
|
||||||
|
photos,
|
||||||
|
voice_memos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateEntryRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
entry_id: MoodEntryId,
|
||||||
|
config: &EntryConfig,
|
||||||
|
) -> Result<UpdateEntryCommand, ApiValidationError> {
|
||||||
|
let mood = Mood::try_from(self.mood)?;
|
||||||
|
let logged_at = match self.logged_at {
|
||||||
|
Some(dt) => parse_datetime(&dt)?,
|
||||||
|
None => Utc::now().fixed_offset(),
|
||||||
|
};
|
||||||
|
let content = parse_content(self.content, config)?;
|
||||||
|
let activities = parse_uuids_as(
|
||||||
|
&self.activity_ids.unwrap_or_default(),
|
||||||
|
config.max_activities_per_entry,
|
||||||
|
"activities",
|
||||||
|
)?;
|
||||||
|
let photos = parse_uuids_as(
|
||||||
|
&self.photo_ids.unwrap_or_default(),
|
||||||
|
config.max_photos,
|
||||||
|
"photos",
|
||||||
|
)?;
|
||||||
|
let voice_memos = parse_uuids_as(
|
||||||
|
&self.voice_memo_ids.unwrap_or_default(),
|
||||||
|
config.max_voice_memos,
|
||||||
|
"voice memos",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(UpdateEntryCommand {
|
||||||
|
entry_id,
|
||||||
|
mood,
|
||||||
|
logged_at,
|
||||||
|
activities,
|
||||||
|
content,
|
||||||
|
photos,
|
||||||
|
voice_memos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListEntriesParams {
|
||||||
|
pub fn into_query(self, user_id: UserId) -> Result<ListEntriesQuery, ApiValidationError> {
|
||||||
|
let range = match (self.from, self.to) {
|
||||||
|
(Some(from), Some(to)) => Some(DateRange::new(
|
||||||
|
parse_datetime(&from)?,
|
||||||
|
parse_datetime(&to)?,
|
||||||
|
)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Ok(ListEntriesQuery {
|
||||||
|
user_id,
|
||||||
|
range,
|
||||||
|
limit: self.limit,
|
||||||
|
offset: self.offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DateRangeParams {
|
||||||
|
pub fn into_date_range(self) -> Result<DateRange, ApiValidationError> {
|
||||||
|
let from = parse_datetime(&self.from)?;
|
||||||
|
let to = parse_datetime(&self.to)?;
|
||||||
|
Ok(DateRange::new(from, to)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_stats_query(self, user_id: UserId) -> Result<MoodStatsQuery, ApiValidationError> {
|
||||||
|
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<MoodEntry> for EntryResponse {
|
||||||
|
fn from(entry: MoodEntry) -> Self {
|
||||||
|
let photo_ids: Vec<uuid::Uuid> = entry.photos().iter().map(|p| p.value()).collect();
|
||||||
|
let voice_memo_ids: Vec<uuid::Uuid> =
|
||||||
|
entry.voice_memos().iter().map(|v| v.value()).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: entry.id().value(),
|
||||||
|
user_id: entry.user_id().value(),
|
||||||
|
mood: entry.mood().value(),
|
||||||
|
mood_label: format!("{:?}", entry.mood()),
|
||||||
|
logged_at: *entry.logged_at(),
|
||||||
|
activities: entry.activities().iter().map(|a| a.value()).collect(),
|
||||||
|
content: entry.content().map(|c| c.value().to_string()),
|
||||||
|
photo_urls: photo_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| format!("/api/v1/media/photos/{id}"))
|
||||||
|
.collect(),
|
||||||
|
photos: photo_ids,
|
||||||
|
voice_memo_urls: voice_memo_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| format!("/api/v1/media/voice-memos/{id}"))
|
||||||
|
.collect(),
|
||||||
|
voice_memos: voice_memo_ids,
|
||||||
|
created_at: *entry.created_at(),
|
||||||
|
updated_at: *entry.updated_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_content(
|
||||||
|
content: Option<String>,
|
||||||
|
config: &EntryConfig,
|
||||||
|
) -> Result<Option<Content>, ApiValidationError> {
|
||||||
|
content
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|text| {
|
||||||
|
validate_content_length(&text, config.max_content_length)?;
|
||||||
|
Content::new(text).map_err(ApiValidationError::from)
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
15
crates/api-types/src/mappers/media.rs
Normal file
15
crates/api-types/src/mappers/media.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||||
|
|
||||||
|
use crate::responses::MediaIdResponse;
|
||||||
|
|
||||||
|
impl From<PhotoId> for MediaIdResponse {
|
||||||
|
fn from(id: PhotoId) -> Self {
|
||||||
|
Self { id: id.value() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VoiceMemoId> for MediaIdResponse {
|
||||||
|
fn from(id: VoiceMemoId) -> Self {
|
||||||
|
Self { id: id.value() }
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/api-types/src/mappers/mod.rs
Normal file
11
crates/api-types/src/mappers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mod activity;
|
||||||
|
mod bulk;
|
||||||
|
mod calendar;
|
||||||
|
mod entry;
|
||||||
|
mod media;
|
||||||
|
mod reminder;
|
||||||
|
pub mod shared;
|
||||||
|
mod stats;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use bulk::correlation_response;
|
||||||
96
crates/api-types/src/mappers/reminder.rs
Normal file
96
crates/api-types/src/mappers/reminder.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use chrono::{NaiveTime, Weekday};
|
||||||
|
|
||||||
|
use domain::reminder::{DaySchedule, Reminder, ReminderId};
|
||||||
|
use domain::user::UserId;
|
||||||
|
|
||||||
|
use application::reminder::commands::{CreateReminderCommand, UpdateReminderCommand};
|
||||||
|
|
||||||
|
use crate::errors::ApiValidationError;
|
||||||
|
use crate::requests::{CreateReminderRequest, UpdateReminderRequest};
|
||||||
|
use crate::responses::{DayScheduleResponse, ReminderResponse};
|
||||||
|
|
||||||
|
impl CreateReminderRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<CreateReminderCommand, ApiValidationError> {
|
||||||
|
let schedule = self.into_schedule()?;
|
||||||
|
|
||||||
|
Ok(CreateReminderCommand { user_id, schedule })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_schedule(self) -> Result<DaySchedule, ApiValidationError> {
|
||||||
|
let mut schedule = DaySchedule::new();
|
||||||
|
|
||||||
|
set_day(&mut schedule, Weekday::Mon, &self.monday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Tue, &self.tuesday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Wed, &self.wednesday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Thu, &self.thursday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Fri, &self.friday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Sat, &self.saturday)?;
|
||||||
|
set_day(&mut schedule, Weekday::Sun, &self.sunday)?;
|
||||||
|
|
||||||
|
Ok(schedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateReminderRequest {
|
||||||
|
pub fn into_command(
|
||||||
|
self,
|
||||||
|
reminder_id: ReminderId,
|
||||||
|
) -> Result<UpdateReminderCommand, ApiValidationError> {
|
||||||
|
let schedule = self.schedule.map(|s| s.into_schedule()).transpose()?;
|
||||||
|
|
||||||
|
Ok(UpdateReminderCommand {
|
||||||
|
reminder_id,
|
||||||
|
schedule,
|
||||||
|
enabled: self.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Reminder> for ReminderResponse {
|
||||||
|
fn from(reminder: Reminder) -> Self {
|
||||||
|
Self {
|
||||||
|
id: reminder.id().value(),
|
||||||
|
schedule: DayScheduleResponse::from(reminder.schedule()),
|
||||||
|
enabled: reminder.is_enabled(),
|
||||||
|
created_at: *reminder.created_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DaySchedule> for DayScheduleResponse {
|
||||||
|
fn from(schedule: &DaySchedule) -> Self {
|
||||||
|
Self {
|
||||||
|
monday: schedule.time_for(Weekday::Mon).map(format_time),
|
||||||
|
tuesday: schedule.time_for(Weekday::Tue).map(format_time),
|
||||||
|
wednesday: schedule.time_for(Weekday::Wed).map(format_time),
|
||||||
|
thursday: schedule.time_for(Weekday::Thu).map(format_time),
|
||||||
|
friday: schedule.time_for(Weekday::Fri).map(format_time),
|
||||||
|
saturday: schedule.time_for(Weekday::Sat).map(format_time),
|
||||||
|
sunday: schedule.time_for(Weekday::Sun).map(format_time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_day(
|
||||||
|
schedule: &mut DaySchedule,
|
||||||
|
day: Weekday,
|
||||||
|
time_str: &Option<String>,
|
||||||
|
) -> Result<(), ApiValidationError> {
|
||||||
|
if let Some(s) = time_str {
|
||||||
|
let time = parse_time(s)?;
|
||||||
|
schedule.set_time(day, Some(time));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_time(s: &str) -> Result<NaiveTime, ApiValidationError> {
|
||||||
|
NaiveTime::parse_from_str(s, "%H:%M")
|
||||||
|
.map_err(|e| ApiValidationError::Invalid(format!("invalid time format: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(time: NaiveTime) -> String {
|
||||||
|
time.format("%H:%M").to_string()
|
||||||
|
}
|
||||||
44
crates/api-types/src/mappers/shared.rs
Normal file
44
crates/api-types/src/mappers/shared.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::errors::ApiValidationError;
|
||||||
|
|
||||||
|
pub fn parse_datetime(
|
||||||
|
s: &str,
|
||||||
|
) -> Result<chrono::DateTime<chrono::FixedOffset>, ApiValidationError> {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(s)
|
||||||
|
.map_err(|e| ApiValidationError::Invalid(format!("invalid datetime: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, ApiValidationError> {
|
||||||
|
s.parse()
|
||||||
|
.map_err(|_| ApiValidationError::Invalid(format!("invalid UUID: {s}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_uuids_as<T>(
|
||||||
|
ids: &[String],
|
||||||
|
max: usize,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Vec<T>, ApiValidationError>
|
||||||
|
where
|
||||||
|
T: From<uuid::Uuid>,
|
||||||
|
{
|
||||||
|
if ids.len() > max {
|
||||||
|
return Err(ApiValidationError::Invalid(format!(
|
||||||
|
"too many {label}, maximum is {max}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let uuid = parse_uuid(s)?;
|
||||||
|
Ok(T::from(uuid))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_content_length(text: &str, max: usize) -> Result<(), ApiValidationError> {
|
||||||
|
if text.len() > max {
|
||||||
|
return Err(ApiValidationError::Invalid(format!(
|
||||||
|
"content exceeds maximum length of {max}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
crates/api-types/src/mappers/stats.rs
Normal file
22
crates/api-types/src/mappers/stats.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use application::entry::use_cases::get_mood_stats::MoodStats;
|
||||||
|
|
||||||
|
use crate::responses::{MoodFrequency, MoodStatsResponse};
|
||||||
|
|
||||||
|
impl From<MoodStats> for MoodStatsResponse {
|
||||||
|
fn from(stats: MoodStats) -> Self {
|
||||||
|
Self {
|
||||||
|
average: stats.average,
|
||||||
|
frequency: stats
|
||||||
|
.frequency
|
||||||
|
.into_iter()
|
||||||
|
.map(|(mood, count)| MoodFrequency {
|
||||||
|
mood: mood.value(),
|
||||||
|
mood_label: format!("{mood:?}"),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
current_streak: stats.current_streak,
|
||||||
|
total_entries: stats.total_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user