diff --git a/.dockerignore b/.dockerignore index 6210bb6..f8e8bec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,10 @@ target/ -node_modules/ .git/ -.fastembed_cache/ *.db *.db-shm *.db-wal -k-notes-frontend/ -dist/ .env +data/ +# Frontend build artefacts are excluded individually; the source must reach the builder stage +k-notes-frontend/node_modules/ +k-notes-frontend/dist/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..878e24d --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ── Required ────────────────────────────────────────────────────────────────── + +# SQLite database path +DATABASE_URL=sqlite:data.db?mode=rwc + +# JWT signing secret — change this before deploying +# Generate a strong one: openssl rand -hex 32 +JWT_SECRET=change-me-before-deploying-use-openssl-rand-hex-32 + +# ── Server ──────────────────────────────────────────────────────────────────── + +HOST=0.0.0.0 +PORT=3000 + +# Comma-separated allowed CORS origins (empty = block all cross-origin requests) +CORS_ORIGINS=http://localhost:5173 + +# Set to false to disable public registration +ALLOW_REGISTRATION=true + +# ── Events ──────────────────────────────────────────────────────────────────── + +# If unset, an in-memory bus is used (fine for single-process dev). +# For the worker to process note events, set this. +# NATS_URL=nats://localhost:4222 + +# ── Smart features (optional) ───────────────────────────────────────────────── + +# Set both to enable semantic search and related notes. +# QDRANT_URL=http://localhost:6334 +# QDRANT_COLLECTION=notes + +# Set to true ONLY in the worker process — loads the fastembed model (~150 MB). +# Leave unset (or false) in the API server. +# ENABLE_EMBEDDINGS=true + +# ── Frontend ────────────────────────────────────────────────────────────────── + +# Path to the built SPA dist directory. +# Defaults to k-notes-frontend/dist (run `make build-frontend` first). +# Set to empty string to run API-only (no SPA served). +# SPA_DIR=k-notes-frontend/dist + +# ── JWT options (optional) ──────────────────────────────────────────────────── + +# JWT_EXPIRY_HOURS=24 +# JWT_ISSUER= +# JWT_AUDIENCE= diff --git a/ARCHITECTURE.MD b/ARCHITECTURE.MD deleted file mode 100644 index f91a5b3..0000000 --- a/ARCHITECTURE.MD +++ /dev/null @@ -1,90 +0,0 @@ -# K-Note - -## Executive Summary - -A high-performance, self-hosted note-taking engine designed to replicate the Google Keep experience. Built with **Rust**, it prioritizes speed, memory safety, and long-term maintainability through **Hexagonal Architecture** (Ports and Adapters). The system is designed to be "storage-agnostic" and "format-blind," allowing users to own their data in simple formats like Markdown. - -## Architecture Pattern: Hexagonal (Ports & Adapters) - -We are decoupling the **Domain Logic** (the "What") from the **Infrastructure** (the "How"). - -- **The Core:** Contains `Note` and `User` entities and the business rules (e.g., "A note cannot have more than 10 tags"). -- **Ports:** Traits that define how the core communicates with the outside world (e.g., `NoteRepository` or `AuthService`). -- **Adapters:** Concrete implementations (e.g., `SqliteNoteRepository`, `Argon2Auth`). -- **Why:** This allows us to start with REST/SQLite today and pivot to WebSockets/PostgreSQL tomorrow by simply writing a new adapter. - -## Tech Stack - -| | | | -| --- | --- | --- | -| **Component** | **Primary Recommendation** | **Alternative** | -| **Language** | **Rust** (1.75+) | Go | -| **API Framework** | **Axum** | Actix-Web | -| **Database** | **SQLite (via SQLx)** | PostgreSQL | -| **Search** | **SQLite FTS5** | Meilisearch | -| **Vector Search** | **Qdrant** | Pgvector | -| **Authentication** | **Axum Login** | OIDC (Keycloak/Authelia) | -| **Frontend** | **React + Tailwind + Shadcn/ui** | Vue + Radix | - -## Data Model (Entity Relationship) - -Code snippet - -``` -erDiagram - USER ||--o{ NOTE : owns - USER { - uuid id PK - string email - string password_hash - } - NOTE ||--o{ TAG : contains - NOTE { - uuid id PK - uuid user_id FK - string title - text content - datetime created_at - datetime updated_at - boolean is_pinned - boolean is_archived - } - TAG { - uuid id PK - string name - uuid user_id FK - } -``` - -## Folder Structure (Workspace Layout) - -We will use a Cargo Workspace to enforce strict boundaries. - -Plaintext - -``` -. -├── Cargo.toml -├── Makefile # Task runner for DB migrations, builds, and tests -├── crates -│ ├── domain # Pure logic, Traits (Ports), and Entities (No SQLx here) -│ ├── infra # Adapters: SQLx implementations, Email, Auth logic -│ └── api # Axum routes, Request/Response DTOs, Middleware -├── migrations # SQLx migration files -└── docker-compose.yaml # For easy self-hosting deployment -``` - -## API Design (MVP) - -- `GET /api/v1/notes` - List notes (with filter for archived/pinned). -- `POST /api/v1/notes` - Create a new note (Accepts Markdown). -- `PATCH /api/v1/notes/:id` - Partial updates. -- `GET /api/v1/search?q=query` - Full-text search via FTS5. -- `GET /api/v1/notes/:id/related` - Get semantically related notes. - -## Guidelines & Principles - -- **Error Handling:** Use `thiserror` for internal library errors and `anyhow` for high-level application flow. Map domain errors to specific HTTP status codes in the API layer. -- **Dependency Injection:** We will use **Atomic References (**`Arc`**)** to inject adapters into Axum state. -- **Validation:** All incoming data must be validated at the API boundary using `validator` crate before reaching the Domain. -- **DX (Developer Experience):** A `Makefile` is mandatory for one-command setups: `make setup`, `make dev`, `make test`. \ No newline at end of file diff --git a/ARCHITECTURE.mmd b/ARCHITECTURE.mmd new file mode 100644 index 0000000..c04a0a1 --- /dev/null +++ b/ARCHITECTURE.mmd @@ -0,0 +1,93 @@ +--- +title: K-Notes — Hexagonal Architecture + DDD +--- +graph TB + subgraph Binaries["Binaries (Composition Root)"] + BOOTSTRAP["bootstrap
Axum HTTP server
Routes, Handlers, SPA serving,
OpenAPI docs (/docs, /scalar)"] + WORKER["worker
Event consumer
NoteEventHandler,
Semaphore(8), graceful shutdown"] + end + + subgraph Wiring["wiring (Assembly)"] + CTX_BUILD["build_context()
Reads env vars, connects DB,
wires adapters into AppContext
"] + end + + subgraph Application["Application Layer"] + direction TB + CTX["AppContext
Repositories + Services + AppConfig"] + subgraph UseCases["Use Cases (CQRS)"] + UC_AUTH["auth
register, login"] + UC_NOTES["notes
create, update, delete, get,
list, search, pin, archive,
add_tag, remove_tag,
get_versions, get_related,
export, import"] + UC_TAGS["tags
create (get-or-create),
delete, rename, list"] + UC_SMART["smart
process_note, delete_vectors"] + end + WORKER_SVC["WorkerService
EventConsumer + EventHandler[]
Semaphore(8), JoinSet,
shutdown watch channel
"] + end + + subgraph Domain["Domain Layer (0 dependencies)"] + direction TB + subgraph Contexts["Business Contexts"] + D_NOTE["note/
Note, NoteId, NoteVersion,
NoteLink, NoteFilter,
NoteTitle, NoteColor"] + D_TAG["tag/
Tag, TagId, TagName"] + D_USER["user/
User, UserId, Email,
Password, PasswordHash"] + D_SMART["smart/
EmbeddingGenerator port
VectorStore port"] + end + subgraph Ports["Port Traits"] + P_REPOS["NoteRepository
TagRepository
UserRepository
LinkRepository"] + P_AUTH["PasswordHasher"] + P_EVENTS["EventPublisher
EventConsumer
EventHandler"] + end + EVENTS["DomainEvent
NoteCreated, NoteUpdated,
NoteDeleted

EventEnvelope (ack / nack)"] + end + + subgraph ApiTypes["api-types (0 domain deps)"] + DTO["DTOs
NoteResponse, TagResponse,
UserResponse, AuthResponse,
BackupData, ConfigResponse,
ErrorResponse, ...
"] + end + + subgraph Adapters["Adapters (implement Port Traits)"] + direction TB + subgraph Storage["Storage"] + A_SQLITE["sqlite
SQLiteNoteRepository
SqliteTagRepository
SqliteUserRepository
SqliteLinkRepository
Migrations (FTS5)
"] + end + subgraph Auth["Auth"] + A_AUTH["auth
Argon2PasswordHasher
JwtValidator (HS256)
OidcService (optional)
"] + end + subgraph Messaging["Messaging"] + A_NATS["nats
JetStream publisher + consumer
Explicit ack/nack, backoff,
DLQ via max_deliver advisory
"] + A_MEM["event-publisher-memory
Broadcast bus for dev/test"] + A_PAYLOAD["event-payload
DomainEvent ↔ JSON wire format"] + end + subgraph Smart["Smart Features"] + A_FASTEMBED["fastembed
FastEmbedGenerator
AllMiniLML6V2 (384-dim)
"] + A_QDRANT["qdrant
QdrantVectorStore
upsert, find_similar, delete
"] + end + end + + %% Dependency arrows + BOOTSTRAP -->|"uses"| Wiring + BOOTSTRAP -->|"maps to"| ApiTypes + WORKER -->|"uses"| Wiring + + Wiring -->|"assembles"| Application + Wiring -->|"constructs"| Adapters + + Application -->|"depends on"| Domain + + Adapters -.->|"implements"| Ports + + %% Key flows + BOOTSTRAP ===|"JWT Bearer"| DTO + WORKER ===|"DomainEvent"| EVENTS + + classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff + classDef app fill:#16213e,stroke:#0f3460,color:#fff + classDef adapter fill:#0f3460,stroke:#533483,color:#fff + classDef binary fill:#533483,stroke:#e94560,color:#fff + classDef api fill:#2a2a4a,stroke:#e94560,color:#fff + classDef wiring fill:#0d2137,stroke:#22c55e,color:#fff + + class Domain domain + class Application app + class Adapters adapter + class Binaries binary + class ApiTypes api + class Wiring wiring diff --git a/Cargo.lock b/Cargo.lock index 09ce3ce..656e5be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,11 +70,37 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "api-types" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "utoipa", + "uuid", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "futures", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arg_enum_proc_macro" @@ -116,18 +142,17 @@ dependencies = [ [[package]] name = "async-nats" -version = "0.45.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86dde77d8a733a9dbaf865a9eb65c72e09c88f3d14d3dd0d2aecf511920ee4fe" +checksum = "fd3bdd6ea595b2ea504500a3566071beb81125fc15d40a6f6bffa43575f64152" dependencies = [ "base64 0.22.1", "bytes", - "futures-util", + "futures", "memchr", "nkeys", "nuid", "once_cell", - "pin-project", "portable-atomic", "rand 0.8.5", "regex", @@ -143,9 +168,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", - "tokio-stream", "tokio-util", - "tokio-websockets", "tracing", "tryhard", "url", @@ -199,6 +222,24 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "domain", + "jsonwebtoken", + "openidconnect", + "reqwest", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -282,7 +323,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core 0.5.6", - "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -348,36 +388,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-login" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" -dependencies = [ - "axum 0.8.8", - "form_urlencoded", - "serde", - "subtle", - "thiserror 2.0.17", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions", - "tracing", - "urlencoding", -] - -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -450,6 +460,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "anyhow", + "application", + "auth", + "axum 0.8.8", + "dotenvy", + "presentation", + "tokio", + "tracing", + "tracing-subscriber", + "wiring", +] + [[package]] name = "built" version = "0.8.0" @@ -585,17 +611,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -859,6 +874,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -934,6 +960,18 @@ dependencies = [ "syn", ] +[[package]] +name = "domain" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "email_address", + "futures", + "thiserror 2.0.17", + "uuid", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1103,6 +1141,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-payload" +version = "0.1.0" +dependencies = [ + "domain", + "serde", + "serde_json", + "thiserror 2.0.17", + "uuid", +] + +[[package]] +name = "event-publisher-memory" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "futures", + "tokio", + "tracing", +] + [[package]] name = "exr" version = "1.74.0" @@ -1134,6 +1194,17 @@ dependencies = [ "tokenizers", ] +[[package]] +name = "fastembed-adapter" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "fastembed", + "tokio", + "tracing", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1210,6 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1574,6 +1646,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1983,34 +2061,6 @@ dependencies = [ "simple_asn1", ] -[[package]] -name = "k-core" -version = "0.1.10" -source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#7a72f5f54ad45ba82f451e90c44c0581d13194d9" -dependencies = [ - "anyhow", - "async-nats", - "async-trait", - "axum 0.8.8", - "chrono", - "fastembed", - "futures-core", - "futures-util", - "qdrant-client", - "serde", - "sqlx", - "thiserror 2.0.17", - "time", - "tokio", - "tower 0.5.2", - "tower-http", - "tower-sessions", - "tower-sessions-sqlx-store", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2070,6 +2120,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2089,7 +2148,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", - "serde", ] [[package]] @@ -2192,6 +2250,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2268,6 +2336,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nats" +version = "0.1.0" +dependencies = [ + "async-nats", + "async-stream", + "async-trait", + "domain", + "event-payload", + "futures", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -2329,100 +2412,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "notes-api" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum 0.8.8", - "chrono", - "dotenvy", - "k-core", - "notes-domain", - "notes-infra", - "password-auth", - "serde", - "serde_json", - "thiserror 2.0.17", - "time", - "tokio", - "tower 0.5.2", - "tower-http", - "tower-sessions", - "tracing", - "tracing-subscriber", - "uuid", - "validator", -] - -[[package]] -name = "notes-domain" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "email_address", - "futures-core", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "notes-infra" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum-login", - "chrono", - "futures-core", - "futures-util", - "jsonwebtoken", - "k-core", - "notes-domain", - "openidconnect", - "password-auth", - "serde", - "serde_json", - "sqlx", - "thiserror 2.0.17", - "tokio", - "tower-sessions", - "tower-sessions-sqlx-store", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "notes-worker" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "dotenvy", - "futures-util", - "k-core", - "notes-domain", - "notes-infra", - "serde", - "serde_json", - "sqlx", - "thiserror 2.0.17", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2762,18 +2751,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-auth" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" -dependencies = [ - "argon2", - "getrandom 0.2.16", - "password-hash", - "rand_core 0.6.4", -] - [[package]] name = "password-hash" version = "0.5.0" @@ -2933,6 +2910,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presentation" +version = "0.1.0" +dependencies = [ + "api-types", + "application", + "async-trait", + "auth", + "axum 0.8.8", + "chrono", + "domain", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "utoipa", + "utoipa-scalar", + "utoipa-swagger-ui", + "uuid", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2942,28 +2941,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.104" @@ -3033,6 +3010,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qdrant-adapter" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "qdrant-client", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "qdrant-client" version = "1.16.0" @@ -3441,25 +3430,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - [[package]] name = "rsa" version = "0.9.9" @@ -3480,6 +3450,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3573,6 +3577,7 @@ version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ + "ring", "rustls-pki-types", "untrusted", ] @@ -3610,6 +3615,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -4020,6 +4034,19 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sqlite" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "serde_json", + "sqlx", + "tokio", + "uuid", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -4057,17 +4084,18 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rustls", "serde", "serde_json", "sha2", "smallvec 1.15.1", "thiserror 2.0.17", - "time", "tokio", "tokio-stream", "tracing", "url", "uuid", + "webpki-roots 0.26.11", ] [[package]] @@ -4147,7 +4175,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.17", - "time", "tracing", "uuid", "whoami", @@ -4187,7 +4214,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.17", - "time", "tracing", "uuid", "whoami", @@ -4214,7 +4240,6 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror 2.0.17", - "time", "tracing", "url", "uuid", @@ -4555,27 +4580,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-websockets" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-sink", - "http", - "httparse", - "rand 0.8.5", - "ring", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tokio-util", - "webpki-roots 0.26.11", -] - [[package]] name = "tonic" version = "0.12.3" @@ -4646,22 +4650,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" -dependencies = [ - "axum-core 0.5.6", - "cookie", - "futures-util", - "http", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -4670,11 +4658,20 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.2", "tower-layer", "tower-service", @@ -4693,71 +4690,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" -dependencies = [ - "async-trait", - "axum-core 0.5.6", - "base64 0.22.1", - "futures", - "http", - "parking_lot", - "rand 0.8.5", - "serde", - "serde_json", - "thiserror 2.0.17", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - -[[package]] -name = "tower-sessions-sqlx-store" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3" -dependencies = [ - "async-trait", - "rmp-serde", - "sqlx", - "thiserror 1.0.69", - "time", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.44" @@ -4842,6 +4774,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4965,12 +4903,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -4983,6 +4915,68 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.12.1", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum 0.8.8", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum 0.8.8", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.19.0" @@ -5006,36 +5000,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "validator" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" -dependencies = [ - "idna", - "once_cell", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" -dependencies = [ - "darling 0.20.11", - "once_cell", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "valuable" version = "0.1.1" @@ -5054,6 +5018,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -5234,6 +5208,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -5541,12 +5524,44 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiring" +version = "0.1.0" +dependencies = [ + "anyhow", + "application", + "auth", + "domain", + "event-publisher-memory", + "fastembed-adapter", + "nats", + "qdrant-adapter", + "sqlite", + "tokio", + "tracing", +] + [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "worker" +version = "0.1.0" +dependencies = [ + "anyhow", + "application", + "async-trait", + "domain", + "dotenvy", + "tokio", + "tracing", + "tracing-subscriber", + "wiring", +] + [[package]] name = "writeable" version = "0.6.2" @@ -5672,12 +5687,44 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.12.1", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zmij" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 075f4b0..f18f3b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,47 @@ [workspace] resolver = "3" -members = [ "notes-api", "notes-domain", "notes-infra", "notes-worker"] \ No newline at end of file +members = [ + "crates/adapters/auth", + "crates/adapters/event-payload", + "crates/adapters/event-publisher-memory", + "crates/adapters/fastembed", + "crates/adapters/nats", + "crates/adapters/qdrant", + "crates/adapters/sqlite", + "crates/api-types", + "crates/application", + "crates/bootstrap", + "crates/domain", + "crates/presentation", + "crates/wiring", + "crates/worker", +] + +[workspace.dependencies] +api-types = { path = "crates/api-types" } +auth = { path = "crates/adapters/auth" } +event-payload = { path = "crates/adapters/event-payload" } +event-publisher-memory = { path = "crates/adapters/event-publisher-memory" } +fastembed-adapter = { path = "crates/adapters/fastembed" } +nats = { path = "crates/adapters/nats" } +qdrant-adapter = { path = "crates/adapters/qdrant" } +sqlite = { path = "crates/adapters/sqlite" } +application = { path = "crates/application" } +bootstrap = { path = "crates/bootstrap" } +domain = { path = "crates/domain" } +presentation = { path = "crates/presentation" } +wiring = { path = "crates/wiring" } +worker = { path = "crates/worker" } + +tokio = { version = "1.0", features = ["full"] } +anyhow = "1.0" +thiserror = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = { features = ["fmt", "env-filter"], version = "0.3" } +async-trait = "0.1" +rand = "0.10.1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" diff --git a/Dockerfile b/Dockerfile index 6756aa9..5b29f84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,91 @@ -FROM rust:1.92 AS builder +# ----- spa ----- +FROM oven/bun:1-slim AS spa-builder +WORKDIR /spa +COPY k-notes-frontend/package.json k-notes-frontend/bun.lock ./ +RUN bun install --frozen-lockfile +COPY k-notes-frontend/ . +RUN bun run build -WORKDIR /app -COPY . . +# ----- build ----- +FROM rust:slim-bookworm AS builder -# Build the release binary -RUN cargo build --release -p notes-api -p notes-worker +WORKDIR /build +# NOTE: before building, remove the legacy crates (notes-api, notes-domain, +# notes-infra, notes-worker) from the [workspace] members in Cargo.toml — +# they depend on k-core (a private git repo) not accessible in CI/CD. + +# Copy workspace manifests first so Docker can cache the dep-fetch layer. +COPY Cargo.toml Cargo.lock ./ + +COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml +COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml +COPY crates/adapters/event-publisher-memory/Cargo.toml crates/adapters/event-publisher-memory/Cargo.toml +COPY crates/adapters/fastembed/Cargo.toml crates/adapters/fastembed/Cargo.toml +COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml +COPY crates/adapters/qdrant/Cargo.toml crates/adapters/qdrant/Cargo.toml +COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml +COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml +COPY crates/application/Cargo.toml crates/application/Cargo.toml +COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml +COPY crates/domain/Cargo.toml crates/domain/Cargo.toml +COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml +COPY crates/wiring/Cargo.toml crates/wiring/Cargo.toml +COPY crates/worker/Cargo.toml crates/worker/Cargo.toml + +# Stub every crate so Cargo can resolve and fetch all dependencies. +RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ + xargs -I{} sh -c 'mkdir -p {}/src && printf "fn main(){}\n" > {}/src/main.rs && touch {}/src/lib.rs' + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + libstdc++-12-dev \ + && rm -rf /var/lib/apt/lists/* + +# ORT_STRATEGY=download fetches a prebuilt ONNX Runtime binary used by +# fastembed (smart features). Only initialised at runtime when QDRANT_URL is set. +ENV ORT_STRATEGY=download + +RUN cargo fetch + +# Copy real source and adapter migrations. +COPY crates/ crates/ +COPY crates/adapters/sqlite/migrations/ crates/adapters/sqlite/migrations/ + +RUN cargo build --release -p bootstrap -p worker + +# ----- runtime ----- FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app +RUN mkdir -p /app/data/model-cache -# Install OpenSSL (required for many Rust networking crates) and CA certificates -RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /build/target/release/bootstrap ./bootstrap +COPY --from=builder /build/target/release/worker ./worker -COPY --from=builder /app/target/release/notes-api . -COPY --from=builder /app/target/release/notes-worker . +# Copy ONNX Runtime shared library (required when smart features are enabled). +RUN find /build 2>/dev/null || true +COPY --from=builder /build/target/release/build/ /tmp/build/ +RUN find /tmp/build -name "libonnxruntime*" -exec cp {} /app/ \; 2>/dev/null || true \ + && rm -rf /tmp/build - -# Create data directory for SQLite -RUN mkdir -p /app/data - -ENV DATABASE_URL=sqlite:///app/data/notes.db -ENV SESSION_SECRET=supersecretchangeinproduction +# Frontend dist — served at / by bootstrap when SPA_DIR is set. +COPY --from=spa-builder /spa/dist ./frontend/dist EXPOSE 3000 -CMD ["./notes-api"] +ENV RUST_LOG=bootstrap=info,tower_http=info,worker=info +ENV LD_LIBRARY_PATH=/app +ENV SPA_DIR=/app/frontend/dist +# FastEmbed downloads the embedding model here on first use. +# Mount a persistent volume to avoid re-downloading across container restarts. +ENV FASTEMBED_CACHE_DIR=/app/data/model-cache + +CMD ["./bootstrap"] diff --git a/LICENSE b/LICENSE index a43ed35..a2f74d7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Gabriel Kaszewski +Copyright (c) 2025-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 diff --git a/Makefile b/Makefile index d516ece..548c00e 100644 --- a/Makefile +++ b/Makefile @@ -1,48 +1,66 @@ -.PHONY: setup dev test check clean migrate +.DEFAULT_GOAL := check -# Database URL for development DATABASE_URL ?= sqlite:data.db?mode=rwc +IMAGE ?= registry.gabrielkaszewski.dev/k-notes:latest -# Setup development environment -setup: - @echo "🔧 Setting up K-Notes development environment..." - cargo build --workspace - @echo "✅ Setup complete!" +# ── Local dev ───────────────────────────────────────────────────────────────── -# Run the development server +# Start the API server. JWT_SECRET is required; this default is dev-only. dev: - @echo "🚀 Starting K-Notes API server..." - DATABASE_URL=$(DATABASE_URL) cargo run --package notes-api + DATABASE_URL=$(DATABASE_URL) JWT_SECRET=dev-secret-not-for-production-use \ + cargo run -p bootstrap + +# Start the background worker (uses in-memory bus when NATS_URL is unset). +dev-worker: + DATABASE_URL=$(DATABASE_URL) cargo run -p worker + +# Start the Vite dev server for the frontend (expects the API on :3000). +dev-frontend: + cd k-notes-frontend && bun run dev + +# Build the frontend SPA into k-notes-frontend/dist. +build-frontend: + cd k-notes-frontend && bun install && bun run build + +# ── Quality checks ──────────────────────────────────────────────────────────── + +# Run the full check suite (same order as CI). +check: fmt-check clippy test + @echo "✅ All checks passed" + +fmt: + cargo fmt + +fmt-check: + cargo fmt --check + +clippy: + cargo clippy --workspace -- -D warnings -# Run all tests test: - @echo "🧪 Running tests..." cargo test --workspace -# Check code compiles -check: - cargo check --workspace +# Apply fmt + clippy fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +# ── Docker ──────────────────────────────────────────────────────────────────── + +docker-build: + docker buildx build --platform linux/amd64 -t $(IMAGE) . + +docker-push: + docker push $(IMAGE) + +deploy: + IMAGE=$(IMAGE) ./deploy.sh + +# ── Housekeeping ────────────────────────────────────────────────────────────── -# Clean build artifacts clean: cargo clean rm -f data.db data.db-wal data.db-shm -# Run migrations (done automatically on server start) -migrate: - @echo "📦 Running database migrations..." - DATABASE_URL=$(DATABASE_URL) cargo run --package notes-api -- --migrate-only 2>/dev/null || \ - (cargo run --package notes-api & sleep 2 && kill $$!) - @echo "✅ Migrations complete!" - -# Run clippy lints -lint: - cargo clippy --workspace -- -D warnings - -# Format code -fmt: - cargo fmt --all - -# Quick development cycle -quick: check test - @echo "✅ All checks passed!" +.PHONY: dev dev-worker dev-frontend build-frontend check fmt fmt-check clippy test fix \ + docker-build docker-push deploy clean diff --git a/README.md b/README.md index e7541c0..fe50154 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,188 @@ # K-Notes -A modern, self-hosted note-taking application built with performance, security, and clean architecture in mind. +A self-hosted note-taking engine built in Rust with a strict hexagonal + DDD architecture. ![K-Notes Logo](k-notes-frontend/public/logo.png) ## Features -- **Authentication**: Secure user registration and login. -- **Note Management**: Create, edit, pin, archive, and delete notes. -- **Rich Text**: Markdown support for note content. -- **Version History**: Track changes, view history, note diffs, download versions, and restore previous states. -- **Organization**: Tagging system for easy filtering. -- **Smart Features**: Semantic search and automatically generated related notes using local embeddings. -- **Theme**: Dark and Light mode support. -- **Responsive**: Mobile-friendly UI built with Tailwind CSS. -- **Architecture**: - - **Backend**: Hexagonal Architecture (Domain, Infra, API layers) in Rust. - - **Infrastructure**: Configurable database backends (SQLite, Postgres). - - **Frontend**: Modern React with TypeScript and Vite. -- **Deployment**: Full Docker support with `compose.yml`. +- **Authentication** — JWT-based login and registration (disable registration via `ALLOW_REGISTRATION=false`) +- **Note Management** — create, update, pin, archive, delete, version history +- **Markdown** — content stored and served as Markdown +- **Tagging** — user-scoped tags with get-or-create semantics +- **Search** — full-text search via SQLite FTS5 +- **Smart Features** — semantic similarity links between notes using local embeddings (fastembed) and Qdrant; enabled when `QDRANT_URL` is set +- **Export / Import** — portable JSON backup and restore +- **API Docs** — Swagger UI at `/docs`, Scalar at `/scalar` +- **SPA** — React frontend served at `/` by the same process ## Tech Stack ### Backend -- **Language**: Rust -- **Framework**: Axum -- **Database**: SQLite (Default) or Postgres (Supported via feature flag) -- **Vector Database**: Qdrant (for Smart Features) -- **Dependency Injection**: Manual wiring for clear boundaries +| Layer | Technology | +|-------|-----------| +| Language | Rust | +| HTTP | Axum 0.8 | +| Database | SQLite (sqlx + FTS5) | +| Events | NATS JetStream (prod) · in-memory bus (dev) | +| Embeddings | fastembed (AllMiniLML6V2) | +| Vector store | Qdrant | +| Auth | JWT (jsonwebtoken + argon2) | +| API docs | utoipa + Scalar + Swagger UI | ### Frontend -- **Framework**: React + Vite -- **Language**: TypeScript -- **Styling**: Tailwind CSS + Shadcn UI -- **State Management**: TanStack Query (React Query) +| Layer | Technology | +|-------|-----------| +| Framework | React + Vite | +| Language | TypeScript | +| Styling | Tailwind CSS + shadcn/ui | +| State | TanStack Query | +| Package manager | Bun | + +## Architecture + +The backend follows **Hexagonal Architecture + CQRS**: + +``` +crates/ + domain/ # Entities, value objects, ports (traits) + application/ # Use cases (commands + queries), WorkerService + adapters/ + sqlite/ # NoteRepository, TagRepository, UserRepository, LinkRepository + auth/ # Argon2PasswordHasher, JwtValidator, OidcService + nats/ # NatsEventPublisher, NatsEventConsumer (JetStream) + event-publisher-memory/ # In-memory bus for dev/test + event-payload/ # DomainEvent ↔ wire format (JSON) + fastembed/ # EmbeddingGenerator implementation + qdrant/ # VectorStore implementation + wiring/ # Assembles AppContext from env vars + presentation/ # Axum routes, OpenAPI, SPA serving + api-types/ # Request/response DTOs (no domain dependency) + bootstrap/ # HTTP server binary + worker/ # Background event processor binary +``` + +Dependency direction: `domain ← application ← {presentation, worker}`. Adapters depend on domain only. `wiring` assembles everything. ## Getting Started -### Docker (Recommended) - -Run the entire stack with a single command: +### Docker (recommended) ```bash -docker compose up -d --build +docker compose up -d ``` -- **Frontend**: http://localhost:8080 -- **Backend**: http://localhost:3000 +- **App + API**: http://localhost:3000 +- **API docs**: http://localhost:3000/docs -The frontend is automatically configured to talk to the backend. +### Local development -### Local Development +#### Prerequisites -#### Backend +- Rust stable (`rustup update stable`) +- Bun (`curl -fsSL https://bun.sh/install | bash`) -1. Navigate to the `notes-api` directory (or root). -2. Set up the environment variables (see `.env.example`). -3. Run the server: +#### Quickstart ```bash -cargo run -p notes-api +cp .env.example .env # edit JWT_SECRET at minimum +make dev # API server on :3000 +make dev-frontend # Vite dev server on :5173 (separate terminal) +make dev-worker # background worker (separate terminal, optional) ``` +The API server also serves the SPA if you run `make build-frontend` first and `SPA_DIR` points at the dist directory. For active frontend development, use the Vite dev server instead — it hot-reloads and proxies API calls to `:3000`. -By default, this uses the **SQLite** backend. +#### Environment variables -#### Configuration +| Variable | Process | Required | Default | Description | +|----------|---------|----------|---------|-------------| +| `DATABASE_URL` | both | yes | — | SQLite path, e.g. `sqlite:data.db?mode=rwc` | +| `JWT_SECRET` | backend | yes | — | HS256 signing secret — generate with `openssl rand -hex 32` | +| `NATS_URL` | both | no | — | NATS JetStream URL; in-memory bus used if unset | +| `QDRANT_URL` | both | no | — | Enables smart features (semantic links) | +| `ENABLE_EMBEDDINGS` | **worker only** | no | `false` | Set `true` in the worker to load the fastembed model (~150 MB). Leave unset in the backend to save memory. | +| `QDRANT_COLLECTION` | both | no | `notes` | Qdrant collection name | +| `QDRANT_VECTOR_SIZE` | both | no | `384` | Must match the embedding model output dimension | +| `ALLOW_REGISTRATION` | backend | no | `true` | Set `false` to close public registration | +| `SPA_DIR` | backend | no | `k-notes-frontend/dist` | Path to built frontend; set empty for API-only mode | +| `CORS_ORIGINS` | backend | no | — | Comma-separated allowed origins | +| `PORT` | backend | no | `3000` | HTTP listen port | +| `HOST` | backend | no | `0.0.0.0` | HTTP listen address | +| `NATS_MAX_DELIVER` | worker | no | `5` | JetStream dead-letter threshold | +| `SMART_NEIGHBOUR_LIMIT` | worker | no | `10` | Max semantic links per note | +| `SMART_MIN_SIMILARITY` | worker | no | `0.7` | Cosine similarity threshold | -The application is configured via environment variables (or `.env` file): +See `.env.example` for a commented template. -- `ALLOW_REGISTRATION`: Set to `false` to disable new user registration (default: `true`). -- `DATABASE_URL`: Connection string for the database. -- `SESSION_SECRET`: Secret key for session encryption. -- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed origins. +## API -**Running with Postgres:** +All endpoints are under `/api/v1`. Full interactive docs at `/docs` (Swagger) or `/scalar` after starting the server. -To use PostgreSQL, build with the `postgres` feature: -```bash -cargo run -p notes-api --no-default-features --features notes-infra/postgres -``` -*Note: Ensure your `DATABASE_URL` is set to a valid Postgres connection string.* +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/auth/login` | — | Login, returns JWT | +| POST | `/auth/register` | — | Register (if enabled) | +| GET | `/auth/me` | ✓ | Current user | +| GET | `/config` | — | Server capabilities | +| GET | `/notes` | ✓ | List notes (filter: pinned, archived, tag) | +| POST | `/notes` | ✓ | Create note | +| GET | `/notes/:id` | ✓ | Get note | +| PATCH | `/notes/:id` | ✓ | Update note | +| DELETE | `/notes/:id` | ✓ | Delete note | +| PATCH | `/notes/:id/pin` | ✓ | Pin / unpin | +| PATCH | `/notes/:id/archive` | ✓ | Archive / unarchive | +| GET | `/notes/:id/versions` | ✓ | Version history | +| GET | `/notes/:id/related` | ✓ | Semantically related notes | +| POST | `/notes/:id/tags` | ✓ | Add tag by name | +| DELETE | `/notes/:id/tags/:tag_id` | ✓ | Remove tag | +| GET | `/search?q=` | ✓ | Full-text search | +| GET | `/tags` | ✓ | List tags | +| POST | `/tags` | ✓ | Create tag | +| DELETE | `/tags/:id` | ✓ | Delete tag | +| PATCH | `/tags/:id` | ✓ | Rename tag | +| GET | `/export` | ✓ | Export all data as JSON | +| POST | `/import` | ✓ | Import from backup JSON | -**Feature Flags (Smart Features):** - -The application includes "Smart Features" (semantic search, related notes) enabled by default. These require `fastembed`, `qdrant-client`, and `async-nats`. - -To build/run **without** smart features (for faster compilation or lighter deployment): +## Deployment ```bash -cargo run -p notes-api --no-default-features --features sqlite +# Build and push image +make deploy + +# Or manually +IMAGE=your-registry/k-notes:latest make docker-build docker-push ``` -#### Frontend +The `CMD` in the Dockerfile starts `bootstrap` (HTTP server). Run `./worker` as a separate container or process for background event processing. -1. Navigate to `k-notes-frontend`. -2. Install dependencies: - -```bash -bun install -``` - -3. Run the dev server: - -```bash -bun dev -``` - -## Database Architecture - -The backend follows a Hexagonal Architecture (Ports and Adapters). The `notes-domain` crate defines the repository capabilities (Ports), and `notes-infra` implements them (Adapters). - -### Supported Databases -- **SQLite**: Fully implemented (default). Ideal for single-instance, self-hosted deployments. -- **Postgres**: Structure is in place (via feature flag), ready for implementation. - -### Extending Database Support - -To add a new database (e.g., MySQL), follow these steps: - -1. **Dependencies**: Add the driver to `notes-infra/Cargo.toml` (e.g., `sqlx` with `mysql` feature) and create a feature flag. -2. **Configuration**: Update `DatabaseConfig` in `notes-infra/src/db.rs` to handle the new connection URL scheme and connection logic in `create_pool`. -3. **Repository Implementation**: - - Implement `NoteRepository`, `TagRepository`, and `UserRepository` traits for the new database in `notes-infra`. -4. **Factory Integration**: - - Update `notes-infra/src/factory.rs` to include a builder for the new repositories. - - Update `build_database_pool` and repository `build_*` functions to support the new database type match arm. -5. **Migrations**: - - Add migration files in `migrations/`. - - Update `run_migrations` in `db.rs` to execute them. - -This design ensures the `notes-api` layer remains completely agnostic to the underlying database technology. +**Docker Compose** volumes to mount: +- `/app/data` — SQLite database file +- `/app/data/model-cache` — fastembed model cache (avoids re-download on restart) ## Project Structure ``` -├── notes-api # API Interface (Axum, HTTP routes) -├── notes-domain # Core Business Logic (Entities, Services, Ports) -├── notes-infra # Infrastructure (Database adapters, Repositories) -├── k-notes-frontend # React Frontend Application -├── migrations # SQLx Database Migrations -└── compose.yml # Docker Composition +crates/ # New architecture (active) + adapters/ # Infrastructure adapters + api-types/ # HTTP DTOs + application/ # Use cases + WorkerService + bootstrap/ # API server binary + domain/ # Core domain + presentation/ # Axum + OpenAPI + SPA + wiring/ # Dependency assembly + worker/ # Event worker binary +k-notes-frontend/ # React SPA +migrations/ # Legacy migration files (see crates/adapters/sqlite/migrations/) + +notes-api/ ⚠ DEPRECATED — will be removed in a future release +notes-domain/ ⚠ DEPRECATED — will be removed in a future release +notes-infra/ ⚠ DEPRECATED — will be removed in a future release +notes-worker/ ⚠ DEPRECATED — will be removed in a future release ``` +> **Note**: The `notes-*` directories contain the original monolithic implementation and are kept for reference only. They are excluded from the workspace and are not built. All active development happens in `crates/`. + ## License -MIT +MIT — Copyright (c) 2025-2026 Gabriel Kaszewski diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..c0698a3 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,66 @@ +services: + backend: + image: registry.gabrielkaszewski.dev/k-notes:latest + environment: + - DATABASE_URL=sqlite:///app/data/notes.db + - JWT_SECRET=change-me + - JWT_ISSUER=https://idm.gabrielkaszewski.dev/oauth/v2/keys + - CORS_ORIGINS=https://knotes.gabrielkaszewski.dev + - HOST=0.0.0.0 + - PORT=3000 + - ALLOW_REGISTRATION=false + - NATS_URL=nats://k_nats:4222 + - QDRANT_URL=http://qdrant:6334 + - QDRANT_COLLECTION=notes + # SPA is bundled in the image at /app/frontend/dist (set in Dockerfile) + # Override here only if you move the dist elsewhere: + # - SPA_DIR=/app/frontend/dist + volumes: + - ./data:/app/data + networks: + - traefik + - shared-services + - internal + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik" + # Both the app domain and the API subdomain point to the same service. + # The backend serves /api/v1/* and falls back to the SPA for everything else. + - "traefik.http.routers.knotes.rule=Host(`knotes.gabrielkaszewski.dev`) || Host(`api.knotes.gabrielkaszewski.dev`)" + - "traefik.http.routers.knotes.entrypoints=websecure" + - "traefik.http.routers.knotes.tls.certresolver=letsencrypt" + - "traefik.http.services.knotes.loadbalancer.server.port=3000" + + worker: + image: registry.gabrielkaszewski.dev/k-notes:latest + command: ["./worker"] + environment: + - DATABASE_URL=sqlite:///app/data/notes.db + - NATS_URL=nats://k_nats:4222 + - QDRANT_URL=http://qdrant:6334 + - ENABLE_EMBEDDINGS=true + depends_on: + - backend + - qdrant + volumes: + - ./data:/app/data + networks: + - internal + - shared-services + + qdrant: + image: qdrant/qdrant:latest + container_name: k_notes_qdrant + volumes: + - ./data/qdrant_storage:/qdrant/storage:z + restart: unless-stopped + networks: + - internal + +networks: + traefik: + external: true + shared-services: + external: true + internal: + driver: bridge diff --git a/compose.yml b/compose.yml index f7bcd7f..2c0a95e 100644 --- a/compose.yml +++ b/compose.yml @@ -4,60 +4,48 @@ services: ports: - "3000:3000" environment: - # In production, use a secure secret - - SESSION_SECRET=dev_secret_key_12345 - DATABASE_URL=sqlite:///app/data/notes.db - - CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173 + - JWT_SECRET=dev-secret-not-for-production-use + - CORS_ORIGINS=http://localhost:5173 - HOST=0.0.0.0 - PORT=3000 - ALLOW_REGISTRATION=true + - NATS_URL=nats://nats:4222 + # Uncomment to enable smart features: + # - QDRANT_URL=http://qdrant:6334 volumes: - ./data:/app/data + depends_on: + - nats worker: build: . - command: ["./notes-worker"] + command: ["./worker"] environment: - DATABASE_URL=sqlite:///app/data/notes.db - - BROKER_URL=nats://nats:4222 - - QDRANT_URL=http://qdrant:6334 - - EMBEDDING_PROVIDER=fastembed + - NATS_URL=nats://nats:4222 + - ENABLE_EMBEDDINGS=true + # Uncomment to enable smart features: + # - QDRANT_URL=http://qdrant:6334 depends_on: - backend - nats - - qdrant volumes: - ./data:/app/data - frontend: - build: ./k-notes-frontend - ports: - - "8080:80" - environment: - # This sets the default backend URL for the frontend - - API_URL=http://localhost:3000 - depends_on: - - backend - nats: image: nats:alpine - container_name: k_notes_nats + # --jetstream enables persistent messaging required by the worker + command: ["--jetstream"] ports: - "4222:4222" - - "6222:6222" - - "8222:8222" restart: unless-stopped - qdrant: - image: qdrant/qdrant:latest - container_name: k_notes_qdrant - ports: - - "6333:6333" - - "6334:6334" - volumes: - - ./data/qdrant_storage:/qdrant/storage:z - restart: unless-stopped - -# Optional: Define volumes explicitly if needed -# volumes: -# backend_data: + # Uncomment to enable smart features (semantic search + related notes) + # qdrant: + # image: qdrant/qdrant:latest + # ports: + # - "6334:6334" + # volumes: + # - ./data/qdrant_storage:/qdrant/storage:z + # restart: unless-stopped diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml new file mode 100644 index 0000000..05be07f --- /dev/null +++ b/crates/adapters/auth/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2024" + +[features] +default = [] +jwt = ["dep:jsonwebtoken"] +oidc = ["dep:openidconnect", "dep:reqwest"] + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +argon2 = "0.5" +url = "2" + +jsonwebtoken = { version = "10", features = ["rust_crypto"], optional = true } +openidconnect = { version = "4", optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/auth/src/config.rs b/crates/adapters/auth/src/config.rs new file mode 100644 index 0000000..f17158a --- /dev/null +++ b/crates/adapters/auth/src/config.rs @@ -0,0 +1,30 @@ +/// Config for OIDC. Validated when constructing OidcService. +#[derive(Debug, Clone)] +pub struct OidcConfig { + pub issuer_url: String, + pub client_id: String, + pub client_secret: Option, + pub redirect_url: String, + /// Optional audience / resource ID for token validation. + pub resource_id: Option, +} + +/// Config for JWT. Validated when constructing JwtValidator. +#[derive(Debug, Clone)] +pub struct JwtConfig { + pub secret: String, + pub issuer: Option, + pub audience: Option, + pub expiry_hours: u64, +} + +impl JwtConfig { + pub fn new(secret: impl Into) -> Self { + Self { + secret: secret.into(), + issuer: None, + audience: None, + expiry_hours: 24, + } + } +} diff --git a/crates/adapters/auth/src/jwt.rs b/crates/adapters/auth/src/jwt.rs new file mode 100644 index 0000000..6859a62 --- /dev/null +++ b/crates/adapters/auth/src/jwt.rs @@ -0,0 +1,100 @@ +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +use domain::user::entity::User; + +use crate::config::JwtConfig; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JwtClaims { + pub sub: String, + pub email: String, + pub exp: usize, + pub iat: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, +} + +#[derive(Debug, Error)] +pub enum JwtError { + #[error("token creation failed: {0}")] + Creation(#[from] jsonwebtoken::errors::Error), + #[error("token expired")] + Expired, + #[error("invalid token: {0}")] + Invalid(String), +} + +pub struct JwtValidator { + config: JwtConfig, + encoding_key: EncodingKey, + decoding_key: DecodingKey, + validation: Validation, +} + +impl JwtValidator { + pub fn new(config: JwtConfig) -> Self { + let encoding_key = EncodingKey::from_secret(config.secret.as_bytes()); + let decoding_key = DecodingKey::from_secret(config.secret.as_bytes()); + + let mut validation = Validation::new(Algorithm::HS256); + if let Some(ref iss) = config.issuer { + validation.set_issuer(&[iss]); + } + if let Some(ref aud) = config.audience { + validation.set_audience(&[aud]); + } + + Self { + config, + encoding_key, + decoding_key, + validation, + } + } + + pub fn create_token(&self, user: &User) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before epoch") + .as_secs() as usize; + + let claims = JwtClaims { + sub: user.id.as_uuid().to_string(), + email: user.email.as_ref().to_string(), + exp: now + self.config.expiry_hours as usize * 3600, + iat: now, + iss: self.config.issuer.clone(), + aud: self.config.audience.clone(), + }; + + encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) + .map_err(JwtError::Creation) + } + + pub fn validate_token(&self, token: &str) -> Result { + decode::(token, &self.decoding_key, &self.validation) + .map(|td| td.claims) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired, + _ => JwtError::Invalid(e.to_string()), + }) + } +} + +impl std::fmt::Debug for JwtValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JwtValidator") + .field("issuer", &self.config.issuer) + .field("expiry_hours", &self.config.expiry_hours) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +#[path = "tests/jwt.rs"] +mod tests; diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs new file mode 100644 index 0000000..ad2981d --- /dev/null +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,8 @@ +pub mod config; +pub mod password; + +#[cfg(feature = "jwt")] +pub mod jwt; + +#[cfg(feature = "oidc")] +pub mod oidc; diff --git a/crates/adapters/auth/src/oidc.rs b/crates/adapters/auth/src/oidc.rs new file mode 100644 index 0000000..f852791 --- /dev/null +++ b/crates/adapters/auth/src/oidc.rs @@ -0,0 +1,178 @@ +use anyhow::{Result, anyhow}; +use openidconnect::{ + AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, + OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse, + UserInfoClaims, + core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, + CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, + CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, + CoreTokenResponse, + }, + reqwest, +}; +use serde::{Deserialize, Serialize}; + +use crate::config::OidcConfig; + +pub type OidcClient = Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +/// Data returned when starting the OIDC authorization flow. +#[derive(Debug, Clone)] +pub struct AuthorizationUrlData { + pub url: url::Url, + pub csrf_token: String, + pub nonce: String, + pub pkce_verifier: String, +} + +/// Verified identity returned after a successful callback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcUser { + pub subject: String, + pub email: String, +} + +#[derive(Clone)] +pub struct OidcService { + client: OidcClient, + resource_id: Option, +} + +impl OidcService { + pub async fn new(config: OidcConfig) -> Result { + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let provider_metadata = CoreProviderMetadata::discover_async( + openidconnect::IssuerUrl::new(config.issuer_url)?, + &http_client, + ) + .await?; + + let client_secret = config + .client_secret + .filter(|s| !s.trim().is_empty()) + .map(openidconnect::ClientSecret::new); + + let client = CoreClient::from_provider_metadata( + provider_metadata, + openidconnect::ClientId::new(config.client_id), + client_secret, + ) + .set_redirect_uri(openidconnect::RedirectUrl::new(config.redirect_url)?); + + Ok(Self { + client, + resource_id: config.resource_id, + }) + } + + pub fn authorization_url(&self) -> AuthorizationUrlData { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (url, csrf_token, nonce) = self + .client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + openidconnect::CsrfToken::new_random, + openidconnect::Nonce::new_random, + ) + .add_scope(Scope::new("profile".into())) + .add_scope(Scope::new("email".into())) + .set_pkce_challenge(pkce_challenge) + .url(); + + AuthorizationUrlData { + url: url.into(), + csrf_token: csrf_token.secret().clone(), + nonce: nonce.secret().clone(), + pkce_verifier: pkce_verifier.secret().clone(), + } + } + + pub async fn exchange_code( + &self, + code: &str, + nonce: &str, + pkce_verifier: &str, + ) -> Result { + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let token_response = self + .client + .exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))? + .set_pkce_verifier(openidconnect::PkceCodeVerifier::new( + pkce_verifier.to_owned(), + )) + .request_async(&http_client) + .await?; + + let id_token = token_response + .id_token() + .ok_or_else(|| anyhow!("server did not return an ID token"))?; + + let mut verifier = self.client.id_token_verifier().clone(); + if let Some(ref rid) = self.resource_id { + let rid = rid.clone(); + verifier = + verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == rid.as_str()); + } + + let oidc_nonce = openidconnect::Nonce::new(nonce.to_owned()); + let claims = id_token.claims(&verifier, &oidc_nonce)?; + + if let Some(expected_hash) = claims.access_token_hash() { + let actual_hash = AccessTokenHash::from_token( + token_response.access_token(), + id_token.signing_alg()?, + id_token.signing_key(&verifier)?, + )?; + if actual_hash != *expected_hash { + return Err(anyhow!("access token hash mismatch")); + } + } + + let email = match claims.email() { + Some(e) => e.as_str().to_owned(), + None => { + tracing::debug!("email absent in ID token, fetching userinfo"); + let userinfo: UserInfoClaims = self + .client + .user_info(token_response.access_token().clone(), None)? + .request_async(&http_client) + .await?; + userinfo + .email() + .map(|e| e.as_str().to_owned()) + .ok_or_else(|| anyhow!("no verified email in identity provider response"))? + } + }; + + Ok(OidcUser { + subject: claims.subject().to_string(), + email, + }) + } +} diff --git a/crates/adapters/auth/src/password.rs b/crates/adapters/auth/src/password.rs new file mode 100644 index 0000000..a37bf1d --- /dev/null +++ b/crates/adapters/auth/src/password.rs @@ -0,0 +1,51 @@ +use argon2::{ + Argon2, + password_hash::{ + PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, rand_core::OsRng, + }, +}; +use async_trait::async_trait; + +use domain::{ + errors::{DomainError, DomainResult}, + user::{ + ports::PasswordHasher, + value_objects::{Password, PasswordHash as DomainPasswordHash}, + }, +}; + +pub struct Argon2PasswordHasher; + +#[async_trait] +impl PasswordHasher for Argon2PasswordHasher { + async fn hash(&self, password: &Password) -> DomainResult { + let password_str = password.as_ref().to_owned(); + tokio::task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password_str.as_bytes(), &salt) + .map_err(|e| DomainError::Infrastructure(format!("hash failed: {e}")))?; + Ok(DomainPasswordHash::new(hash.to_string())) + }) + .await + .map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))? + } + + async fn verify(&self, password: &Password, hash: &DomainPasswordHash) -> DomainResult { + let password_str = password.as_ref().to_owned(); + let hash_str = hash.as_str().to_owned(); + tokio::task::spawn_blocking(move || { + let parsed = PasswordHash::new(&hash_str) + .map_err(|e| DomainError::Infrastructure(format!("invalid hash: {e}")))?; + Ok(Argon2::default() + .verify_password(password_str.as_bytes(), &parsed) + .is_ok()) + }) + .await + .map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))? + } +} + +#[cfg(test)] +#[path = "tests/password.rs"] +mod tests; diff --git a/crates/adapters/auth/src/tests/jwt.rs b/crates/adapters/auth/src/tests/jwt.rs new file mode 100644 index 0000000..56a3540 --- /dev/null +++ b/crates/adapters/auth/src/tests/jwt.rs @@ -0,0 +1,68 @@ +use domain::user::{entity::User, value_objects::Email}; + +use crate::{config::JwtConfig, jwt::JwtValidator}; + +fn validator() -> JwtValidator { + JwtValidator::new(JwtConfig::new( + "a-test-secret-that-is-long-enough-for-hs256", + )) +} + +fn user() -> User { + User::new_oidc("sub|123", Email::new("test@example.com").unwrap()) +} + +#[test] +fn create_and_validate_round_trip() { + let v = validator(); + let u = user(); + let token = v.create_token(&u).unwrap(); + let claims = v.validate_token(&token).unwrap(); + + assert_eq!(claims.email, "test@example.com"); + assert_eq!(claims.sub, u.id.as_uuid().to_string()); +} + +#[test] +fn wrong_secret_rejects_token() { + let v1 = JwtValidator::new(JwtConfig::new( + "secret-one-long-enough-for-hs256-validation", + )); + let v2 = JwtValidator::new(JwtConfig::new( + "secret-two-long-enough-for-hs256-validation", + )); + + let token = v1.create_token(&user()).unwrap(); + assert!(v2.validate_token(&token).is_err()); +} + +#[test] +fn invalid_token_is_rejected() { + let v = validator(); + assert!(v.validate_token("not.a.valid.jwt").is_err()); +} + +#[test] +fn expired_token_returns_expired_error() { + use crate::jwt::JwtError; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + + let secret = "a-test-secret-that-is-long-enough-for-hs256"; + let claims = crate::jwt::JwtClaims { + sub: "user-id".into(), + email: "x@example.com".into(), + exp: 1, // epoch + 1 second — already expired + iat: 0, + iss: None, + aud: None, + }; + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap(); + + let v = JwtValidator::new(JwtConfig::new(secret)); + assert!(matches!(v.validate_token(&token), Err(JwtError::Expired))); +} diff --git a/crates/adapters/auth/src/tests/password.rs b/crates/adapters/auth/src/tests/password.rs new file mode 100644 index 0000000..d3aee79 --- /dev/null +++ b/crates/adapters/auth/src/tests/password.rs @@ -0,0 +1,36 @@ +use domain::user::{ + ports::PasswordHasher, + value_objects::{Password, PasswordHash}, +}; + +use crate::password::Argon2PasswordHasher; + +#[tokio::test] +async fn hash_produces_verifiable_hash() { + let hasher = Argon2PasswordHasher; + let password = Password::new("correcthorsebattery").unwrap(); + + let hash = hasher.hash(&password).await.unwrap(); + assert!(hasher.verify(&password, &hash).await.unwrap()); +} + +#[tokio::test] +async fn wrong_password_does_not_verify() { + let hasher = Argon2PasswordHasher; + let password = Password::new("correcthorsebattery").unwrap(); + let wrong = Password::new("wrongpassword12345").unwrap(); + + let hash = hasher.hash(&password).await.unwrap(); + assert!(!hasher.verify(&wrong, &hash).await.unwrap()); +} + +#[tokio::test] +async fn same_password_produces_different_hashes() { + let hasher = Argon2PasswordHasher; + let password = Password::new("samepassword123").unwrap(); + + let hash1 = hasher.hash(&password).await.unwrap(); + let hash2 = hasher.hash(&password).await.unwrap(); + + assert_ne!(hash1.as_str(), hash2.as_str()); +} diff --git a/crates/adapters/event-payload/Cargo.toml b/crates/adapters/event-payload/Cargo.toml new file mode 100644 index 0000000..b986e0f --- /dev/null +++ b/crates/adapters/event-payload/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "event-payload" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs new file mode 100644 index 0000000..43e2921 --- /dev/null +++ b/crates/adapters/event-payload/src/lib.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use domain::{ + errors::DomainError, events::DomainEvent, note::entity::NoteId, user::entity::UserId, +}; + +/// Wire-format representation of a DomainEvent. +/// Uses primitive types only — no domain newtypes — so it is stable across +/// schema versions and safe to serialize to any transport (NATS, HTTP, file). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", content = "data")] +pub enum EventPayload { + NoteCreated { note_id: String, user_id: String }, + NoteUpdated { note_id: String, user_id: String }, + NoteDeleted { note_id: String, user_id: String }, +} + +impl EventPayload { + pub fn event_type(&self) -> &'static str { + match self { + Self::NoteCreated { .. } => "NoteCreated", + Self::NoteUpdated { .. } => "NoteUpdated", + Self::NoteDeleted { .. } => "NoteDeleted", + } + } + + pub fn to_json(&self) -> Result, DomainError> { + serde_json::to_vec(self) + .map_err(|e| DomainError::Infrastructure(format!("serialize failed: {e}"))) + } + + pub fn from_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + .map_err(|e| DomainError::Infrastructure(format!("deserialize failed: {e}"))) + } +} + +impl From<&DomainEvent> for EventPayload { + fn from(event: &DomainEvent) -> Self { + match event { + DomainEvent::NoteCreated { note_id, user_id } => Self::NoteCreated { + note_id: note_id.as_uuid().to_string(), + user_id: user_id.as_uuid().to_string(), + }, + DomainEvent::NoteUpdated { note_id, user_id } => Self::NoteUpdated { + note_id: note_id.as_uuid().to_string(), + user_id: user_id.as_uuid().to_string(), + }, + DomainEvent::NoteDeleted { note_id, user_id } => Self::NoteDeleted { + note_id: note_id.as_uuid().to_string(), + user_id: user_id.as_uuid().to_string(), + }, + } + } +} + +impl TryFrom for DomainEvent { + type Error = DomainError; + + fn try_from(payload: EventPayload) -> Result { + fn parse(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| DomainError::Infrastructure(format!("invalid uuid '{s}': {e}"))) + } + + match payload { + EventPayload::NoteCreated { note_id, user_id } => Ok(DomainEvent::NoteCreated { + note_id: NoteId::from_uuid(parse(¬e_id)?), + user_id: UserId::from_uuid(parse(&user_id)?), + }), + EventPayload::NoteUpdated { note_id, user_id } => Ok(DomainEvent::NoteUpdated { + note_id: NoteId::from_uuid(parse(¬e_id)?), + user_id: UserId::from_uuid(parse(&user_id)?), + }), + EventPayload::NoteDeleted { note_id, user_id } => Ok(DomainEvent::NoteDeleted { + note_id: NoteId::from_uuid(parse(¬e_id)?), + user_id: UserId::from_uuid(parse(&user_id)?), + }), + } + } +} + +#[cfg(test)] +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/event-payload/src/tests/lib.rs b/crates/adapters/event-payload/src/tests/lib.rs new file mode 100644 index 0000000..fee46ed --- /dev/null +++ b/crates/adapters/event-payload/src/tests/lib.rs @@ -0,0 +1,88 @@ +use domain::{events::DomainEvent, note::entity::NoteId, user::entity::UserId}; + +use crate::EventPayload; + +fn note_created() -> DomainEvent { + DomainEvent::NoteCreated { + note_id: NoteId::new(), + user_id: UserId::new(), + } +} + +#[test] +fn domain_event_round_trips_through_payload() { + let event = note_created(); + let payload = EventPayload::from(&event); + let recovered = DomainEvent::try_from(payload).unwrap(); + + // Compare by serialising both — DomainEvent doesn't implement PartialEq. + let EventPayload::NoteCreated { + note_id: orig_nid, + user_id: orig_uid, + } = EventPayload::from(&event) + else { + panic!("wrong variant"); + }; + let EventPayload::NoteCreated { + note_id: rec_nid, + user_id: rec_uid, + } = EventPayload::from(&recovered) + else { + panic!("wrong variant"); + }; + assert_eq!(orig_nid, rec_nid); + assert_eq!(orig_uid, rec_uid); +} + +#[test] +fn payload_serialises_to_json_and_back() { + let event = note_created(); + let payload = EventPayload::from(&event); + let bytes = payload.to_json().unwrap(); + let recovered = EventPayload::from_json(&bytes).unwrap(); + assert_eq!(payload, recovered); +} + +#[test] +fn event_type_label_is_correct() { + let uid = UserId::new(); + let nid = NoteId::new(); + assert_eq!( + EventPayload::NoteCreated { + note_id: nid.to_string(), + user_id: uid.to_string() + } + .event_type(), + "NoteCreated" + ); + assert_eq!( + EventPayload::NoteUpdated { + note_id: nid.to_string(), + user_id: uid.to_string() + } + .event_type(), + "NoteUpdated" + ); + assert_eq!( + EventPayload::NoteDeleted { + note_id: nid.to_string(), + user_id: uid.to_string() + } + .event_type(), + "NoteDeleted" + ); +} + +#[test] +fn invalid_json_returns_error() { + assert!(EventPayload::from_json(b"not json at all").is_err()); +} + +#[test] +fn invalid_uuid_in_payload_returns_error() { + let payload = EventPayload::NoteCreated { + note_id: "not-a-uuid".into(), + user_id: "also-not-a-uuid".into(), + }; + assert!(DomainEvent::try_from(payload).is_err()); +} diff --git a/crates/adapters/event-publisher-memory/Cargo.toml b/crates/adapters/event-publisher-memory/Cargo.toml new file mode 100644 index 0000000..ccf8a25 --- /dev/null +++ b/crates/adapters/event-publisher-memory/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "event-publisher-memory" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/adapters/event-publisher-memory/src/lib.rs b/crates/adapters/event-publisher-memory/src/lib.rs new file mode 100644 index 0000000..e882b75 --- /dev/null +++ b/crates/adapters/event-publisher-memory/src/lib.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use tokio::sync::broadcast; + +use domain::{ + errors::DomainError, + events::{DomainEvent, EventConsumer, EventEnvelope, EventPublisher}, +}; + +const CHANNEL_CAPACITY: usize = 256; + +/// Shared in-memory event bus backed by a tokio broadcast channel. +/// Create one bus, then hand out publisher and consumer handles from it. +pub struct MemoryEventBus { + sender: broadcast::Sender, +} + +impl MemoryEventBus { + pub fn new() -> Self { + let (sender, _) = broadcast::channel(CHANNEL_CAPACITY); + Self { sender } + } + + pub fn publisher(&self) -> Arc { + Arc::new(MemoryEventPublisher { + sender: self.sender.clone(), + }) + } + + pub fn consumer(&self) -> Arc { + Arc::new(MemoryEventConsumer { + sender: self.sender.clone(), + }) + } +} + +impl Default for MemoryEventBus { + fn default() -> Self { + Self::new() + } +} + +pub struct MemoryEventPublisher { + sender: broadcast::Sender, +} + +#[async_trait] +impl EventPublisher for MemoryEventPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + // send() only fails when there are no receivers; that is fine in dev/test. + let _ = self.sender.send(event.clone()); + Ok(()) + } +} + +pub struct MemoryEventConsumer { + sender: broadcast::Sender, +} + +impl EventConsumer for MemoryEventConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let rx = self.sender.subscribe(); + + Box::pin(futures::stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(event) => { + let envelope = EventEnvelope::noop(event); + return Some((Ok(envelope), rx)); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("memory event bus: consumer lagged, skipped {n} messages"); + } + Err(broadcast::error::RecvError::Closed) => return None, + } + } + })) + } +} + +#[cfg(test)] +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/event-publisher-memory/src/tests/lib.rs b/crates/adapters/event-publisher-memory/src/tests/lib.rs new file mode 100644 index 0000000..db04f6c --- /dev/null +++ b/crates/adapters/event-publisher-memory/src/tests/lib.rs @@ -0,0 +1,75 @@ +use futures::StreamExt; + +use domain::{ + events::{DomainEvent, EventConsumer, EventPublisher}, + note::entity::NoteId, + user::entity::UserId, +}; + +use crate::MemoryEventBus; + +fn note_updated() -> DomainEvent { + DomainEvent::NoteUpdated { + note_id: NoteId::new(), + user_id: UserId::new(), + } +} + +#[tokio::test] +async fn published_event_is_received_by_consumer() { + let bus = MemoryEventBus::new(); + let publisher = bus.publisher(); + let consumer = bus.consumer(); + + let event = note_updated(); + let mut stream = consumer.consume(); + + publisher.publish(&event).await.unwrap(); + + let envelope = stream.next().await.unwrap().unwrap(); + assert!(matches!(envelope.event, DomainEvent::NoteUpdated { .. })); +} + +#[tokio::test] +async fn ack_on_memory_envelope_is_noop() { + let bus = MemoryEventBus::new(); + let publisher = bus.publisher(); + let consumer = bus.consumer(); + + // Subscribe before publishing — broadcast drops messages sent before subscribe. + let mut stream = consumer.consume(); + publisher.publish(¬e_updated()).await.unwrap(); + + let envelope = stream.next().await.unwrap().unwrap(); + envelope.ack().await.unwrap(); +} + +#[tokio::test] +async fn multiple_consumers_each_receive_the_event() { + let bus = MemoryEventBus::new(); + let publisher = bus.publisher(); + let c1 = bus.consumer(); + let c2 = bus.consumer(); + + let mut s1 = c1.consume(); + let mut s2 = c2.consume(); + + publisher.publish(¬e_updated()).await.unwrap(); + + assert!(matches!( + s1.next().await.unwrap().unwrap().event, + DomainEvent::NoteUpdated { .. } + )); + assert!(matches!( + s2.next().await.unwrap().unwrap().event, + DomainEvent::NoteUpdated { .. } + )); +} + +#[tokio::test] +async fn publish_with_no_consumer_does_not_error() { + let bus = MemoryEventBus::new(); + let publisher = bus.publisher(); + // No consumer — publish should silently succeed. + publisher.publish(¬e_updated()).await.unwrap(); +} diff --git a/crates/adapters/fastembed/Cargo.toml b/crates/adapters/fastembed/Cargo.toml new file mode 100644 index 0000000..7b90fe6 --- /dev/null +++ b/crates/adapters/fastembed/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fastembed-adapter" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +fastembed = "5" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/fastembed/src/lib.rs b/crates/adapters/fastembed/src/lib.rs new file mode 100644 index 0000000..ba418f6 --- /dev/null +++ b/crates/adapters/fastembed/src/lib.rs @@ -0,0 +1,88 @@ +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; + +use domain::{ + errors::{DomainError, DomainResult}, + smart::ports::EmbeddingGenerator, +}; + +pub struct FastEmbedConfig { + pub model: EmbeddingModel, + /// Directory used to cache downloaded model files. + /// Defaults to the system cache directory when `None`. + pub cache_dir: Option, + pub show_download_progress: bool, +} + +impl Default for FastEmbedConfig { + fn default() -> Self { + Self { + model: EmbeddingModel::AllMiniLML6V2, + cache_dir: None, + show_download_progress: false, + } + } +} + +impl FastEmbedConfig { + pub fn with_model(model: EmbeddingModel) -> Self { + Self { + model, + ..Default::default() + } + } +} + +pub struct FastEmbedGenerator { + model: Arc>, +} + +impl FastEmbedGenerator { + /// Initialise the model. Downloads and caches model files on first call. + pub fn new(config: FastEmbedConfig) -> Result { + let mut opts = TextInitOptions::new(config.model) + .with_show_download_progress(config.show_download_progress); + + if let Some(dir) = config.cache_dir { + opts = opts.with_cache_dir(dir); + } + + let model = TextEmbedding::try_new(opts) + .map_err(|e| DomainError::Infrastructure(format!("fastembed init failed: {e}")))?; + + Ok(Self { + model: Arc::new(Mutex::new(model)), + }) + } +} + +#[async_trait] +impl EmbeddingGenerator for FastEmbedGenerator { + async fn generate(&self, text: &str) -> DomainResult> { + let model = Arc::clone(&self.model); + let text = text.to_owned(); + + tokio::task::spawn_blocking(move || { + let mut guard = model + .lock() + .map_err(|_| DomainError::Infrastructure("model mutex poisoned".into()))?; + guard + .embed(vec![text.as_str()], None) + .map_err(|e| DomainError::Infrastructure(format!("embedding failed: {e}")))? + .into_iter() + .next() + .ok_or_else(|| DomainError::Infrastructure("no embedding returned".into())) + }) + .await + .map_err(|e| DomainError::Infrastructure(format!("spawn_blocking panicked: {e}")))? + } +} + +#[cfg(test)] +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/fastembed/src/tests/lib.rs b/crates/adapters/fastembed/src/tests/lib.rs new file mode 100644 index 0000000..a1ea1be --- /dev/null +++ b/crates/adapters/fastembed/src/tests/lib.rs @@ -0,0 +1,33 @@ +use crate::{FastEmbedConfig, FastEmbedGenerator}; +use domain::smart::ports::EmbeddingGenerator; +use fastembed::EmbeddingModel; + +/// Downloads the model on first run (~90 MB). Run with: +/// cargo test -p fastembed-adapter -- --ignored +#[tokio::test] +#[ignore] +async fn generates_embedding_with_correct_dimension() { + let generator = + FastEmbedGenerator::new(FastEmbedConfig::with_model(EmbeddingModel::AllMiniLML6V2)) + .expect("model init failed"); + + let embedding = generator.generate("hello world").await.unwrap(); + + // AllMiniLML6V2 produces 384-dimensional vectors. + assert_eq!(embedding.len(), 384); + // Sanity: values are in a reasonable range. + assert!(embedding.iter().all(|v| v.is_finite())); +} + +#[tokio::test] +#[ignore] +async fn different_texts_produce_different_embeddings() { + let generator = + FastEmbedGenerator::new(FastEmbedConfig::with_model(EmbeddingModel::AllMiniLML6V2)) + .expect("model init failed"); + + let a = generator.generate("cats").await.unwrap(); + let b = generator.generate("quantum mechanics").await.unwrap(); + + assert_ne!(a, b); +} diff --git a/crates/adapters/nats/Cargo.toml b/crates/adapters/nats/Cargo.toml new file mode 100644 index 0000000..97a6ef9 --- /dev/null +++ b/crates/adapters/nats/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nats" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +event-payload = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +serde_json = { workspace = true } +async-nats = "0.37" +async-stream = "0.3" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/nats/src/consumer.rs b/crates/adapters/nats/src/consumer.rs new file mode 100644 index 0000000..c8f408d --- /dev/null +++ b/crates/adapters/nats/src/consumer.rs @@ -0,0 +1,104 @@ +use std::{sync::Arc, time::Duration}; + +use async_nats::jetstream::{ + AckKind, + consumer::{self, pull}, +}; +use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; + +use domain::{ + errors::DomainError, + events::{DomainEvent, EventConsumer, EventEnvelope}, +}; +use event_payload::EventPayload; + +pub struct NatsEventConsumer { + consumer: Arc>, +} + +impl NatsEventConsumer { + pub(crate) fn new(consumer: consumer::Consumer) -> Self { + Self { + consumer: Arc::new(consumer), + } + } +} + +impl EventConsumer for NatsEventConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + let consumer = Arc::clone(&self.consumer); + + Box::pin(async_stream::stream! { + let mut messages = match consumer.messages().await { + Ok(m) => m, + Err(e) => { + yield Err(DomainError::Infrastructure( + format!("failed to open jetstream message stream: {e}") + )); + return; + } + }; + + while let Some(result) = messages.next().await { + let msg = match result { + Ok(m) => m, + Err(e) => { + yield Err(DomainError::Infrastructure(e.to_string())); + continue; + } + }; + + // Malformed messages are acked immediately to prevent infinite + // redelivery of poison payloads that can never be processed. + let payload = match EventPayload::from_json(&msg.payload) { + Ok(p) => p, + Err(e) => { + tracing::error!("unprocessable message payload, acking to discard: {e}"); + let _ = msg.ack().await; + continue; + } + }; + + let event = match DomainEvent::try_from(payload) { + Ok(e) => e, + Err(e) => { + tracing::error!("invalid event payload, acking to discard: {e}"); + let _ = msg.ack().await; + continue; + } + }; + + let delivered = msg.info().map(|i| i.delivered).unwrap_or(1); + let nack_delay = backoff(delivered); + + let msg = Arc::new(msg); + let ack_msg = Arc::clone(&msg); + let nack_msg = Arc::clone(&msg); + + yield Ok(EventEnvelope::new( + event, + move || -> BoxFuture<'static, _> { + Box::pin(async move { + ack_msg.ack().await.map_err(|e| { + DomainError::Infrastructure(format!("nats ack failed: {e}")) + }) + }) + }, + move || -> BoxFuture<'static, _> { + Box::pin(async move { + nack_msg.ack_with(AckKind::Nak(Some(nack_delay))).await.map_err(|e| { + DomainError::Infrastructure(format!("nats nack failed: {e}")) + }) + }) + }, + )); + } + }) + } +} + +/// Exponential backoff capped at 5 minutes: 1s → 5s → 25s → 125s → 300s. +fn backoff(delivered: i64) -> Duration { + let exp = delivered.saturating_sub(1) as u32; + Duration::from_secs(5u64.saturating_pow(exp).min(300)) +} diff --git a/crates/adapters/nats/src/lib.rs b/crates/adapters/nats/src/lib.rs new file mode 100644 index 0000000..8cd9c11 --- /dev/null +++ b/crates/adapters/nats/src/lib.rs @@ -0,0 +1,92 @@ +pub mod consumer; +pub mod publisher; + +use std::time::Duration; + +use async_nats::jetstream::{self, consumer as nats_consumer, consumer::pull}; + +use crate::{consumer::NatsEventConsumer, publisher::NatsEventPublisher}; + +// ── Subject routing ─────────────────────────────────────────────────────────── + +pub(crate) fn subject_for(event: &domain::events::DomainEvent) -> &'static str { + use domain::events::DomainEvent; + match event { + DomainEvent::NoteCreated { .. } => "knotes.note.created", + DomainEvent::NoteUpdated { .. } => "knotes.note.updated", + DomainEvent::NoteDeleted { .. } => "knotes.note.deleted", + } +} + +pub(crate) const SUBSCRIBE_SUBJECT: &str = "knotes.note.>"; + +// ── Config ──────────────────────────────────────────────────────────────────── + +/// Configuration for the JetStream stream and durable pull consumer. +/// +/// **Dead-letter queue**: after `max_deliver` failed attempts NATS stops +/// redelivering and publishes an advisory to +/// `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.{stream}.{consumer}`. +/// Subscribe to those with a monitoring consumer or NATS dashboard to +/// observe dead messages. +#[derive(Debug, Clone)] +pub struct JetStreamConfig { + /// Name of the JetStream stream (created on first use if absent). + pub stream_name: String, + /// Durable consumer name — survives worker restarts. + pub consumer_name: String, + /// Maximum delivery attempts before the message is considered dead. + pub max_deliver: i64, + /// How long JetStream waits for an ack before redelivering. + pub ack_wait: Duration, +} + +impl Default for JetStreamConfig { + fn default() -> Self { + Self { + stream_name: "KNOTES".into(), + consumer_name: "knotes-worker".into(), + max_deliver: 5, + ack_wait: Duration::from_secs(30), + } + } +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +/// Connect to NATS and initialise both the publisher and consumer. +/// Creates the JetStream stream and durable pull consumer if they do not exist. +pub async fn setup( + url: &str, + config: JetStreamConfig, +) -> Result<(NatsEventPublisher, NatsEventConsumer), Box> { + let client = async_nats::connect(url).await?; + let js = jetstream::new(client); + + let stream = js + .get_or_create_stream(jetstream::stream::Config { + name: config.stream_name.clone(), + subjects: vec![SUBSCRIBE_SUBJECT.into()], + ..Default::default() + }) + .await?; + + let nats_consumer: nats_consumer::Consumer = stream + .get_or_create_consumer( + &config.consumer_name, + pull::Config { + durable_name: Some(config.consumer_name.clone()), + ack_policy: jetstream::consumer::AckPolicy::Explicit, + max_deliver: config.max_deliver, + ack_wait: config.ack_wait, + filter_subject: SUBSCRIBE_SUBJECT.into(), + ..Default::default() + }, + ) + .await?; + + Ok(( + NatsEventPublisher::new(js), + NatsEventConsumer::new(nats_consumer), + )) +} diff --git a/crates/adapters/nats/src/publisher.rs b/crates/adapters/nats/src/publisher.rs new file mode 100644 index 0000000..997458a --- /dev/null +++ b/crates/adapters/nats/src/publisher.rs @@ -0,0 +1,34 @@ +use async_nats::jetstream; +use async_trait::async_trait; + +use domain::{ + errors::DomainError, + events::{DomainEvent, EventPublisher}, +}; +use event_payload::EventPayload; + +use crate::subject_for; + +pub struct NatsEventPublisher { + js: jetstream::Context, +} + +impl NatsEventPublisher { + pub(crate) fn new(js: jetstream::Context) -> Self { + Self { js } + } +} + +#[async_trait] +impl EventPublisher for NatsEventPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + let bytes = EventPayload::from(event).to_json()?; + self.js + .publish(subject_for(event), bytes.into()) + .await + .map_err(|e| DomainError::Infrastructure(format!("nats publish failed: {e}")))? + .await + .map_err(|e| DomainError::Infrastructure(format!("nats publish ack failed: {e}")))?; + Ok(()) + } +} diff --git a/crates/adapters/qdrant/Cargo.toml b/crates/adapters/qdrant/Cargo.toml new file mode 100644 index 0000000..89c586e --- /dev/null +++ b/crates/adapters/qdrant/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "qdrant-adapter" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +qdrant-client = "1" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/qdrant/src/lib.rs b/crates/adapters/qdrant/src/lib.rs new file mode 100644 index 0000000..04963b3 --- /dev/null +++ b/crates/adapters/qdrant/src/lib.rs @@ -0,0 +1,139 @@ +use async_trait::async_trait; +use qdrant_client::{ + Qdrant, QdrantError, + qdrant::{ + CreateCollectionBuilder, DeletePointsBuilder, Distance, PointId, PointStruct, + PointsIdsList, SearchPointsBuilder, UpsertPointsBuilder, VectorParamsBuilder, + point_id::PointIdOptions, + }, +}; +use uuid::Uuid; + +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::NoteId, + smart::ports::VectorStore, +}; + +pub struct QdrantConfig { + pub url: String, + pub collection: String, + /// Dimensionality of the vectors stored in this collection. + /// Must match the output size of the embedding model (e.g. 384 for AllMiniLML6V2). + pub vector_size: u64, +} + +impl Default for QdrantConfig { + fn default() -> Self { + Self { + url: "http://localhost:6334".into(), + collection: "notes".into(), + vector_size: 384, + } + } +} + +pub struct QdrantVectorStore { + client: Qdrant, + collection: String, +} + +impl QdrantVectorStore { + pub fn new(config: QdrantConfig) -> Result> { + let client = Qdrant::from_url(&config.url).build().map_err(Box::new)?; + Ok(Self { + client, + collection: config.collection, + }) + } + + /// Ensure the collection exists. Call once during startup before accepting requests. + pub async fn init(&self, vector_size: u64) -> DomainResult<()> { + if self + .client + .collection_exists(&self.collection) + .await + .map_err(qdrant_err)? + { + return Ok(()); + } + + self.client + .create_collection( + CreateCollectionBuilder::new(&self.collection) + .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine)), + ) + .await + .map_err(qdrant_err)?; + + tracing::info!(collection = %self.collection, "qdrant collection created"); + Ok(()) + } +} + +#[async_trait] +impl VectorStore for QdrantVectorStore { + async fn upsert(&self, id: &NoteId, vector: &[f32]) -> DomainResult<()> { + let point = PointStruct::new( + uuid_to_point_id(id.as_uuid()), + vector.to_vec(), + qdrant_client::Payload::default(), + ); + + self.client + .upsert_points(UpsertPointsBuilder::new(&self.collection, vec![point])) + .await + .map_err(qdrant_err) + .map(|_| ()) + } + + async fn find_similar(&self, vector: &[f32], limit: usize) -> DomainResult> { + let response = self + .client + .search_points( + SearchPointsBuilder::new(&self.collection, vector.to_vec(), limit as u64) + .with_payload(false), + ) + .await + .map_err(qdrant_err)?; + + response + .result + .into_iter() + .filter_map(|scored| { + let uuid_str = match scored.id?.point_id_options? { + PointIdOptions::Uuid(s) => s, + _ => return None, + }; + let uuid = Uuid::parse_str(&uuid_str).ok()?; + Some(Ok((NoteId::from_uuid(uuid), scored.score))) + }) + .collect() + } + + async fn delete(&self, id: &NoteId) -> DomainResult<()> { + self.client + .delete_points( + DeletePointsBuilder::new(&self.collection).points(PointsIdsList { + ids: vec![uuid_to_point_id(id.as_uuid())], + }), + ) + .await + .map_err(qdrant_err) + .map(|_| ()) + } +} + +fn uuid_to_point_id(uuid: Uuid) -> PointId { + PointId { + point_id_options: Some(PointIdOptions::Uuid(uuid.to_string())), + } +} + +fn qdrant_err(e: QdrantError) -> DomainError { + DomainError::Infrastructure(format!("qdrant: {e}")) +} + +#[cfg(test)] +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/adapters/qdrant/src/tests/lib.rs b/crates/adapters/qdrant/src/tests/lib.rs new file mode 100644 index 0000000..c99f193 --- /dev/null +++ b/crates/adapters/qdrant/src/tests/lib.rs @@ -0,0 +1,45 @@ +use domain::{note::entity::NoteId, smart::ports::VectorStore}; + +use crate::{QdrantConfig, QdrantVectorStore}; + +const VECTOR_SIZE: u64 = 4; // small for tests + +fn test_config() -> QdrantConfig { + QdrantConfig { + url: "http://localhost:6334".into(), + collection: "test-notes".into(), + vector_size: VECTOR_SIZE, + } +} + +/// Requires a running Qdrant instance. Run with: +/// cargo test -p qdrant-adapter -- --ignored +#[tokio::test] +#[ignore] +async fn upsert_and_find_similar() { + let store = QdrantVectorStore::new(test_config()).unwrap(); + store.init(VECTOR_SIZE).await.unwrap(); + + let id = NoteId::new(); + let vector = vec![1.0f32, 0.0, 0.0, 0.0]; + store.upsert(&id, &vector).await.unwrap(); + + let results = store.find_similar(&vector, 1).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, id); + assert!(results[0].1 > 0.99); +} + +#[tokio::test] +#[ignore] +async fn delete_removes_vector() { + let store = QdrantVectorStore::new(test_config()).unwrap(); + store.init(VECTOR_SIZE).await.unwrap(); + + let id = NoteId::new(); + store.upsert(&id, &[1.0, 0.0, 0.0, 0.0]).await.unwrap(); + store.delete(&id).await.unwrap(); + + let results = store.find_similar(&[1.0, 0.0, 0.0, 0.0], 10).await.unwrap(); + assert!(!results.iter().any(|(rid, _)| rid == &id)); +} diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml new file mode 100644 index 0000000..95f778c --- /dev/null +++ b/crates/adapters/sqlite/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "sqlite" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate", "macros"] } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/sqlite/migrations/20241223000000_initial_schema.sql b/crates/adapters/sqlite/migrations/20241223000000_initial_schema.sql new file mode 100644 index 0000000..bb5eb63 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20241223000000_initial_schema.sql @@ -0,0 +1,71 @@ +-- Initial schema for K-Notes +-- SQLite with FTS5 for full-text search + +-- Users table (OIDC-ready) +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + subject TEXT UNIQUE NOT NULL, -- OIDC subject identifier + email TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_users_subject ON users(subject); +CREATE INDEX idx_users_email ON users(email); + +-- Notes table +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + is_pinned INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_notes_user_id ON notes(user_id); +CREATE INDEX idx_notes_is_pinned ON notes(is_pinned); +CREATE INDEX idx_notes_is_archived ON notes(is_archived); +CREATE INDEX idx_notes_updated_at ON notes(updated_at); + +-- Tags table (user-scoped) +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(name, user_id) +); + +CREATE INDEX idx_tags_user_id ON tags(user_id); + +-- Junction table for note-tag relationship +CREATE TABLE IF NOT EXISTS note_tags ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (note_id, tag_id) +); + +CREATE INDEX idx_note_tags_tag_id ON note_tags(tag_id); + +-- Full-text search virtual table +CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + title, + content, + content='notes', + content_rowid='rowid' +); + +-- Triggers to keep FTS index in sync +CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content); +END; + +CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content); +END; + +CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content); + INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content); +END; diff --git a/crates/adapters/sqlite/migrations/20241223000001_add_password_hash.sql b/crates/adapters/sqlite/migrations/20241223000001_add_password_hash.sql new file mode 100644 index 0000000..28d80c4 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20241223000001_add_password_hash.sql @@ -0,0 +1,2 @@ +-- Add password_hash column to users table +ALTER TABLE users ADD COLUMN password_hash TEXT; diff --git a/crates/adapters/sqlite/migrations/20241223000002_add_color_to_notes.sql b/crates/adapters/sqlite/migrations/20241223000002_add_color_to_notes.sql new file mode 100644 index 0000000..a28f843 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20241223000002_add_color_to_notes.sql @@ -0,0 +1 @@ +ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT 'DEFAULT'; diff --git a/crates/adapters/sqlite/migrations/20251223030000_add_note_versions.sql b/crates/adapters/sqlite/migrations/20251223030000_add_note_versions.sql new file mode 100644 index 0000000..c987043 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20251223030000_add_note_versions.sql @@ -0,0 +1,11 @@ +-- Add note_versions table +CREATE TABLE note_versions ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE +); + +CREATE INDEX idx_note_versions_note_id ON note_versions(note_id); diff --git a/crates/adapters/sqlite/migrations/20251226000000_create_note_links.sql b/crates/adapters/sqlite/migrations/20251226000000_create_note_links.sql new file mode 100644 index 0000000..044fcf0 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20251226000000_create_note_links.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS note_links ( + source_note_id TEXT NOT NULL, + target_note_id TEXT NOT NULL, + score REAL NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (source_note_id, target_note_id), + FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY (target_note_id) REFERENCES notes(id) ON DELETE CASCADE +); + +CREATE INDEX idx_note_links_source ON note_links(source_note_id); +CREATE INDEX idx_note_links_target ON note_links(target_note_id); diff --git a/crates/adapters/sqlite/migrations/20251231000000_nullable_title.sql b/crates/adapters/sqlite/migrations/20251231000000_nullable_title.sql new file mode 100644 index 0000000..c7329a3 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20251231000000_nullable_title.sql @@ -0,0 +1,45 @@ +-- Allow NULL titles in notes table +-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table + +-- Step 1: Create new table with nullable title +CREATE TABLE notes_new ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT, -- Now nullable + content TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'DEFAULT', + is_pinned INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Step 2: Copy data from old table +INSERT INTO notes_new (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at) +SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at FROM notes; + +-- Step 3: Drop old table +DROP TABLE notes; + +-- Step 4: Rename new table +ALTER TABLE notes_new RENAME TO notes; + +-- Step 5: Recreate indexes +CREATE INDEX idx_notes_user_id ON notes(user_id); +CREATE INDEX idx_notes_is_pinned ON notes(is_pinned); +CREATE INDEX idx_notes_is_archived ON notes(is_archived); +CREATE INDEX idx_notes_updated_at ON notes(updated_at); + +-- Step 6: Recreate FTS triggers +CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content); +END; + +CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content); +END; + +CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content); + INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content); +END; diff --git a/crates/adapters/sqlite/migrations/20251231000001_nullable_note_version_title.sql b/crates/adapters/sqlite/migrations/20251231000001_nullable_note_version_title.sql new file mode 100644 index 0000000..fd4fbd7 --- /dev/null +++ b/crates/adapters/sqlite/migrations/20251231000001_nullable_note_version_title.sql @@ -0,0 +1,16 @@ +-- note_versions.title should be nullable to match notes where title is optional +CREATE TABLE note_versions_new ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL, + title TEXT, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE +); + +INSERT INTO note_versions_new SELECT id, note_id, NULLIF(title, ''), content, created_at FROM note_versions; + +DROP TABLE note_versions; +ALTER TABLE note_versions_new RENAME TO note_versions; + +CREATE INDEX idx_note_versions_note_id ON note_versions(note_id); diff --git a/crates/adapters/sqlite/src/db.rs b/crates/adapters/sqlite/src/db.rs new file mode 100644 index 0000000..e248618 --- /dev/null +++ b/crates/adapters/sqlite/src/db.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +pub use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; +use std::str::FromStr; + +use domain::errors::DomainError; + +pub async fn connect(database_url: &str) -> Result { + let options = SqliteConnectOptions::from_str(database_url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .foreign_keys(true); + + SqlitePoolOptions::new() + .max_connections(5) + .connect_with(options) + .await +} + +pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!("./migrations").run(pool).await +} + +/// Parse a datetime string from SQLite (RFC3339 or naive format). +pub(crate) fn parse_dt(s: &str) -> Result, DomainError> { + DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc()) + }) + .map_err(|e| DomainError::Repository(format!("invalid datetime '{s}': {e}"))) +} + +/// Map a sqlx error to DomainError::Repository. +pub(crate) trait RepoExt { + fn repo(self) -> Result; +} + +impl RepoExt for Result { + fn repo(self) -> Result { + self.map_err(|e| DomainError::Repository(e.to_string())) + } +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs new file mode 100644 index 0000000..52b156a --- /dev/null +++ b/crates/adapters/sqlite/src/lib.rs @@ -0,0 +1,5 @@ +pub mod db; +pub mod link; +pub mod note; +pub mod tag; +pub mod user; diff --git a/crates/adapters/sqlite/src/link.rs b/crates/adapters/sqlite/src/link.rs new file mode 100644 index 0000000..5a5a7f1 --- /dev/null +++ b/crates/adapters/sqlite/src/link.rs @@ -0,0 +1,103 @@ +use async_trait::async_trait; +use sqlx::{FromRow, SqlitePool}; + +use domain::{ + errors::{DomainError, DomainResult}, + note::{ + entity::{NoteId, NoteLink}, + ports::LinkRepository, + }, +}; + +use crate::db::RepoExt; + +pub struct SqliteLinkRepository { + pool: SqlitePool, +} + +impl SqliteLinkRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[derive(FromRow)] +struct LinkRow { + source_note_id: String, + target_note_id: String, + score: f32, + created_at: String, +} + +impl TryFrom for NoteLink { + type Error = DomainError; + + fn try_from(row: LinkRow) -> Result { + let source_id = NoteId::from_uuid( + uuid::Uuid::parse_str(&row.source_note_id) + .map_err(|e| DomainError::Repository(format!("invalid source uuid: {e}")))?, + ); + let target_id = NoteId::from_uuid( + uuid::Uuid::parse_str(&row.target_note_id) + .map_err(|e| DomainError::Repository(format!("invalid target uuid: {e}")))?, + ); + let created_at = crate::db::parse_dt(&row.created_at)?; + Ok(NoteLink { + source_id, + target_id, + score: row.score, + created_at, + }) + } +} + +#[async_trait] +impl LinkRepository for SqliteLinkRepository { + async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> { + let mut tx = self.pool.begin().await.repo()?; + + for link in links { + sqlx::query( + r#" + INSERT INTO note_links (source_note_id, target_note_id, score, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_note_id, target_note_id) DO UPDATE SET + score = excluded.score, + created_at = excluded.created_at + "#, + ) + .bind(link.source_id.as_uuid().to_string()) + .bind(link.target_id.as_uuid().to_string()) + .bind(link.score) + .bind(link.created_at.to_rfc3339()) + .execute(&mut *tx) + .await + .repo()?; + } + + tx.commit().await.repo() + } + + async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> { + sqlx::query("DELETE FROM note_links WHERE source_note_id = ?") + .bind(source_id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn find_for_note(&self, note_id: &NoteId) -> DomainResult> { + sqlx::query_as::<_, LinkRow>( + "SELECT source_note_id, target_note_id, score, created_at \ + FROM note_links WHERE source_note_id = ? ORDER BY score DESC", + ) + .bind(note_id.as_uuid().to_string()) + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(NoteLink::try_from) + .collect() + } +} diff --git a/crates/adapters/sqlite/src/note.rs b/crates/adapters/sqlite/src/note.rs new file mode 100644 index 0000000..52076fd --- /dev/null +++ b/crates/adapters/sqlite/src/note.rs @@ -0,0 +1,281 @@ +use async_trait::async_trait; +use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool}; + +use domain::{ + errors::{DomainError, DomainResult}, + note::{ + entity::{Note, NoteFilter, NoteId, NoteVersion}, + ports::NoteRepository, + value_objects::{NoteColor, NoteTitle}, + }, + tag::entity::{Tag, TagId}, + user::entity::UserId, +}; + +use crate::db::{RepoExt, parse_dt}; + +pub struct SqliteNoteRepository { + pool: SqlitePool, +} + +impl SqliteNoteRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +// ── Row types ──────────────────────────────────────────────────────────────── + +#[derive(FromRow)] +struct NoteRow { + id: String, + user_id: String, + title: Option, + content: String, + color: String, + is_pinned: i32, + is_archived: i32, + created_at: String, + updated_at: String, + tags_json: String, +} + +impl TryFrom for Note { + type Error = DomainError; + + fn try_from(row: NoteRow) -> Result { + let id = NoteId::from_uuid( + uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?, + ); + let user_id = UserId::from_uuid( + uuid::Uuid::parse_str(&row.user_id) + .map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?, + ); + let title = NoteTitle::from_optional(row.title)?; + let tags = parse_tags_json(&row.tags_json)?; + + Ok(Note { + id, + user_id, + title, + content: row.content, + color: NoteColor::new(row.color), + is_pinned: row.is_pinned != 0, + is_archived: row.is_archived != 0, + created_at: parse_dt(&row.created_at)?, + updated_at: parse_dt(&row.updated_at)?, + tags, + }) + } +} + +fn parse_tags_json(json: &str) -> Result, DomainError> { + let values: Vec = serde_json::from_str(json) + .map_err(|e| DomainError::Repository(format!("invalid tags json: {e}")))?; + + values + .into_iter() + .filter(|v| !v.is_null()) + .map(|v| { + let parse_str = |key: &str| { + v[key] + .as_str() + .ok_or_else(|| DomainError::Repository(format!("missing tag field '{key}'"))) + }; + let id = TagId::from_uuid( + uuid::Uuid::parse_str(parse_str("id")?) + .map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?, + ); + let user_id = UserId::from_uuid( + uuid::Uuid::parse_str(parse_str("user_id")?) + .map_err(|e| DomainError::Repository(format!("invalid tag user_id: {e}")))?, + ); + let name = domain::tag::value_objects::TagName::new(parse_str("name")?)?; + Ok(Tag::from_row(id, name, user_id)) + }) + .collect() +} + +#[derive(FromRow)] +struct VersionRow { + id: String, + note_id: String, + title: Option, + content: String, + created_at: String, +} + +impl TryFrom for NoteVersion { + type Error = DomainError; + + fn try_from(row: VersionRow) -> Result { + Ok(NoteVersion { + id: uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::Repository(format!("invalid version uuid: {e}")))?, + note_id: NoteId::from_uuid( + uuid::Uuid::parse_str(&row.note_id) + .map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?, + ), + title: row.title, + content: row.content, + created_at: parse_dt(&row.created_at)?, + }) + } +} + +// ── Shared SELECT fragment ──────────────────────────────────────────────────── + +const NOTE_SELECT: &str = r#" + SELECT n.id, n.user_id, n.title, n.content, n.color, n.is_pinned, n.is_archived, + n.created_at, n.updated_at, + json_group_array( + CASE WHEN t.id IS NOT NULL + THEN json_object('id', t.id, 'name', t.name, 'user_id', t.user_id) + ELSE NULL END + ) AS tags_json + FROM notes n + LEFT JOIN note_tags nt ON n.id = nt.note_id + LEFT JOIN tags t ON nt.tag_id = t.id +"#; + +// ── NoteRepository ─────────────────────────────────────────────────────────── + +#[async_trait] +impl NoteRepository for SqliteNoteRepository { + async fn find_by_id(&self, id: &NoteId) -> DomainResult> { + let sql = format!("{NOTE_SELECT} WHERE n.id = ? GROUP BY n.id"); + sqlx::query_as::<_, NoteRow>(&sql) + .bind(id.as_uuid().to_string()) + .fetch_optional(&self.pool) + .await + .repo()? + .map(Note::try_from) + .transpose() + } + + async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult> { + let base = format!("{NOTE_SELECT} WHERE n.user_id = "); + let mut qb: QueryBuilder = QueryBuilder::new(base); + qb.push_bind(user_id.as_uuid().to_string()); + + if let Some(pinned) = filter.is_pinned { + qb.push(" AND n.is_pinned = ").push_bind(pinned as i32); + } + if let Some(archived) = filter.is_archived { + qb.push(" AND n.is_archived = ").push_bind(archived as i32); + } + if let Some(tag_id) = filter.tag_id { + qb.push(" AND n.id IN (SELECT note_id FROM note_tags WHERE tag_id = ") + .push_bind(tag_id.as_uuid().to_string()) + .push(")"); + } + + qb.push(" GROUP BY n.id ORDER BY n.is_pinned DESC, n.updated_at DESC"); + + qb.build_query_as::() + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(Note::try_from) + .collect() + } + + async fn search(&self, user_id: &UserId, query: &str) -> DomainResult> { + let sql = format!( + r#"{NOTE_SELECT} + WHERE n.user_id = ? + AND ( + n.rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?) + OR EXISTS ( + SELECT 1 FROM note_tags nt2 + JOIN tags t2 ON nt2.tag_id = t2.id + WHERE nt2.note_id = n.id AND t2.name LIKE ? + ) + ) + GROUP BY n.id ORDER BY n.updated_at DESC"# + ); + + sqlx::query_as::<_, NoteRow>(&sql) + .bind(user_id.as_uuid().to_string()) + .bind(query) + .bind(format!("%{query}%")) + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(Note::try_from) + .collect() + } + + async fn save(&self, note: &Note) -> DomainResult<()> { + sqlx::query( + r#" + INSERT INTO notes (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + content = excluded.content, + color = excluded.color, + is_pinned = excluded.is_pinned, + is_archived = excluded.is_archived, + updated_at = excluded.updated_at + "#, + ) + .bind(note.id.as_uuid().to_string()) + .bind(note.user_id.as_uuid().to_string()) + .bind(note.title.as_ref().map(|t| t.as_ref())) + .bind(¬e.content) + .bind(note.color.as_str()) + .bind(note.is_pinned as i32) + .bind(note.is_archived as i32) + .bind(note.created_at.to_rfc3339()) + .bind(note.updated_at.to_rfc3339()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn delete(&self, id: &NoteId) -> DomainResult<()> { + sqlx::query("DELETE FROM notes WHERE id = ?") + .bind(id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn save_version(&self, version: &NoteVersion) -> DomainResult<()> { + sqlx::query( + "INSERT INTO note_versions (id, note_id, title, content, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(version.id.to_string()) + .bind(version.note_id.as_uuid().to_string()) + .bind(version.title.as_deref()) + .bind(&version.content) + .bind(version.created_at.to_rfc3339()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn find_versions(&self, note_id: &NoteId) -> DomainResult> { + sqlx::query_as::<_, VersionRow>( + "SELECT id, note_id, title, content, created_at FROM note_versions WHERE note_id = ? ORDER BY created_at DESC", + ) + .bind(note_id.as_uuid().to_string()) + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(NoteVersion::try_from) + .collect() + } +} + +#[cfg(test)] +#[path = "tests/note.rs"] +mod tests; diff --git a/crates/adapters/sqlite/src/tag.rs b/crates/adapters/sqlite/src/tag.rs new file mode 100644 index 0000000..1592e25 --- /dev/null +++ b/crates/adapters/sqlite/src/tag.rs @@ -0,0 +1,157 @@ +use async_trait::async_trait; +use sqlx::{FromRow, SqlitePool}; + +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::NoteId, + tag::{ + entity::{Tag, TagId}, + ports::TagRepository, + value_objects::TagName, + }, + user::entity::UserId, +}; + +use crate::db::RepoExt; + +pub struct SqliteTagRepository { + pool: SqlitePool, +} + +impl SqliteTagRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[derive(FromRow)] +struct TagRow { + id: String, + name: String, + user_id: String, +} + +impl TryFrom for Tag { + type Error = DomainError; + + fn try_from(row: TagRow) -> Result { + let id = TagId::from_uuid( + uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?, + ); + let user_id = UserId::from_uuid( + uuid::Uuid::parse_str(&row.user_id) + .map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?, + ); + let name = TagName::new(row.name)?; + Ok(Tag::from_row(id, name, user_id)) + } +} + +#[async_trait] +impl TagRepository for SqliteTagRepository { + async fn find_by_id(&self, id: &TagId) -> DomainResult> { + sqlx::query_as::<_, TagRow>("SELECT id, name, user_id FROM tags WHERE id = ?") + .bind(id.as_uuid().to_string()) + .fetch_optional(&self.pool) + .await + .repo()? + .map(Tag::try_from) + .transpose() + } + + async fn find_by_user(&self, user_id: &UserId) -> DomainResult> { + sqlx::query_as::<_, TagRow>( + "SELECT id, name, user_id FROM tags WHERE user_id = ? ORDER BY name", + ) + .bind(user_id.as_uuid().to_string()) + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(Tag::try_from) + .collect() + } + + async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult> { + sqlx::query_as::<_, TagRow>( + "SELECT id, name, user_id FROM tags WHERE user_id = ? AND name = ?", + ) + .bind(user_id.as_uuid().to_string()) + .bind(name.as_ref()) + .fetch_optional(&self.pool) + .await + .repo()? + .map(Tag::try_from) + .transpose() + } + + async fn find_by_note(&self, note_id: &NoteId) -> DomainResult> { + sqlx::query_as::<_, TagRow>( + r#" + SELECT t.id, t.name, t.user_id + FROM tags t + INNER JOIN note_tags nt ON t.id = nt.tag_id + WHERE nt.note_id = ? + ORDER BY t.name + "#, + ) + .bind(note_id.as_uuid().to_string()) + .fetch_all(&self.pool) + .await + .repo()? + .into_iter() + .map(Tag::try_from) + .collect() + } + + async fn save(&self, tag: &Tag) -> DomainResult<()> { + sqlx::query( + r#" + INSERT INTO tags (id, name, user_id) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name + "#, + ) + .bind(tag.id.as_uuid().to_string()) + .bind(tag.name.as_ref()) + .bind(tag.user_id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn delete(&self, id: &TagId) -> DomainResult<()> { + sqlx::query("DELETE FROM tags WHERE id = ?") + .bind(id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> { + sqlx::query("INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)") + .bind(note_id.as_uuid().to_string()) + .bind(tag_id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> { + sqlx::query("DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?") + .bind(note_id.as_uuid().to_string()) + .bind(tag_id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } +} + +#[cfg(test)] +#[path = "tests/tag.rs"] +mod tests; diff --git a/crates/adapters/sqlite/src/tests/note.rs b/crates/adapters/sqlite/src/tests/note.rs new file mode 100644 index 0000000..bbea2af --- /dev/null +++ b/crates/adapters/sqlite/src/tests/note.rs @@ -0,0 +1,119 @@ +use sqlx::SqlitePool; + +use domain::{ + note::{ + entity::{Note, NoteFilter}, + ports::NoteRepository, + value_objects::NoteTitle, + }, + user::{entity::User, ports::UserRepository, value_objects::Email}, +}; + +use crate::{db::run_migrations, note::SqliteNoteRepository, user::SqliteUserRepository}; + +async fn pool() -> SqlitePool { + let p = SqlitePool::connect("sqlite::memory:").await.unwrap(); + run_migrations(&p).await.unwrap(); + p +} + +async fn seed_user(pool: &SqlitePool) -> User { + let repo = SqliteUserRepository::new(pool.clone()); + let user = User::new_oidc("sub", Email::new("u@example.com").unwrap()); + repo.save(&user).await.unwrap(); + user +} + +#[tokio::test] +async fn save_and_find_by_id() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let note = Note::new(user.id, NoteTitle::new("Hello").ok(), "world".to_string()); + repo.save(¬e).await.unwrap(); + + let found = repo.find_by_id(¬e.id).await.unwrap().unwrap(); + assert_eq!(found.content, "world"); + assert_eq!(found.title.as_ref().unwrap().as_ref(), "Hello"); +} + +#[tokio::test] +async fn save_note_without_title() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let note = Note::new(user.id, None, "no title".to_string()); + repo.save(¬e).await.unwrap(); + + let found = repo.find_by_id(¬e.id).await.unwrap().unwrap(); + assert!(found.title.is_none()); +} + +#[tokio::test] +async fn find_by_user_with_pinned_filter() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let mut pinned = Note::new(user.id, None, "pinned".to_string()); + pinned.set_pinned(true); + repo.save(&pinned).await.unwrap(); + repo.save(&Note::new(user.id, None, "normal".to_string())) + .await + .unwrap(); + + let results = repo + .find_by_user(&user.id, NoteFilter::default().pinned()) + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].content, "pinned"); +} + +#[tokio::test] +async fn delete_removes_note() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let note = Note::new(user.id, None, "bye".to_string()); + repo.save(¬e).await.unwrap(); + repo.delete(¬e.id).await.unwrap(); + + assert!(repo.find_by_id(¬e.id).await.unwrap().is_none()); +} + +#[tokio::test] +async fn save_and_find_versions() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let note = Note::new(user.id, None, "v1".to_string()); + repo.save(¬e).await.unwrap(); + + let version = domain::note::entity::NoteVersion::snapshot(¬e); + repo.save_version(&version).await.unwrap(); + + let versions = repo.find_versions(¬e.id).await.unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].content, "v1"); +} + +#[tokio::test] +async fn upsert_updates_note() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteNoteRepository::new(p); + + let mut note = Note::new(user.id, None, "original".to_string()); + repo.save(¬e).await.unwrap(); + + note.set_content("updated"); + repo.save(¬e).await.unwrap(); + + let found = repo.find_by_id(¬e.id).await.unwrap().unwrap(); + assert_eq!(found.content, "updated"); +} diff --git a/crates/adapters/sqlite/src/tests/tag.rs b/crates/adapters/sqlite/src/tests/tag.rs new file mode 100644 index 0000000..edb570d --- /dev/null +++ b/crates/adapters/sqlite/src/tests/tag.rs @@ -0,0 +1,82 @@ +use sqlx::SqlitePool; + +use domain::{ + tag::{entity::Tag, ports::TagRepository, value_objects::TagName}, + user::entity::{User, UserId}, +}; + +use crate::{db::run_migrations, tag::SqliteTagRepository, user::SqliteUserRepository}; +use domain::user::{ports::UserRepository, value_objects::Email}; + +async fn pool() -> SqlitePool { + let p = SqlitePool::connect("sqlite::memory:").await.unwrap(); + run_migrations(&p).await.unwrap(); + p +} + +async fn seed_user(pool: &SqlitePool) -> User { + let repo = SqliteUserRepository::new(pool.clone()); + let user = User::new_oidc("sub", Email::new("u@example.com").unwrap()); + repo.save(&user).await.unwrap(); + user +} + +#[tokio::test] +async fn save_and_find_by_id() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteTagRepository::new(p); + + let tag = Tag::new(TagName::new("work").unwrap(), user.id); + repo.save(&tag).await.unwrap(); + + let found = repo.find_by_id(&tag.id).await.unwrap().unwrap(); + assert_eq!(found.name.as_ref(), "work"); +} + +#[tokio::test] +async fn find_by_name() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteTagRepository::new(p); + + let tag = Tag::new(TagName::new("rust").unwrap(), user.id); + repo.save(&tag).await.unwrap(); + + let found = repo + .find_by_name(&user.id, &TagName::new("rust").unwrap()) + .await + .unwrap(); + assert_eq!(found.unwrap().id, tag.id); +} + +#[tokio::test] +async fn find_by_user_returns_sorted() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteTagRepository::new(p); + + repo.save(&Tag::new(TagName::new("zebra").unwrap(), user.id)) + .await + .unwrap(); + repo.save(&Tag::new(TagName::new("alpha").unwrap(), user.id)) + .await + .unwrap(); + + let tags = repo.find_by_user(&user.id).await.unwrap(); + assert_eq!(tags[0].name.as_ref(), "alpha"); + assert_eq!(tags[1].name.as_ref(), "zebra"); +} + +#[tokio::test] +async fn delete_removes_tag() { + let p = pool().await; + let user = seed_user(&p).await; + let repo = SqliteTagRepository::new(p); + + let tag = Tag::new(TagName::new("gone").unwrap(), user.id); + repo.save(&tag).await.unwrap(); + repo.delete(&tag.id).await.unwrap(); + + assert!(repo.find_by_id(&tag.id).await.unwrap().is_none()); +} diff --git a/crates/adapters/sqlite/src/tests/user.rs b/crates/adapters/sqlite/src/tests/user.rs new file mode 100644 index 0000000..88cf302 --- /dev/null +++ b/crates/adapters/sqlite/src/tests/user.rs @@ -0,0 +1,84 @@ +use sqlx::SqlitePool; + +use domain::user::{ + entity::{User, UserId}, + ports::UserRepository, + value_objects::{Email, PasswordHash}, +}; + +use crate::{db::run_migrations, user::SqliteUserRepository}; + +async fn pool() -> SqlitePool { + let p = SqlitePool::connect("sqlite::memory:").await.unwrap(); + run_migrations(&p).await.unwrap(); + p +} + +#[tokio::test] +async fn save_and_find_by_id() { + let repo = SqliteUserRepository::new(pool().await); + let user = User::new_oidc("oidc|123", Email::new("a@example.com").unwrap()); + repo.save(&user).await.unwrap(); + + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.subject, "oidc|123"); + assert_eq!(found.email.as_ref(), "a@example.com"); + assert!(found.password_hash.is_none()); +} + +#[tokio::test] +async fn save_local_user_with_password_hash() { + let repo = SqliteUserRepository::new(pool().await); + let user = User::new_local( + Email::new("local@example.com").unwrap(), + PasswordHash::new("argon2hash"), + ); + repo.save(&user).await.unwrap(); + + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.password_hash.unwrap().as_str(), "argon2hash"); +} + +#[tokio::test] +async fn find_by_subject() { + let repo = SqliteUserRepository::new(pool().await); + let user = User::new_oidc("google|456", Email::new("g@example.com").unwrap()); + repo.save(&user).await.unwrap(); + + let found = repo.find_by_subject("google|456").await.unwrap().unwrap(); + assert_eq!(found.id, user.id); +} + +#[tokio::test] +async fn find_by_email() { + let repo = SqliteUserRepository::new(pool().await); + let email = Email::new("find@example.com").unwrap(); + let user = User::new_oidc("sub", email.clone()); + repo.save(&user).await.unwrap(); + + let found = repo.find_by_email(&email).await.unwrap().unwrap(); + assert_eq!(found.id, user.id); +} + +#[tokio::test] +async fn delete_removes_user() { + let repo = SqliteUserRepository::new(pool().await); + let user = User::new_oidc("del|1", Email::new("del@example.com").unwrap()); + repo.save(&user).await.unwrap(); + repo.delete(&user.id).await.unwrap(); + + assert!(repo.find_by_id(&user.id).await.unwrap().is_none()); +} + +#[tokio::test] +async fn upsert_updates_existing_user() { + let repo = SqliteUserRepository::new(pool().await); + let mut user = User::new_oidc("sub", Email::new("u@example.com").unwrap()); + repo.save(&user).await.unwrap(); + + user.subject = "sub-updated".into(); + repo.save(&user).await.unwrap(); + + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.subject, "sub-updated"); +} diff --git a/crates/adapters/sqlite/src/user.rs b/crates/adapters/sqlite/src/user.rs new file mode 100644 index 0000000..670fed6 --- /dev/null +++ b/crates/adapters/sqlite/src/user.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use sqlx::{FromRow, SqlitePool}; + +use domain::{ + errors::DomainResult, + user::{ + entity::{User, UserId}, + ports::UserRepository, + value_objects::{Email, PasswordHash}, + }, +}; + +use crate::db::{RepoExt, parse_dt}; + +pub struct SqliteUserRepository { + pool: SqlitePool, +} + +impl SqliteUserRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[derive(FromRow)] +struct UserRow { + id: String, + subject: String, + email: String, + password_hash: Option, + created_at: String, +} + +impl TryFrom for User { + type Error = domain::errors::DomainError; + + fn try_from(row: UserRow) -> Result { + use domain::errors::DomainError; + let id = UserId::from_uuid( + uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?, + ); + let email = Email::new(&row.email)?; + let password_hash = row.password_hash.map(PasswordHash::new); + let created_at = parse_dt(&row.created_at)?; + Ok(User::from_row( + id, + row.subject, + email, + password_hash, + created_at, + )) + } +} + +#[async_trait] +impl UserRepository for SqliteUserRepository { + async fn find_by_id(&self, id: &UserId) -> DomainResult> { + let id_str = id.as_uuid().to_string(); + sqlx::query_as::<_, UserRow>( + "SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?", + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .repo()? + .map(User::try_from) + .transpose() + } + + async fn find_by_subject(&self, subject: &str) -> DomainResult> { + sqlx::query_as::<_, UserRow>( + "SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?", + ) + .bind(subject) + .fetch_optional(&self.pool) + .await + .repo()? + .map(User::try_from) + .transpose() + } + + async fn find_by_email(&self, email: &Email) -> DomainResult> { + sqlx::query_as::<_, UserRow>( + "SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?", + ) + .bind(email.as_ref()) + .fetch_optional(&self.pool) + .await + .repo()? + .map(User::try_from) + .transpose() + } + + async fn save(&self, user: &User) -> DomainResult<()> { + sqlx::query( + r#" + INSERT INTO users (id, subject, email, password_hash, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + subject = excluded.subject, + email = excluded.email, + password_hash = excluded.password_hash + "#, + ) + .bind(user.id.as_uuid().to_string()) + .bind(&user.subject) + .bind(user.email.as_ref()) + .bind(user.password_hash.as_ref().map(PasswordHash::as_str)) + .bind(user.created_at.to_rfc3339()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } + + async fn delete(&self, id: &UserId) -> DomainResult<()> { + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id.as_uuid().to_string()) + .execute(&self.pool) + .await + .repo() + .map(|_| ()) + } +} + +#[cfg(test)] +#[path = "tests/user.rs"] +mod tests; diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml new file mode 100644 index 0000000..81ec18c --- /dev/null +++ b/crates/api-types/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "api-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } diff --git a/crates/api-types/src/auth.rs b/crates/api-types/src/auth.rs new file mode 100644 index 0000000..0856d92 --- /dev/null +++ b/crates/api-types/src/auth.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UserResponse { + pub id: Uuid, + pub email: String, + pub created_at: DateTime, +} + +/// Returned after a successful login or register. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AuthResponse { + pub user: UserResponse, + pub access_token: String, +} diff --git a/crates/api-types/src/backup.rs b/crates/api-types/src/backup.rs new file mode 100644 index 0000000..d716add --- /dev/null +++ b/crates/api-types/src/backup.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// A note in portable backup format (no IDs — uses names and content only). +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BackupNote { + pub title: Option, + pub content: String, + pub color: String, + pub is_pinned: bool, + pub is_archived: bool, + /// Tag names associated with this note. + pub tags: Vec, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BackupData { + pub notes: Vec, +} diff --git a/crates/api-types/src/config.rs b/crates/api-types/src/config.rs new file mode 100644 index 0000000..9e5cabe --- /dev/null +++ b/crates/api-types/src/config.rs @@ -0,0 +1,6 @@ +use serde::Serialize; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ConfigResponse { + pub allow_registration: bool, +} diff --git a/crates/api-types/src/errors.rs b/crates/api-types/src/errors.rs new file mode 100644 index 0000000..052c49e --- /dev/null +++ b/crates/api-types/src/errors.rs @@ -0,0 +1,36 @@ +use serde::Serialize; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ErrorResponse { + pub code: String, + pub message: String, +} + +impl ErrorResponse { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self::new("NOT_FOUND", message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new("FORBIDDEN", message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::new("CONFLICT", message) + } + + pub fn validation(message: impl Into) -> Self { + Self::new("VALIDATION_ERROR", message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new("INTERNAL_ERROR", message) + } +} diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs new file mode 100644 index 0000000..26aa024 --- /dev/null +++ b/crates/api-types/src/lib.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod backup; +pub mod config; +pub mod errors; +pub mod notes; +pub mod tags; diff --git a/crates/api-types/src/notes.rs b/crates/api-types/src/notes.rs new file mode 100644 index 0000000..817a939 --- /dev/null +++ b/crates/api-types/src/notes.rs @@ -0,0 +1,82 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::tags::TagResponse; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateNoteRequest { + pub title: Option, + #[serde(default)] + pub content: String, + pub color: Option, + #[serde(default)] + pub is_pinned: bool, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateNoteRequest { + pub title: Option, + pub content: Option, + pub color: Option, +} + +#[derive(Debug, Deserialize, Default, utoipa::IntoParams)] +pub struct ListNotesParams { + pub pinned: Option, + pub archived: Option, + /// Filter by tag name. + pub tag: Option, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct SearchParams { + pub q: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct AddTagRequest { + pub tag_name: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct PinRequest { + pub pinned: bool, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ArchiveRequest { + pub archived: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct NoteResponse { + pub id: Uuid, + pub user_id: Uuid, + pub title: Option, + pub content: String, + pub color: String, + pub is_pinned: bool, + pub is_archived: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub tags: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct NoteVersionResponse { + pub id: Uuid, + pub note_id: Uuid, + pub title: Option, + pub content: String, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct NoteLinkResponse { + pub source_id: Uuid, + pub target_id: Uuid, + /// Cosine similarity score in [0.0, 1.0]. + pub score: f32, + pub created_at: DateTime, +} diff --git a/crates/api-types/src/tags.rs b/crates/api-types/src/tags.rs new file mode 100644 index 0000000..3e1ccb5 --- /dev/null +++ b/crates/api-types/src/tags.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateTagRequest { + pub name: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RenameTagRequest { + pub name: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct TagResponse { + pub id: Uuid, + pub name: String, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..cae9890 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } diff --git a/crates/application/src/auth/commands.rs b/crates/application/src/auth/commands.rs new file mode 100644 index 0000000..7a44ad9 --- /dev/null +++ b/crates/application/src/auth/commands.rs @@ -0,0 +1,9 @@ +pub struct RegisterCommand { + pub email: String, + pub password: String, +} + +pub struct LoginCommand { + pub email: String, + pub password: String, +} diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs new file mode 100644 index 0000000..7010fff --- /dev/null +++ b/crates/application/src/auth/login.rs @@ -0,0 +1,37 @@ +use domain::{ + errors::{DomainError, DomainResult}, + user::{ + entity::User, + value_objects::{Email, Password}, + }, +}; + +use super::commands::LoginCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> DomainResult { + let email = Email::new(&cmd.email)?; + let password = Password::new(cmd.password)?; + + let user = ctx + .repos + .user + .find_by_email(&email) + .await? + .ok_or_else(|| DomainError::NotFound(format!("user {email}")))?; + + let hash = user + .password_hash + .as_ref() + .ok_or_else(|| DomainError::Forbidden("account uses external authentication".into()))?; + + if !ctx.services.password_hasher.verify(&password, hash).await? { + return Err(DomainError::Forbidden("invalid credentials".into())); + } + + Ok(user) +} + +#[cfg(test)] +#[path = "tests/login.rs"] +mod tests; diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs new file mode 100644 index 0000000..327219c --- /dev/null +++ b/crates/application/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod login; +pub mod register; diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs new file mode 100644 index 0000000..aae52f5 --- /dev/null +++ b/crates/application/src/auth/register.rs @@ -0,0 +1,31 @@ +use domain::{ + errors::DomainResult, + user::{ + entity::User, + value_objects::{Email, Password}, + }, +}; + +use super::commands::RegisterCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> DomainResult { + let email = Email::new(&cmd.email)?; + let password = Password::new(cmd.password)?; + + if ctx.repos.user.find_by_email(&email).await?.is_some() { + return Err(domain::errors::DomainError::Conflict(format!( + "user with email {} already exists", + email + ))); + } + + let hash = ctx.services.password_hasher.hash(&password).await?; + let user = User::new_local(email, hash); + ctx.repos.user.save(&user).await?; + Ok(user) +} + +#[cfg(test)] +#[path = "tests/register.rs"] +mod tests; diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs new file mode 100644 index 0000000..d06efb1 --- /dev/null +++ b/crates/application/src/auth/tests/login.rs @@ -0,0 +1,66 @@ +use crate::{ + auth::{ + commands::{LoginCommand, RegisterCommand}, + login, register, + }, + test_helpers::TestContext, +}; + +async fn registered_ctx() -> (TestContext, String, String) { + let t = TestContext::new(); + let email = "user@example.com".to_string(); + let password = "password123".to_string(); + register::execute( + &t.ctx, + RegisterCommand { + email: email.clone(), + password: password.clone(), + }, + ) + .await + .unwrap(); + (t, email, password) +} + +#[tokio::test] +async fn valid_credentials_return_user() { + let (t, email, password) = registered_ctx().await; + let user = login::execute(&t.ctx, LoginCommand { email, password }) + .await + .unwrap(); + assert_eq!(user.email.as_ref(), "user@example.com"); +} + +#[tokio::test] +async fn wrong_password_is_rejected() { + let (t, email, _) = registered_ctx().await; + let result = login::execute( + &t.ctx, + LoginCommand { + email, + password: "wrongpass".into(), + }, + ) + .await; + assert!(matches!( + result, + Err(domain::errors::DomainError::Forbidden(_)) + )); +} + +#[tokio::test] +async fn unknown_email_is_not_found() { + let t = TestContext::new(); + let result = login::execute( + &t.ctx, + LoginCommand { + email: "ghost@example.com".into(), + password: "password123".into(), + }, + ) + .await; + assert!(matches!( + result, + Err(domain::errors::DomainError::NotFound(_)) + )); +} diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs new file mode 100644 index 0000000..2229180 --- /dev/null +++ b/crates/application/src/auth/tests/register.rs @@ -0,0 +1,56 @@ +use crate::{ + auth::{commands::RegisterCommand, register}, + test_helpers::TestContext, +}; + +#[tokio::test] +async fn registers_new_user() { + let t = TestContext::new(); + let user = register::execute( + &t.ctx, + RegisterCommand { + email: "user@example.com".into(), + password: "password123".into(), + }, + ) + .await + .unwrap(); + + assert_eq!(user.email.as_ref(), "user@example.com"); + assert!(user.password_hash.is_some()); +} + +#[tokio::test] +async fn rejects_duplicate_email() { + let t = TestContext::new(); + let cmd = || RegisterCommand { + email: "dup@example.com".into(), + password: "password123".into(), + }; + + register::execute(&t.ctx, cmd()).await.unwrap(); + let result = register::execute(&t.ctx, cmd()).await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::Conflict(_)) + )); +} + +#[tokio::test] +async fn rejects_invalid_email() { + let t = TestContext::new(); + let result = register::execute( + &t.ctx, + RegisterCommand { + email: "not-an-email".into(), + password: "password123".into(), + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::Validation(_)) + )); +} diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs new file mode 100644 index 0000000..709a0f1 --- /dev/null +++ b/crates/application/src/config.rs @@ -0,0 +1,33 @@ +/// Application-level configuration. Auth and infra adapter config lives in their own crates. +#[derive(Debug, Clone)] +pub struct AppConfig { + pub base_url: String, + pub smart: SmartConfig, + /// When false the `/auth/register` endpoint returns 403. + pub allow_registration: bool, +} + +#[derive(Debug, Clone)] +pub struct SmartConfig { + pub neighbour_limit: usize, + pub min_similarity: f32, +} + +impl Default for SmartConfig { + fn default() -> Self { + Self { + neighbour_limit: 10, + min_similarity: 0.7, + } + } +} + +impl AppConfig { + pub fn from_env() -> Self { + Self { + base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), + smart: SmartConfig::default(), + allow_registration: true, + } + } +} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs new file mode 100644 index 0000000..85df85b --- /dev/null +++ b/crates/application/src/context.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use domain::{ + events::{EventConsumer, EventPublisher}, + note::ports::{LinkRepository, NoteRepository}, + smart::ports::{EmbeddingGenerator, VectorStore}, + tag::ports::TagRepository, + user::ports::{PasswordHasher, UserRepository}, +}; + +use crate::config::AppConfig; + +#[derive(Clone)] +pub struct Repositories { + pub note: Arc, + pub tag: Arc, + pub user: Arc, + pub link: Arc, +} + +#[derive(Clone)] +pub struct Services { + pub password_hasher: Arc, + pub event_publisher: Arc, + /// None when smart features are not configured. + pub embedding: Option>, + /// None when smart features are not configured. + pub vector_store: Option>, + pub event_consumer: Arc, +} + +#[derive(Clone)] +pub struct AppContext { + pub repos: Repositories, + pub services: Services, + pub config: AppConfig, +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..251508d --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,10 @@ +pub mod auth; +pub mod config; +pub mod context; +pub mod notes; +pub mod smart; +pub mod tags; +pub mod worker; + +#[cfg(test)] +pub(crate) mod test_helpers; diff --git a/crates/application/src/notes/add_tag.rs b/crates/application/src/notes/add_tag.rs new file mode 100644 index 0000000..4335b1a --- /dev/null +++ b/crates/application/src/notes/add_tag.rs @@ -0,0 +1,51 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::{MAX_TAGS_PER_NOTE, NoteId}, + tag::entity::TagId, + user::entity::UserId, +}; + +use super::commands::AddTagCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: AddTagCommand) -> DomainResult<()> { + let note_id = NoteId::from_uuid(cmd.note_id); + let tag_id = TagId::from_uuid(cmd.tag_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot modify another user's note".into(), + )); + } + + if !note.can_add_tag() { + return Err(DomainError::Conflict(format!( + "note already has the maximum of {MAX_TAGS_PER_NOTE} tags" + ))); + } + + let tag = ctx + .repos + .tag + .find_by_id(&tag_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?; + + if tag.user_id != user_id { + return Err(DomainError::Forbidden("tag belongs to another user".into())); + } + + ctx.repos.tag.add_to_note(&tag_id, ¬e_id).await +} + +#[cfg(test)] +#[path = "tests/add_tag.rs"] +mod tests; diff --git a/crates/application/src/notes/archive_note.rs b/crates/application/src/notes/archive_note.rs new file mode 100644 index 0000000..4a90322 --- /dev/null +++ b/crates/application/src/notes/archive_note.rs @@ -0,0 +1,30 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::Note, + user::entity::UserId, +}; + +use super::commands::ArchiveNoteCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: ArchiveNoteCommand) -> DomainResult { + let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let mut note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot modify another user's note".into(), + )); + } + + note.set_archived(cmd.archived); + ctx.repos.note.save(¬e).await?; + Ok(note) +} diff --git a/crates/application/src/notes/commands.rs b/crates/application/src/notes/commands.rs new file mode 100644 index 0000000..c5a9515 --- /dev/null +++ b/crates/application/src/notes/commands.rs @@ -0,0 +1,46 @@ +use uuid::Uuid; + +pub struct CreateNoteCommand { + pub user_id: Uuid, + pub title: Option, + pub content: String, + pub color: Option, + pub is_pinned: bool, +} + +pub struct UpdateNoteCommand { + pub note_id: Uuid, + pub user_id: Uuid, + pub title: Option, + pub content: Option, + pub color: Option, +} + +pub struct DeleteNoteCommand { + pub note_id: Uuid, + pub user_id: Uuid, +} + +pub struct PinNoteCommand { + pub note_id: Uuid, + pub user_id: Uuid, + pub pinned: bool, +} + +pub struct ArchiveNoteCommand { + pub note_id: Uuid, + pub user_id: Uuid, + pub archived: bool, +} + +pub struct AddTagCommand { + pub note_id: Uuid, + pub tag_id: Uuid, + pub user_id: Uuid, +} + +pub struct RemoveTagCommand { + pub note_id: Uuid, + pub tag_id: Uuid, + pub user_id: Uuid, +} diff --git a/crates/application/src/notes/create_note.rs b/crates/application/src/notes/create_note.rs new file mode 100644 index 0000000..e329b31 --- /dev/null +++ b/crates/application/src/notes/create_note.rs @@ -0,0 +1,46 @@ +use domain::{ + errors::DomainResult, + events::DomainEvent, + note::{ + entity::Note, + value_objects::{NoteColor, NoteTitle}, + }, + user::entity::UserId, +}; + +use super::commands::CreateNoteCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: CreateNoteCommand) -> DomainResult { + let user_id = UserId::from_uuid(cmd.user_id); + let title = NoteTitle::from_optional(cmd.title)?; + + let mut note = Note::new(user_id, title, cmd.content); + + if let Some(color) = cmd.color { + note.set_color(NoteColor::new(color)); + } + if cmd.is_pinned { + note.set_pinned(true); + } + + ctx.repos.note.save(¬e).await?; + + if let Err(e) = ctx + .services + .event_publisher + .publish(&DomainEvent::NoteCreated { + note_id: note.id, + user_id, + }) + .await + { + tracing::warn!("failed to publish NoteCreated: {e}"); + } + + Ok(note) +} + +#[cfg(test)] +#[path = "tests/create_note.rs"] +mod tests; diff --git a/crates/application/src/notes/delete_note.rs b/crates/application/src/notes/delete_note.rs new file mode 100644 index 0000000..aa39699 --- /dev/null +++ b/crates/application/src/notes/delete_note.rs @@ -0,0 +1,44 @@ +use domain::{ + errors::{DomainError, DomainResult}, + events::DomainEvent, + note::entity::NoteId, + user::entity::UserId, +}; + +use super::commands::DeleteNoteCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: DeleteNoteCommand) -> DomainResult<()> { + let note_id = NoteId::from_uuid(cmd.note_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot delete another user's note".into(), + )); + } + + ctx.repos.note.delete(¬e_id).await?; + + if let Err(e) = ctx + .services + .event_publisher + .publish(&DomainEvent::NoteDeleted { note_id, user_id }) + .await + { + tracing::warn!("failed to publish NoteDeleted: {e}"); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/delete_note.rs"] +mod tests; diff --git a/crates/application/src/notes/export_notes.rs b/crates/application/src/notes/export_notes.rs new file mode 100644 index 0000000..0cdebc7 --- /dev/null +++ b/crates/application/src/notes/export_notes.rs @@ -0,0 +1,33 @@ +use domain::{errors::DomainResult, note::entity::NoteFilter, user::entity::UserId}; + +use crate::context::AppContext; + +pub struct ExportedNote { + pub title: Option, + pub content: String, + pub color: String, + pub is_pinned: bool, + pub is_archived: bool, + pub tags: Vec, +} + +pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> DomainResult> { + let uid = UserId::from_uuid(user_id); + let notes = ctx + .repos + .note + .find_by_user(&uid, NoteFilter::default()) + .await?; + + Ok(notes + .into_iter() + .map(|n| ExportedNote { + title: n.title.map(|t| t.into_inner()), + content: n.content, + color: n.color.into_inner(), + is_pinned: n.is_pinned, + is_archived: n.is_archived, + tags: n.tags.into_iter().map(|t| t.name.into_inner()).collect(), + }) + .collect()) +} diff --git a/crates/application/src/notes/get_note.rs b/crates/application/src/notes/get_note.rs new file mode 100644 index 0000000..9a7afad --- /dev/null +++ b/crates/application/src/notes/get_note.rs @@ -0,0 +1,28 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::Note, + user::entity::UserId, +}; + +use super::queries::GetNoteQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: GetNoteQuery) -> DomainResult { + let note_id = domain::note::entity::NoteId::from_uuid(q.note_id); + let user_id = UserId::from_uuid(q.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "note belongs to another user".into(), + )); + } + + Ok(note) +} diff --git a/crates/application/src/notes/get_related.rs b/crates/application/src/notes/get_related.rs new file mode 100644 index 0000000..66b3efb --- /dev/null +++ b/crates/application/src/notes/get_related.rs @@ -0,0 +1,28 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::{NoteId, NoteLink}, + user::entity::UserId, +}; + +use super::queries::GetRelatedQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: GetRelatedQuery) -> DomainResult> { + let note_id = NoteId::from_uuid(q.note_id); + let user_id = UserId::from_uuid(q.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "note belongs to another user".into(), + )); + } + + ctx.repos.link.find_for_note(¬e_id).await +} diff --git a/crates/application/src/notes/get_versions.rs b/crates/application/src/notes/get_versions.rs new file mode 100644 index 0000000..fb74652 --- /dev/null +++ b/crates/application/src/notes/get_versions.rs @@ -0,0 +1,28 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::{NoteId, NoteVersion}, + user::entity::UserId, +}; + +use super::queries::GetVersionsQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: GetVersionsQuery) -> DomainResult> { + let note_id = NoteId::from_uuid(q.note_id); + let user_id = UserId::from_uuid(q.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "note belongs to another user".into(), + )); + } + + ctx.repos.note.find_versions(¬e_id).await +} diff --git a/crates/application/src/notes/import_notes.rs b/crates/application/src/notes/import_notes.rs new file mode 100644 index 0000000..2852eb8 --- /dev/null +++ b/crates/application/src/notes/import_notes.rs @@ -0,0 +1,73 @@ +use domain::errors::DomainResult; + +use super::{ + add_tag, archive_note, + commands::{AddTagCommand, ArchiveNoteCommand, CreateNoteCommand}, + create_note, +}; +use crate::context::AppContext; +use crate::tags::{commands::CreateTagCommand, create_tag}; + +pub struct ImportNote { + pub title: Option, + pub content: String, + pub color: Option, + pub is_pinned: bool, + pub is_archived: bool, + pub tags: Vec, +} + +pub async fn execute( + ctx: &AppContext, + user_id: uuid::Uuid, + notes: Vec, +) -> DomainResult<()> { + for item in notes { + let note = create_note::execute( + ctx, + CreateNoteCommand { + user_id, + title: item.title, + content: item.content, + color: item.color, + is_pinned: item.is_pinned, + }, + ) + .await?; + + if item.is_archived { + archive_note::execute( + ctx, + ArchiveNoteCommand { + note_id: note.id.as_uuid(), + user_id, + archived: true, + }, + ) + .await?; + } + + for tag_name in item.tags { + let tag = create_tag::execute( + ctx, + CreateTagCommand { + user_id, + name: tag_name, + }, + ) + .await?; + + add_tag::execute( + ctx, + AddTagCommand { + note_id: note.id.as_uuid(), + tag_id: tag.id.as_uuid(), + user_id, + }, + ) + .await?; + } + } + + Ok(()) +} diff --git a/crates/application/src/notes/list_notes.rs b/crates/application/src/notes/list_notes.rs new file mode 100644 index 0000000..e78905a --- /dev/null +++ b/crates/application/src/notes/list_notes.rs @@ -0,0 +1,22 @@ +use domain::{ + errors::DomainResult, note::entity::Note, tag::value_objects::TagName, user::entity::UserId, +}; + +use super::queries::ListNotesQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: ListNotesQuery) -> DomainResult> { + let user_id = UserId::from_uuid(q.user_id); + let mut filter = q.filter; + + if let Some(name_str) = q.tag_name { + let name = TagName::new(name_str)?; + match ctx.repos.tag.find_by_name(&user_id, &name).await? { + Some(tag) => filter.tag_id = Some(tag.id), + // Tag doesn't exist for this user — no notes can match. + None => return Ok(vec![]), + } + } + + ctx.repos.note.find_by_user(&user_id, filter).await +} diff --git a/crates/application/src/notes/mod.rs b/crates/application/src/notes/mod.rs new file mode 100644 index 0000000..8f786fc --- /dev/null +++ b/crates/application/src/notes/mod.rs @@ -0,0 +1,16 @@ +pub mod add_tag; +pub mod archive_note; +pub mod commands; +pub mod create_note; +pub mod delete_note; +pub mod export_notes; +pub mod get_note; +pub mod get_related; +pub mod get_versions; +pub mod import_notes; +pub mod list_notes; +pub mod pin_note; +pub mod queries; +pub mod remove_tag; +pub mod search_notes; +pub mod update_note; diff --git a/crates/application/src/notes/pin_note.rs b/crates/application/src/notes/pin_note.rs new file mode 100644 index 0000000..c7e4ceb --- /dev/null +++ b/crates/application/src/notes/pin_note.rs @@ -0,0 +1,30 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::Note, + user::entity::UserId, +}; + +use super::commands::PinNoteCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: PinNoteCommand) -> DomainResult { + let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let mut note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot modify another user's note".into(), + )); + } + + note.set_pinned(cmd.pinned); + ctx.repos.note.save(¬e).await?; + Ok(note) +} diff --git a/crates/application/src/notes/queries.rs b/crates/application/src/notes/queries.rs new file mode 100644 index 0000000..0aad3a0 --- /dev/null +++ b/crates/application/src/notes/queries.rs @@ -0,0 +1,33 @@ +use uuid::Uuid; + +use domain::note::entity::NoteFilter; + +/// Query to list a user's notes. +/// Provide either `filter.tag_id` (already resolved) **or** `tag_name` +/// (the use case will resolve it). `tag_name` takes precedence. +pub struct ListNotesQuery { + pub user_id: Uuid, + pub filter: NoteFilter, + /// If set, resolves the tag by name before applying the filter. + pub tag_name: Option, +} + +pub struct GetNoteQuery { + pub note_id: Uuid, + pub user_id: Uuid, +} + +pub struct SearchNotesQuery { + pub user_id: Uuid, + pub query: String, +} + +pub struct GetVersionsQuery { + pub note_id: Uuid, + pub user_id: Uuid, +} + +pub struct GetRelatedQuery { + pub note_id: Uuid, + pub user_id: Uuid, +} diff --git a/crates/application/src/notes/remove_tag.rs b/crates/application/src/notes/remove_tag.rs new file mode 100644 index 0000000..4fdd3ad --- /dev/null +++ b/crates/application/src/notes/remove_tag.rs @@ -0,0 +1,30 @@ +use domain::{ + errors::{DomainError, DomainResult}, + note::entity::NoteId, + tag::entity::TagId, + user::entity::UserId, +}; + +use super::commands::RemoveTagCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: RemoveTagCommand) -> DomainResult<()> { + let note_id = NoteId::from_uuid(cmd.note_id); + let tag_id = TagId::from_uuid(cmd.tag_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot modify another user's note".into(), + )); + } + + ctx.repos.tag.remove_from_note(&tag_id, ¬e_id).await +} diff --git a/crates/application/src/notes/search_notes.rs b/crates/application/src/notes/search_notes.rs new file mode 100644 index 0000000..71745a1 --- /dev/null +++ b/crates/application/src/notes/search_notes.rs @@ -0,0 +1,9 @@ +use domain::{errors::DomainResult, note::entity::Note, user::entity::UserId}; + +use super::queries::SearchNotesQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: SearchNotesQuery) -> DomainResult> { + let user_id = UserId::from_uuid(q.user_id); + ctx.repos.note.search(&user_id, &q.query).await +} diff --git a/crates/application/src/notes/tests/add_tag.rs b/crates/application/src/notes/tests/add_tag.rs new file mode 100644 index 0000000..02d2589 --- /dev/null +++ b/crates/application/src/notes/tests/add_tag.rs @@ -0,0 +1,98 @@ +use crate::{ + notes::{ + add_tag, + commands::{AddTagCommand, CreateNoteCommand}, + create_note, + }, + tags::{commands::CreateTagCommand, create_tag}, + test_helpers::TestContext, +}; +use domain::note::entity::MAX_TAGS_PER_NOTE; +use uuid::Uuid; + +#[tokio::test] +async fn adds_tag_to_note() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "tagged".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + let tag = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id, + name: "rust".into(), + }, + ) + .await + .unwrap(); + + add_tag::execute( + &t.ctx, + AddTagCommand { + note_id: note.id.as_uuid(), + tag_id: tag.id.as_uuid(), + user_id, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn rejects_when_tag_limit_reached() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let mut note = domain::note::entity::Note::new( + domain::user::entity::UserId::from_uuid(user_id), + None, + "content", + ); + // fill tags to the limit + for i in 0..MAX_TAGS_PER_NOTE { + let tag = domain::tag::entity::Tag::new( + domain::tag::value_objects::TagName::new(format!("tag-{i}")).unwrap(), + domain::user::entity::UserId::from_uuid(user_id), + ); + t.ctx.repos.tag.save(&tag).await.unwrap(); + note.tags.push(tag); + } + t.ctx.repos.note.save(¬e).await.unwrap(); + + let extra_tag = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id, + name: "extra".into(), + }, + ) + .await + .unwrap(); + + let result = add_tag::execute( + &t.ctx, + AddTagCommand { + note_id: note.id.as_uuid(), + tag_id: extra_tag.id.as_uuid(), + user_id, + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::Conflict(_)) + )); +} diff --git a/crates/application/src/notes/tests/create_note.rs b/crates/application/src/notes/tests/create_note.rs new file mode 100644 index 0000000..4dc4da9 --- /dev/null +++ b/crates/application/src/notes/tests/create_note.rs @@ -0,0 +1,50 @@ +use crate::{ + notes::{commands::CreateNoteCommand, create_note}, + test_helpers::TestContext, +}; +use domain::events::DomainEvent; +use uuid::Uuid; + +#[tokio::test] +async fn creates_note_and_publishes_event() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: Some("Hello".into()), + content: "world".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + assert_eq!(note.content, "world"); + assert_eq!(note.title.as_ref().unwrap().as_ref(), "Hello"); + + let events = t.publisher.events.lock().unwrap(); + assert!(matches!(events[0], DomainEvent::NoteCreated { .. })); +} + +#[tokio::test] +async fn creates_note_without_title() { + let t = TestContext::new(); + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id: Uuid::new_v4(), + title: None, + content: "untitled".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + assert!(note.title.is_none()); +} diff --git a/crates/application/src/notes/tests/delete_note.rs b/crates/application/src/notes/tests/delete_note.rs new file mode 100644 index 0000000..8940158 --- /dev/null +++ b/crates/application/src/notes/tests/delete_note.rs @@ -0,0 +1,73 @@ +use crate::{ + notes::{ + commands::{CreateNoteCommand, DeleteNoteCommand}, + create_note, delete_note, + }, + test_helpers::TestContext, +}; +use uuid::Uuid; + +#[tokio::test] +async fn owner_can_delete_note() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "bye".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + delete_note::execute( + &t.ctx, + DeleteNoteCommand { + note_id: note.id.as_uuid(), + user_id, + }, + ) + .await + .unwrap(); + + let found = t.ctx.repos.note.find_by_id(¬e.id).await.unwrap(); + assert!(found.is_none()); +} + +#[tokio::test] +async fn other_user_cannot_delete_note() { + let t = TestContext::new(); + let owner = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id: owner, + title: None, + content: "mine".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + let result = delete_note::execute( + &t.ctx, + DeleteNoteCommand { + note_id: note.id.as_uuid(), + user_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::Forbidden(_)) + )); +} diff --git a/crates/application/src/notes/tests/update_note.rs b/crates/application/src/notes/tests/update_note.rs new file mode 100644 index 0000000..9efbfb0 --- /dev/null +++ b/crates/application/src/notes/tests/update_note.rs @@ -0,0 +1,115 @@ +use crate::{ + notes::{ + commands::{CreateNoteCommand, UpdateNoteCommand}, + create_note, update_note, + }, + test_helpers::TestContext, +}; +use uuid::Uuid; + +#[tokio::test] +async fn owner_can_update_note() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "original".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + let updated = update_note::execute( + &t.ctx, + UpdateNoteCommand { + note_id: note.id.as_uuid(), + user_id, + title: None, + content: Some("updated".into()), + color: None, + }, + ) + .await + .unwrap(); + + assert_eq!(updated.content, "updated"); +} + +#[tokio::test] +async fn other_user_cannot_update_note() { + let t = TestContext::new(); + let owner = Uuid::new_v4(); + let other = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id: owner, + title: None, + content: "secret".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + let result = update_note::execute( + &t.ctx, + UpdateNoteCommand { + note_id: note.id.as_uuid(), + user_id: other, + title: None, + content: Some("hacked".into()), + color: None, + }, + ) + .await; + + assert!(matches!( + result, + Err(domain::errors::DomainError::Forbidden(_)) + )); +} + +#[tokio::test] +async fn update_creates_version_snapshot() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "v1".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + update_note::execute( + &t.ctx, + UpdateNoteCommand { + note_id: note.id.as_uuid(), + user_id, + title: None, + content: Some("v2".into()), + color: None, + }, + ) + .await + .unwrap(); + + let versions = t.ctx.repos.note.find_versions(¬e.id).await.unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].content, "v1"); +} diff --git a/crates/application/src/notes/update_note.rs b/crates/application/src/notes/update_note.rs new file mode 100644 index 0000000..3e1ad08 --- /dev/null +++ b/crates/application/src/notes/update_note.rs @@ -0,0 +1,63 @@ +use domain::{ + errors::{DomainError, DomainResult}, + events::DomainEvent, + note::{ + entity::{Note, NoteVersion}, + value_objects::{NoteColor, NoteTitle}, + }, + user::entity::UserId, +}; + +use super::commands::UpdateNoteCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: UpdateNoteCommand) -> DomainResult { + let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let mut note = ctx + .repos + .note + .find_by_id(¬e_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?; + + if note.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot modify another user's note".into(), + )); + } + + let version = NoteVersion::snapshot(¬e); + ctx.repos.note.save_version(&version).await?; + + if let Some(title) = cmd.title { + note.set_title(NoteTitle::from_optional(Some(title))?); + } + if let Some(content) = cmd.content { + note.set_content(content); + } + if let Some(color) = cmd.color { + note.set_color(NoteColor::new(color)); + } + + ctx.repos.note.save(¬e).await?; + + if let Err(e) = ctx + .services + .event_publisher + .publish(&DomainEvent::NoteUpdated { + note_id: note.id, + user_id, + }) + .await + { + tracing::warn!("failed to publish NoteUpdated: {e}"); + } + + Ok(note) +} + +#[cfg(test)] +#[path = "tests/update_note.rs"] +mod tests; diff --git a/crates/application/src/smart/delete_vectors.rs b/crates/application/src/smart/delete_vectors.rs new file mode 100644 index 0000000..188402b --- /dev/null +++ b/crates/application/src/smart/delete_vectors.rs @@ -0,0 +1,12 @@ +use domain::{errors::DomainResult, note::entity::NoteId}; + +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, note_id: NoteId) -> DomainResult<()> { + if let Some(vector_store) = ctx.services.vector_store.as_ref() + && let Err(e) = vector_store.delete(¬e_id).await + { + tracing::warn!("failed to delete vector for note {note_id}: {e}"); + } + ctx.repos.link.delete_for_source(¬e_id).await +} diff --git a/crates/application/src/smart/mod.rs b/crates/application/src/smart/mod.rs new file mode 100644 index 0000000..7f92546 --- /dev/null +++ b/crates/application/src/smart/mod.rs @@ -0,0 +1,2 @@ +pub mod delete_vectors; +pub mod process_note; diff --git a/crates/application/src/smart/process_note.rs b/crates/application/src/smart/process_note.rs new file mode 100644 index 0000000..ce726b0 --- /dev/null +++ b/crates/application/src/smart/process_note.rs @@ -0,0 +1,48 @@ +use domain::{ + errors::DomainResult, + note::entity::{NoteId, NoteLink}, + user::entity::UserId, +}; + +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, note_id: NoteId, _user_id: UserId) -> DomainResult<()> { + let (Some(embedder), Some(vector_store)) = ( + ctx.services.embedding.as_ref(), + ctx.services.vector_store.as_ref(), + ) else { + return Ok(()); + }; + + let note = ctx.repos.note.find_by_id(¬e_id).await?; + let Some(note) = note else { return Ok(()) }; + + let text = match ¬e.title { + Some(t) => format!("{} {}", t.as_ref(), note.content), + None => note.content.clone(), + }; + + let embedding = embedder.generate(&text).await?; + vector_store.upsert(¬e_id, &embedding).await?; + + let limit = ctx.config.smart.neighbour_limit; + let similar = vector_store.find_similar(&embedding, limit + 1).await?; + + let links: Vec = similar + .into_iter() + .filter(|(id, score)| *id != note_id && *score >= ctx.config.smart.min_similarity) + .take(limit) + .map(|(target_id, score)| NoteLink::new(note_id, target_id, score)) + .collect(); + + ctx.repos.link.delete_for_source(¬e_id).await?; + if !links.is_empty() { + ctx.repos.link.save_links(&links).await?; + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/process_note.rs"] +mod tests; diff --git a/crates/application/src/smart/tests/process_note.rs b/crates/application/src/smart/tests/process_note.rs new file mode 100644 index 0000000..03b99d5 --- /dev/null +++ b/crates/application/src/smart/tests/process_note.rs @@ -0,0 +1,113 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::{ + errors::DomainResult, + note::entity::{Note, NoteId}, + smart::ports::{EmbeddingGenerator, VectorStore}, + user::entity::UserId, +}; +use uuid::Uuid; + +use crate::{ + notes::{commands::CreateNoteCommand, create_note}, + smart::process_note, + test_helpers::TestContext, +}; + +struct FakeEmbedder; + +#[async_trait] +impl EmbeddingGenerator for FakeEmbedder { + async fn generate(&self, _text: &str) -> DomainResult> { + Ok(vec![1.0, 0.0, 0.0]) + } +} + +#[derive(Default)] +struct FakeVectorStore { + upserted: Mutex>, +} + +#[async_trait] +impl VectorStore for FakeVectorStore { + async fn upsert(&self, id: &NoteId, _vector: &[f32]) -> DomainResult<()> { + self.upserted.lock().unwrap().push(*id); + Ok(()) + } + + async fn find_similar( + &self, + _vector: &[f32], + _limit: usize, + ) -> DomainResult> { + Ok(vec![]) + } + + async fn delete(&self, _id: &NoteId) -> DomainResult<()> { + Ok(()) + } +} + +fn ctx_with_smart() -> TestContext { + let mut t = TestContext::new(); + let store = Arc::new(FakeVectorStore::default()); + t.ctx.services.embedding = Some(Arc::new(FakeEmbedder)); + t.ctx.services.vector_store = Some(Arc::clone(&store) as Arc); + t +} + +#[tokio::test] +async fn processes_note_when_smart_enabled() { + let t = ctx_with_smart(); + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "interesting content".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + process_note::execute( + &t.ctx, + note.id, + domain::user::entity::UserId::from_uuid(user_id), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn skips_when_smart_disabled() { + let t = TestContext::new(); // no embedding/vector_store + let user_id = Uuid::new_v4(); + + let note = create_note::execute( + &t.ctx, + CreateNoteCommand { + user_id, + title: None, + content: "content".into(), + color: None, + is_pinned: false, + }, + ) + .await + .unwrap(); + + let result = process_note::execute( + &t.ctx, + note.id, + domain::user::entity::UserId::from_uuid(user_id), + ) + .await; + + assert!(result.is_ok()); +} diff --git a/crates/application/src/tags/commands.rs b/crates/application/src/tags/commands.rs new file mode 100644 index 0000000..6e80fa4 --- /dev/null +++ b/crates/application/src/tags/commands.rs @@ -0,0 +1,17 @@ +use uuid::Uuid; + +pub struct CreateTagCommand { + pub user_id: Uuid, + pub name: String, +} + +pub struct DeleteTagCommand { + pub tag_id: Uuid, + pub user_id: Uuid, +} + +pub struct RenameTagCommand { + pub tag_id: Uuid, + pub user_id: Uuid, + pub new_name: String, +} diff --git a/crates/application/src/tags/create_tag.rs b/crates/application/src/tags/create_tag.rs new file mode 100644 index 0000000..eeebf7d --- /dev/null +++ b/crates/application/src/tags/create_tag.rs @@ -0,0 +1,26 @@ +use domain::{ + errors::DomainResult, + tag::{entity::Tag, value_objects::TagName}, + user::entity::UserId, +}; + +use super::commands::CreateTagCommand; +use crate::context::AppContext; + +/// Returns an existing tag with the same name if one exists, otherwise creates a new one. +pub async fn execute(ctx: &AppContext, cmd: CreateTagCommand) -> DomainResult { + let user_id = UserId::from_uuid(cmd.user_id); + let name = TagName::new(cmd.name)?; + + if let Some(existing) = ctx.repos.tag.find_by_name(&user_id, &name).await? { + return Ok(existing); + } + + let tag = Tag::new(name, user_id); + ctx.repos.tag.save(&tag).await?; + Ok(tag) +} + +#[cfg(test)] +#[path = "tests/create_tag.rs"] +mod tests; diff --git a/crates/application/src/tags/delete_tag.rs b/crates/application/src/tags/delete_tag.rs new file mode 100644 index 0000000..e0713c9 --- /dev/null +++ b/crates/application/src/tags/delete_tag.rs @@ -0,0 +1,28 @@ +use domain::{ + errors::{DomainError, DomainResult}, + tag::entity::TagId, + user::entity::UserId, +}; + +use super::commands::DeleteTagCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: DeleteTagCommand) -> DomainResult<()> { + let tag_id = TagId::from_uuid(cmd.tag_id); + let user_id = UserId::from_uuid(cmd.user_id); + + let tag = ctx + .repos + .tag + .find_by_id(&tag_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?; + + if tag.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot delete another user's tag".into(), + )); + } + + ctx.repos.tag.delete(&tag_id).await +} diff --git a/crates/application/src/tags/list_tags.rs b/crates/application/src/tags/list_tags.rs new file mode 100644 index 0000000..705914c --- /dev/null +++ b/crates/application/src/tags/list_tags.rs @@ -0,0 +1,9 @@ +use domain::{errors::DomainResult, tag::entity::Tag, user::entity::UserId}; + +use super::queries::ListTagsQuery; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, q: ListTagsQuery) -> DomainResult> { + let user_id = UserId::from_uuid(q.user_id); + ctx.repos.tag.find_by_user(&user_id).await +} diff --git a/crates/application/src/tags/mod.rs b/crates/application/src/tags/mod.rs new file mode 100644 index 0000000..e9a4297 --- /dev/null +++ b/crates/application/src/tags/mod.rs @@ -0,0 +1,6 @@ +pub mod commands; +pub mod create_tag; +pub mod delete_tag; +pub mod list_tags; +pub mod queries; +pub mod rename_tag; diff --git a/crates/application/src/tags/queries.rs b/crates/application/src/tags/queries.rs new file mode 100644 index 0000000..72d13dc --- /dev/null +++ b/crates/application/src/tags/queries.rs @@ -0,0 +1,5 @@ +use uuid::Uuid; + +pub struct ListTagsQuery { + pub user_id: Uuid, +} diff --git a/crates/application/src/tags/rename_tag.rs b/crates/application/src/tags/rename_tag.rs new file mode 100644 index 0000000..61dd264 --- /dev/null +++ b/crates/application/src/tags/rename_tag.rs @@ -0,0 +1,31 @@ +use domain::{ + errors::{DomainError, DomainResult}, + tag::{entity::Tag, entity::TagId, value_objects::TagName}, + user::entity::UserId, +}; + +use super::commands::RenameTagCommand; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, cmd: RenameTagCommand) -> DomainResult { + let tag_id = TagId::from_uuid(cmd.tag_id); + let user_id = UserId::from_uuid(cmd.user_id); + let new_name = TagName::new(cmd.new_name)?; + + let mut tag = ctx + .repos + .tag + .find_by_id(&tag_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?; + + if tag.user_id != user_id { + return Err(DomainError::Forbidden( + "cannot rename another user's tag".into(), + )); + } + + tag.name = new_name; + ctx.repos.tag.save(&tag).await?; + Ok(tag) +} diff --git a/crates/application/src/tags/tests/create_tag.rs b/crates/application/src/tags/tests/create_tag.rs new file mode 100644 index 0000000..fb52db3 --- /dev/null +++ b/crates/application/src/tags/tests/create_tag.rs @@ -0,0 +1,76 @@ +use crate::{ + tags::{commands::CreateTagCommand, create_tag}, + test_helpers::TestContext, +}; +use uuid::Uuid; + +#[tokio::test] +async fn creates_new_tag() { + let t = TestContext::new(); + let tag = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id: Uuid::new_v4(), + name: "work".into(), + }, + ) + .await + .unwrap(); + + assert_eq!(tag.name.as_ref(), "work"); +} + +#[tokio::test] +async fn returns_existing_tag_with_same_name() { + let t = TestContext::new(); + let user_id = Uuid::new_v4(); + + let first = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id, + name: "rust".into(), + }, + ) + .await + .unwrap(); + + let second = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id, + name: "rust".into(), + }, + ) + .await + .unwrap(); + + assert_eq!(first.id, second.id); +} + +#[tokio::test] +async fn different_users_can_have_same_tag_name() { + let t = TestContext::new(); + + let a = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id: Uuid::new_v4(), + name: "shared".into(), + }, + ) + .await + .unwrap(); + + let b = create_tag::execute( + &t.ctx, + CreateTagCommand { + user_id: Uuid::new_v4(), + name: "shared".into(), + }, + ) + .await + .unwrap(); + + assert_ne!(a.id, b.id); +} diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs new file mode 100644 index 0000000..5018183 --- /dev/null +++ b/crates/application/src/test_helpers.rs @@ -0,0 +1,330 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use domain::{ + errors::{DomainError, DomainResult}, + events::{DomainEvent, EventPublisher}, + note::{ + entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion}, + ports::{LinkRepository, NoteRepository}, + }, + tag::{ + entity::{Tag, TagId}, + ports::TagRepository, + value_objects::TagName, + }, + user::{ + entity::{User, UserId}, + ports::{PasswordHasher, UserRepository}, + value_objects::{Email, Password, PasswordHash}, + }, +}; + +use crate::{ + config::{AppConfig, SmartConfig}, + context::{AppContext, Repositories, Services}, +}; + +// ── Note ───────────────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct MemoryNoteRepo { + notes: Mutex>, + versions: Mutex>>, +} + +#[async_trait] +impl NoteRepository for MemoryNoteRepo { + async fn find_by_id(&self, id: &NoteId) -> DomainResult> { + Ok(self.notes.lock().unwrap().get(id).cloned()) + } + + async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult> { + Ok(self + .notes + .lock() + .unwrap() + .values() + .filter(|n| { + n.user_id == *user_id + && filter.is_pinned.map_or(true, |v| n.is_pinned == v) + && filter.is_archived.map_or(true, |v| n.is_archived == v) + }) + .cloned() + .collect()) + } + + async fn search(&self, user_id: &UserId, query: &str) -> DomainResult> { + let q = query.to_lowercase(); + Ok(self + .notes + .lock() + .unwrap() + .values() + .filter(|n| { + n.user_id == *user_id + && (n.content.to_lowercase().contains(&q) + || n.title + .as_ref() + .map_or(false, |t| t.as_ref().to_lowercase().contains(&q))) + }) + .cloned() + .collect()) + } + + async fn save(&self, note: &Note) -> DomainResult<()> { + self.notes.lock().unwrap().insert(note.id, note.clone()); + Ok(()) + } + + async fn delete(&self, id: &NoteId) -> DomainResult<()> { + self.notes.lock().unwrap().remove(id); + Ok(()) + } + + async fn save_version(&self, v: &NoteVersion) -> DomainResult<()> { + self.versions + .lock() + .unwrap() + .entry(v.note_id) + .or_default() + .push(v.clone()); + Ok(()) + } + + async fn find_versions(&self, note_id: &NoteId) -> DomainResult> { + Ok(self + .versions + .lock() + .unwrap() + .get(note_id) + .cloned() + .unwrap_or_default()) + } +} + +// ── Tag ────────────────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct MemoryTagRepo { + tags: Mutex>, + note_tags: Mutex>, +} + +#[async_trait] +impl TagRepository for MemoryTagRepo { + async fn find_by_id(&self, id: &TagId) -> DomainResult> { + Ok(self.tags.lock().unwrap().get(id).cloned()) + } + + async fn find_by_user(&self, user_id: &UserId) -> DomainResult> { + Ok(self + .tags + .lock() + .unwrap() + .values() + .filter(|t| t.user_id == *user_id) + .cloned() + .collect()) + } + + async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult> { + Ok(self + .tags + .lock() + .unwrap() + .values() + .find(|t| t.user_id == *user_id && t.name == *name) + .cloned()) + } + + async fn find_by_note(&self, note_id: &NoteId) -> DomainResult> { + let note_tags = self.note_tags.lock().unwrap(); + let tags = self.tags.lock().unwrap(); + Ok(note_tags + .keys() + .filter(|(nid, _)| nid == note_id) + .filter_map(|(_, tid)| tags.get(tid).cloned()) + .collect()) + } + + async fn save(&self, tag: &Tag) -> DomainResult<()> { + self.tags.lock().unwrap().insert(tag.id, tag.clone()); + Ok(()) + } + + async fn delete(&self, id: &TagId) -> DomainResult<()> { + self.tags.lock().unwrap().remove(id); + Ok(()) + } + + async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> { + self.note_tags + .lock() + .unwrap() + .insert((*note_id, *tag_id), ()); + Ok(()) + } + + async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> { + self.note_tags.lock().unwrap().remove(&(*note_id, *tag_id)); + Ok(()) + } +} + +// ── User ───────────────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct MemoryUserRepo { + users: Mutex>, +} + +#[async_trait] +impl UserRepository for MemoryUserRepo { + async fn find_by_id(&self, id: &UserId) -> DomainResult> { + Ok(self.users.lock().unwrap().get(id).cloned()) + } + + async fn find_by_subject(&self, subject: &str) -> DomainResult> { + Ok(self + .users + .lock() + .unwrap() + .values() + .find(|u| u.subject == subject) + .cloned()) + } + + async fn find_by_email(&self, email: &Email) -> DomainResult> { + Ok(self + .users + .lock() + .unwrap() + .values() + .find(|u| u.email.as_ref() == email.as_ref()) + .cloned()) + } + + async fn save(&self, user: &User) -> DomainResult<()> { + self.users.lock().unwrap().insert(user.id, user.clone()); + Ok(()) + } + + async fn delete(&self, id: &UserId) -> DomainResult<()> { + self.users.lock().unwrap().remove(id); + Ok(()) + } +} + +// ── Link ───────────────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct MemoryLinkRepo { + links: Mutex>, +} + +#[async_trait] +impl LinkRepository for MemoryLinkRepo { + async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> { + self.links.lock().unwrap().extend_from_slice(links); + Ok(()) + } + + async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> { + self.links + .lock() + .unwrap() + .retain(|l| l.source_id != *source_id); + Ok(()) + } + + async fn find_for_note(&self, note_id: &NoteId) -> DomainResult> { + Ok(self + .links + .lock() + .unwrap() + .iter() + .filter(|l| l.source_id == *note_id) + .cloned() + .collect()) + } +} + +// ── PasswordHasher ─────────────────────────────────────────────────────────── + +pub struct PlaintextHasher; + +#[async_trait] +impl PasswordHasher for PlaintextHasher { + async fn hash(&self, password: &Password) -> DomainResult { + Ok(PasswordHash::new(format!("hashed:{}", password.as_ref()))) + } + + async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult { + Ok(hash.as_str() == format!("hashed:{}", password.as_ref())) + } +} + +// ── EventPublisher ─────────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct RecordingPublisher { + pub events: Mutex>, +} + +#[async_trait] +impl EventPublisher for RecordingPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +// ── AppContext builder ──────────────────────────────────────────────────────── + +pub struct TestContext { + pub ctx: AppContext, + pub publisher: Arc, +} + +impl TestContext { + pub fn new() -> Self { + use domain::events::EventConsumer; + use futures::stream::BoxStream; + + struct NoopConsumer; + impl EventConsumer for NoopConsumer { + fn consume(&self) -> BoxStream<'_, Result> { + Box::pin(futures::stream::empty()) + } + } + + let publisher = Arc::new(RecordingPublisher::default()); + + let ctx = AppContext { + repos: Repositories { + note: Arc::new(MemoryNoteRepo::default()), + tag: Arc::new(MemoryTagRepo::default()), + user: Arc::new(MemoryUserRepo::default()), + link: Arc::new(MemoryLinkRepo::default()), + }, + services: Services { + password_hasher: Arc::new(PlaintextHasher), + event_publisher: Arc::clone(&publisher) as Arc, + embedding: None, + vector_store: None, + event_consumer: Arc::new(NoopConsumer), + }, + config: AppConfig { + base_url: "http://localhost:3000".into(), + smart: SmartConfig::default(), + allow_registration: true, + }, + }; + + Self { ctx, publisher } + } +} diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs new file mode 100644 index 0000000..d935407 --- /dev/null +++ b/crates/application/src/worker.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use domain::events::{EventConsumer, EventEnvelope, EventHandler}; +use futures::StreamExt; +use tokio::sync::Semaphore; + +const DEFAULT_CONCURRENCY: usize = 8; + +pub struct WorkerService { + consumer: Arc, + handlers: Vec>, + semaphore: Arc, +} + +impl WorkerService { + pub fn new(consumer: Arc, handlers: Vec>) -> Self { + Self { + consumer, + handlers, + semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)), + } + } + + pub async fn run(self, mut shutdown: tokio::sync::watch::Receiver) { + let handlers = Arc::new(self.handlers); + let mut tasks = tokio::task::JoinSet::new(); + let mut stream = self.consumer.consume(); + + loop { + tokio::select! { + biased; + _ = shutdown.changed() => { + tracing::info!("shutdown received, stopping event consumption"); + break; + } + item = stream.next() => { + match item { + Some(Ok(envelope)) => { + let permit = self.semaphore.clone().acquire_owned().await; + let Ok(permit) = permit else { break }; + let h = Arc::clone(&handlers); + tasks.spawn(async move { + dispatch(h, envelope).await; + drop(permit); + }); + } + Some(Err(e)) => tracing::error!("event consumer error: {e}"), + None => break, + } + } + } + } + + let in_flight = tasks.len(); + if in_flight > 0 { + tracing::info!(in_flight, "draining in-flight tasks"); + } + while tasks.join_next().await.is_some() {} + } +} + +async fn dispatch(handlers: Arc>>, envelope: EventEnvelope) { + let mut failed = false; + + for handler in handlers.iter() { + if let Err(e) = handler.handle(&envelope.event).await { + tracing::warn!("event handler error: {e}"); + failed = true; + } + } + + let result = if failed { + // At least one handler failed — nack so the transport can redeliver. + // With JetStream this triggers redelivery up to max_deliver times, + // after which the message is considered dead (visible via advisory events). + // Handlers must be idempotent since they may run again on redelivery. + envelope.nack().await + } else { + envelope.ack().await + }; + + if let Err(e) = result { + tracing::error!("ack/nack failed: {e}"); + } +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 0000000..f22ab19 --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "bootstrap" +path = "src/main.rs" + +[dependencies] +application = { workspace = true } +auth = { workspace = true, features = ["jwt"] } +presentation = { workspace = true } +wiring = { workspace = true } +axum = "0.8" +dotenvy = "0.15" +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs new file mode 100644 index 0000000..bc7a51a --- /dev/null +++ b/crates/bootstrap/src/main.rs @@ -0,0 +1,82 @@ +use anyhow::Context as _; +use auth::{config::JwtConfig, jwt::JwtValidator}; +use presentation::{PresentationState, apply_middleware, router}; +use wiring::{WiringConfig, build_context}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + init_tracing("bootstrap"); + + let wiring_cfg = WiringConfig::from_env()?; + let ctx = build_context(&wiring_cfg).await?; + + let jwt_validator = jwt_validator_from_env()?; + let state = PresentationState::new(ctx, jwt_validator); + + let cors_origins = std::env::var("CORS_ORIGINS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + // SPA_DIR — path to the built frontend dist directory. + // Defaults to k-notes-frontend/dist relative to the working directory. + // Set to "" to disable SPA serving (API-only mode). + let spa_dir = std::env::var("SPA_DIR").unwrap_or_else(|_| "k-notes-frontend/dist".into()); + let spa_dir = if spa_dir.is_empty() { + None + } else { + Some(std::path::PathBuf::from(spa_dir)) + }; + + let app = apply_middleware(router(state, spa_dir), cors_origins); + + let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()); + let port = std::env::var("PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(3000); + + let addr = format!("{host}:{port}"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("listening on http://{addr}"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +fn jwt_validator_from_env() -> anyhow::Result { + let secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?; + let expiry_hours = std::env::var("JWT_EXPIRY_HOURS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(24); + + Ok(JwtValidator::new(JwtConfig { + secret, + issuer: std::env::var("JWT_ISSUER").ok(), + audience: std::env::var("JWT_AUDIENCE").ok(), + expiry_hours, + })) +} + +async fn shutdown_signal() { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("shutdown signal received"); +} + +fn init_tracing(service: &str) { + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("{service}=info,tower_http=info").into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..d29606a --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +email_address = "0.2" diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs new file mode 100644 index 0000000..fa429a9 --- /dev/null +++ b/crates/domain/src/errors.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DomainError { + #[error("not found: {0}")] + NotFound(String), + #[error("conflict: {0}")] + Conflict(String), + #[error("forbidden: {0}")] + Forbidden(String), + #[error("validation: {0}")] + Validation(String), + #[error("repository: {0}")] + Repository(String), + #[error("infrastructure: {0}")] + Infrastructure(String), +} + +pub type DomainResult = Result; diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs new file mode 100644 index 0000000..42172ef --- /dev/null +++ b/crates/domain/src/events.rs @@ -0,0 +1,65 @@ +use futures::{future::BoxFuture, stream::BoxStream}; + +use crate::{errors::DomainError, note::entity::NoteId, user::entity::UserId}; + +#[derive(Debug, Clone)] +pub enum DomainEvent { + NoteCreated { note_id: NoteId, user_id: UserId }, + NoteUpdated { note_id: NoteId, user_id: UserId }, + NoteDeleted { note_id: NoteId, user_id: UserId }, +} + +type AckFn = Box BoxFuture<'static, Result<(), DomainError>> + Send>; + +pub struct EventEnvelope { + pub event: DomainEvent, + ack_fn: AckFn, + nack_fn: AckFn, +} + +impl EventEnvelope { + pub fn new( + event: DomainEvent, + ack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static, + nack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static, + ) -> Self { + Self { + event, + ack_fn: Box::new(ack_fn), + nack_fn: Box::new(nack_fn), + } + } + + /// Both ack and nack are no-ops. For in-memory and test consumers. + pub fn noop(event: DomainEvent) -> Self { + Self::new( + event, + || Box::pin(async { Ok(()) }), + || Box::pin(async { Ok(()) }), + ) + } + + pub async fn ack(self) -> Result<(), DomainError> { + (self.ack_fn)().await + } + + /// Signal that processing failed. The transport decides whether to redeliver + /// (JetStream: redeliver up to max_deliver times; in-memory: no-op). + pub async fn nack(self) -> Result<(), DomainError> { + (self.nack_fn)().await + } +} + +#[async_trait::async_trait] +pub trait EventPublisher: Send + Sync { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; +} + +pub trait EventConsumer: Send + Sync { + fn consume(&self) -> BoxStream<'_, Result>; +} + +#[async_trait::async_trait] +pub trait EventHandler: Send + Sync { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..3c15889 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,6 @@ +pub mod errors; +pub mod events; +pub mod note; +pub mod smart; +pub mod tag; +pub mod user; diff --git a/crates/domain/src/note/entity.rs b/crates/domain/src/note/entity.rs new file mode 100644 index 0000000..b3d37ca --- /dev/null +++ b/crates/domain/src/note/entity.rs @@ -0,0 +1,176 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::value_objects::{NoteColor, NoteTitle}; +use crate::{tag::entity::Tag, user::entity::UserId}; + +pub const MAX_TAGS_PER_NOTE: usize = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NoteId(Uuid); + +impl NoteId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for NoteId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for NoteId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Note { + pub id: NoteId, + pub user_id: UserId, + pub title: Option, + pub content: String, + pub color: NoteColor, + pub is_pinned: bool, + pub is_archived: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Hydrated by the repository on read. Not managed by Note itself. + pub tags: Vec, +} + +impl Note { + pub fn new(user_id: UserId, title: Option, content: impl Into) -> Self { + let now = Utc::now(); + Self { + id: NoteId::new(), + user_id, + title, + content: content.into(), + color: NoteColor::default(), + is_pinned: false, + is_archived: false, + created_at: now, + updated_at: now, + tags: Vec::new(), + } + } + + pub fn set_title(&mut self, title: Option) { + self.title = title; + self.updated_at = Utc::now(); + } + + pub fn set_content(&mut self, content: impl Into) { + self.content = content.into(); + self.updated_at = Utc::now(); + } + + pub fn set_color(&mut self, color: NoteColor) { + self.color = color; + self.updated_at = Utc::now(); + } + + pub fn set_pinned(&mut self, pinned: bool) { + self.is_pinned = pinned; + self.updated_at = Utc::now(); + } + + pub fn set_archived(&mut self, archived: bool) { + self.is_archived = archived; + self.updated_at = Utc::now(); + } + + pub fn can_add_tag(&self) -> bool { + self.tags.len() < MAX_TAGS_PER_NOTE + } +} + +/// Snapshot of a note's content at a point in time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoteVersion { + pub id: Uuid, + pub note_id: NoteId, + pub title: Option, + pub content: String, + pub created_at: DateTime, +} + +impl NoteVersion { + pub fn snapshot(note: &Note) -> Self { + Self { + id: Uuid::new_v4(), + note_id: note.id, + title: note.title.as_ref().map(|t| t.as_ref().to_string()), + content: note.content.clone(), + created_at: Utc::now(), + } + } +} + +/// Semantic similarity edge between two notes, produced by smart features. +#[derive(Debug, Clone, PartialEq)] +pub struct NoteLink { + pub source_id: NoteId, + pub target_id: NoteId, + /// Cosine similarity score in [0.0, 1.0]. + pub score: f32, + pub created_at: DateTime, +} + +impl NoteLink { + pub fn new(source_id: NoteId, target_id: NoteId, score: f32) -> Self { + Self { + source_id, + target_id, + score, + created_at: Utc::now(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct NoteFilter { + pub is_pinned: Option, + pub is_archived: Option, + pub tag_id: Option, +} + +impl NoteFilter { + pub fn pinned(mut self) -> Self { + self.is_pinned = Some(true); + self + } + + pub fn archived(mut self) -> Self { + self.is_archived = Some(true); + self + } + + pub fn not_archived(mut self) -> Self { + self.is_archived = Some(false); + self + } + + pub fn with_tag(mut self, tag_id: crate::tag::entity::TagId) -> Self { + self.tag_id = Some(tag_id); + self + } +} + +#[cfg(test)] +#[path = "tests/entity.rs"] +mod tests; diff --git a/crates/domain/src/note/mod.rs b/crates/domain/src/note/mod.rs new file mode 100644 index 0000000..43df887 --- /dev/null +++ b/crates/domain/src/note/mod.rs @@ -0,0 +1,6 @@ +pub mod entity; +pub mod ports; +pub mod value_objects; + +pub use entity::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteId, NoteLink, NoteVersion}; +pub use value_objects::{NoteColor, NoteTitle}; diff --git a/crates/domain/src/note/ports.rs b/crates/domain/src/note/ports.rs new file mode 100644 index 0000000..0d5b825 --- /dev/null +++ b/crates/domain/src/note/ports.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; + +use super::entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion}; +use crate::{errors::DomainResult, user::entity::UserId}; + +#[async_trait] +pub trait NoteRepository: Send + Sync { + async fn find_by_id(&self, id: &NoteId) -> DomainResult>; + async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult>; + async fn search(&self, user_id: &UserId, query: &str) -> DomainResult>; + async fn save(&self, note: &Note) -> DomainResult<()>; + async fn delete(&self, id: &NoteId) -> DomainResult<()>; + async fn save_version(&self, version: &NoteVersion) -> DomainResult<()>; + async fn find_versions(&self, note_id: &NoteId) -> DomainResult>; +} + +#[async_trait] +pub trait LinkRepository: Send + Sync { + async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()>; + async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()>; + async fn find_for_note(&self, note_id: &NoteId) -> DomainResult>; +} diff --git a/crates/domain/src/note/tests/entity.rs b/crates/domain/src/note/tests/entity.rs new file mode 100644 index 0000000..2afcfa5 --- /dev/null +++ b/crates/domain/src/note/tests/entity.rs @@ -0,0 +1,47 @@ +use crate::{ + note::entity::{MAX_TAGS_PER_NOTE, Note, NoteVersion}, + tag::{entity::Tag, value_objects::TagName}, + user::entity::UserId, +}; + +fn uid() -> UserId { + UserId::new() +} + +#[test] +fn new_note_defaults() { + let note = Note::new(uid(), None, "content"); + assert!(!note.is_pinned); + assert!(!note.is_archived); + assert_eq!(note.color.as_str(), "DEFAULT"); + assert!(note.tags.is_empty()); +} + +#[test] +fn set_pinned_updates_timestamp() { + let mut note = Note::new(uid(), None, "content"); + let before = note.updated_at; + std::thread::sleep(std::time::Duration::from_millis(5)); + note.set_pinned(true); + assert!(note.is_pinned); + assert!(note.updated_at > before); +} + +#[test] +fn can_add_tag_respects_limit() { + let user_id = uid(); + let mut note = Note::new(user_id, None, "content"); + assert!(note.can_add_tag()); + note.tags = (0..MAX_TAGS_PER_NOTE) + .map(|_| Tag::new(TagName::new("x").unwrap(), user_id)) + .collect(); + assert!(!note.can_add_tag()); +} + +#[test] +fn note_version_snapshots_content() { + let note = Note::new(uid(), None, "hello"); + let v = NoteVersion::snapshot(¬e); + assert_eq!(v.note_id, note.id); + assert_eq!(v.content, "hello"); +} diff --git a/crates/domain/src/note/tests/value_objects.rs b/crates/domain/src/note/tests/value_objects.rs new file mode 100644 index 0000000..e806bcd --- /dev/null +++ b/crates/domain/src/note/tests/value_objects.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn title_trims_whitespace() { + let t = NoteTitle::new(" My Note ").unwrap(); + assert_eq!(t.as_ref(), "My Note"); +} + +#[test] +fn title_rejects_too_long() { + assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH + 1)).is_err()); + assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH)).is_ok()); +} + +#[test] +fn title_from_optional_empty_is_none() { + assert!(NoteTitle::from_optional(None).unwrap().is_none()); + assert!( + NoteTitle::from_optional(Some(" ".into())) + .unwrap() + .is_none() + ); +} + +#[test] +fn color_uppercases() { + let c = NoteColor::new("default"); + assert_eq!(c.as_str(), "DEFAULT"); +} + +#[test] +fn color_default() { + assert_eq!(NoteColor::default().as_str(), "DEFAULT"); +} diff --git a/crates/domain/src/note/value_objects.rs b/crates/domain/src/note/value_objects.rs new file mode 100644 index 0000000..31bdceb --- /dev/null +++ b/crates/domain/src/note/value_objects.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use crate::errors::DomainError; + +pub const MAX_NOTE_TITLE_LENGTH: usize = 200; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NoteTitle(String); + +impl NoteTitle { + pub fn new(value: impl Into) -> Result { + let s = value.into(); + let trimmed = s.trim(); + if trimmed.len() > MAX_NOTE_TITLE_LENGTH { + return Err(DomainError::Validation(format!( + "note title exceeds {MAX_NOTE_TITLE_LENGTH} characters" + ))); + } + Ok(Self(trimmed.to_string())) + } + + /// Returns `None` for empty/whitespace input, `Some` otherwise. + pub fn from_optional(value: Option) -> Result, DomainError> { + match value { + None => Ok(None), + Some(s) if s.trim().is_empty() => Ok(None), + Some(s) => Self::new(s).map(Some), + } + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for NoteTitle { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for NoteTitle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for NoteTitle { + type Error = DomainError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +impl TryFrom<&str> for NoteTitle { + type Error = DomainError; + fn try_from(s: &str) -> Result { + Self::new(s) + } +} + +/// Background color of a note. Stored as an uppercase string (e.g. "DEFAULT", "#FF5733"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoteColor(String); + +impl NoteColor { + pub fn new(value: impl Into) -> Self { + Self(value.into().trim().to_uppercase()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl Default for NoteColor { + fn default() -> Self { + Self::new("DEFAULT") + } +} + +impl fmt::Display for NoteColor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +#[path = "tests/value_objects.rs"] +mod tests; diff --git a/crates/domain/src/smart/mod.rs b/crates/domain/src/smart/mod.rs new file mode 100644 index 0000000..40006fc --- /dev/null +++ b/crates/domain/src/smart/mod.rs @@ -0,0 +1 @@ +pub mod ports; diff --git a/crates/domain/src/smart/ports.rs b/crates/domain/src/smart/ports.rs new file mode 100644 index 0000000..0e58a64 --- /dev/null +++ b/crates/domain/src/smart/ports.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; + +use crate::{errors::DomainResult, note::entity::NoteId}; + +#[async_trait] +pub trait EmbeddingGenerator: Send + Sync { + async fn generate(&self, text: &str) -> DomainResult>; +} + +#[async_trait] +pub trait VectorStore: Send + Sync { + async fn upsert(&self, id: &NoteId, vector: &[f32]) -> DomainResult<()>; + async fn find_similar(&self, vector: &[f32], limit: usize) -> DomainResult>; + async fn delete(&self, id: &NoteId) -> DomainResult<()>; +} diff --git a/crates/domain/src/tag/entity.rs b/crates/domain/src/tag/entity.rs new file mode 100644 index 0000000..ead507c --- /dev/null +++ b/crates/domain/src/tag/entity.rs @@ -0,0 +1,56 @@ +use std::fmt; + +use uuid::Uuid; + +use super::value_objects::TagName; +use crate::user::entity::UserId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TagId(Uuid); + +impl TagId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for TagId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for TagId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tag { + pub id: TagId, + pub name: TagName, + pub user_id: UserId, +} + +impl Tag { + pub fn new(name: TagName, user_id: UserId) -> Self { + Self { + id: TagId::new(), + name, + user_id, + } + } + + pub fn from_row(id: TagId, name: TagName, user_id: UserId) -> Self { + Self { id, name, user_id } + } +} diff --git a/crates/domain/src/tag/mod.rs b/crates/domain/src/tag/mod.rs new file mode 100644 index 0000000..e9e1015 --- /dev/null +++ b/crates/domain/src/tag/mod.rs @@ -0,0 +1,6 @@ +pub mod entity; +pub mod ports; +pub mod value_objects; + +pub use entity::{Tag, TagId}; +pub use value_objects::TagName; diff --git a/crates/domain/src/tag/ports.rs b/crates/domain/src/tag/ports.rs new file mode 100644 index 0000000..99acd29 --- /dev/null +++ b/crates/domain/src/tag/ports.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; + +use super::{ + entity::{Tag, TagId}, + value_objects::TagName, +}; +use crate::{errors::DomainResult, note::entity::NoteId, user::entity::UserId}; + +#[async_trait] +pub trait TagRepository: Send + Sync { + async fn find_by_id(&self, id: &TagId) -> DomainResult>; + async fn find_by_user(&self, user_id: &UserId) -> DomainResult>; + async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult>; + async fn find_by_note(&self, note_id: &NoteId) -> DomainResult>; + async fn save(&self, tag: &Tag) -> DomainResult<()>; + async fn delete(&self, id: &TagId) -> DomainResult<()>; + async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>; + async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>; +} diff --git a/crates/domain/src/tag/tests/value_objects.rs b/crates/domain/src/tag/tests/value_objects.rs new file mode 100644 index 0000000..dc46f72 --- /dev/null +++ b/crates/domain/src/tag/tests/value_objects.rs @@ -0,0 +1,19 @@ +use super::*; + +#[test] +fn normalises_to_lowercase_trimmed() { + let t = TagName::new(" Important ").unwrap(); + assert_eq!(t.as_ref(), "important"); +} + +#[test] +fn rejects_empty() { + assert!(TagName::new("").is_err()); + assert!(TagName::new(" ").is_err()); +} + +#[test] +fn rejects_too_long() { + assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH + 1)).is_err()); + assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH)).is_ok()); +} diff --git a/crates/domain/src/tag/value_objects.rs b/crates/domain/src/tag/value_objects.rs new file mode 100644 index 0000000..8e47bd3 --- /dev/null +++ b/crates/domain/src/tag/value_objects.rs @@ -0,0 +1,57 @@ +use std::fmt; + +use crate::errors::DomainError; + +pub const MAX_TAG_NAME_LENGTH: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TagName(String); + +impl TagName { + pub fn new(value: impl Into) -> Result { + let s = value.into().trim().to_lowercase(); + if s.is_empty() { + return Err(DomainError::Validation("tag name cannot be empty".into())); + } + if s.len() > MAX_TAG_NAME_LENGTH { + return Err(DomainError::Validation(format!( + "tag name exceeds {MAX_TAG_NAME_LENGTH} characters" + ))); + } + Ok(Self(s)) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for TagName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TagName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for TagName { + type Error = DomainError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +impl TryFrom<&str> for TagName { + type Error = DomainError; + fn try_from(s: &str) -> Result { + Self::new(s) + } +} + +#[cfg(test)] +#[path = "tests/value_objects.rs"] +mod tests; diff --git a/crates/domain/src/user/entity.rs b/crates/domain/src/user/entity.rs new file mode 100644 index 0000000..8bf0ffc --- /dev/null +++ b/crates/domain/src/user/entity.rs @@ -0,0 +1,85 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::value_objects::{Email, PasswordHash}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(Uuid); + +impl UserId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for UserId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for UserId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub id: UserId, + /// OIDC subject claim; equals email string for local-auth users. + pub subject: String, + pub email: Email, + pub password_hash: Option, + pub created_at: DateTime, +} + +impl User { + pub fn new_oidc(subject: impl Into, email: Email) -> Self { + Self { + id: UserId::new(), + subject: subject.into(), + email, + password_hash: None, + created_at: Utc::now(), + } + } + + pub fn new_local(email: Email, password_hash: PasswordHash) -> Self { + let subject = email.as_ref().to_string(); + Self { + id: UserId::new(), + subject, + email, + password_hash: Some(password_hash), + created_at: Utc::now(), + } + } + + /// Reconstruct from storage. Does not validate business rules. + pub fn from_row( + id: UserId, + subject: String, + email: Email, + password_hash: Option, + created_at: DateTime, + ) -> Self { + Self { + id, + subject, + email, + password_hash, + created_at, + } + } +} diff --git a/crates/domain/src/user/mod.rs b/crates/domain/src/user/mod.rs new file mode 100644 index 0000000..d0f1be1 --- /dev/null +++ b/crates/domain/src/user/mod.rs @@ -0,0 +1,6 @@ +pub mod entity; +pub mod ports; +pub mod value_objects; + +pub use entity::{User, UserId}; +pub use value_objects::{Email, Password, PasswordHash}; diff --git a/crates/domain/src/user/ports.rs b/crates/domain/src/user/ports.rs new file mode 100644 index 0000000..0da530a --- /dev/null +++ b/crates/domain/src/user/ports.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; + +use super::{ + entity::{User, UserId}, + value_objects::{Email, Password, PasswordHash}, +}; +use crate::errors::DomainResult; + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: &UserId) -> DomainResult>; + async fn find_by_subject(&self, subject: &str) -> DomainResult>; + async fn find_by_email(&self, email: &Email) -> DomainResult>; + async fn save(&self, user: &User) -> DomainResult<()>; + async fn delete(&self, id: &UserId) -> DomainResult<()>; +} + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, password: &Password) -> DomainResult; + async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult; +} diff --git a/crates/domain/src/user/tests/value_objects.rs b/crates/domain/src/user/tests/value_objects.rs new file mode 100644 index 0000000..3f61b0d --- /dev/null +++ b/crates/domain/src/user/tests/value_objects.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn email_normalises() { + let e = Email::new(" USER@EXAMPLE.COM ").unwrap(); + assert_eq!(e.as_ref(), "user@example.com"); +} + +#[test] +fn email_rejects_invalid() { + assert!(Email::new("not-an-email").is_err()); + assert!(Email::new("@example.com").is_err()); +} + +#[test] +fn password_enforces_minimum_length() { + assert!(Password::new("short").is_err()); + assert!(Password::new("longenough").is_ok()); +} + +#[test] +fn password_hides_in_debug() { + let p = Password::new("supersecret").unwrap(); + assert!(!format!("{p:?}").contains("supersecret")); +} diff --git a/crates/domain/src/user/value_objects.rs b/crates/domain/src/user/value_objects.rs new file mode 100644 index 0000000..a597a0e --- /dev/null +++ b/crates/domain/src/user/value_objects.rs @@ -0,0 +1,101 @@ +use std::fmt; + +use crate::errors::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(email_address::EmailAddress); + +impl Email { + pub fn new(value: impl AsRef) -> Result { + let s = value.as_ref().trim().to_lowercase(); + s.parse::() + .map(Self) + .map_err(|e| DomainError::Validation(format!("invalid email: {e}"))) + } + + pub fn into_inner(self) -> String { + self.0.to_string() + } +} + +impl AsRef for Email { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for Email { + type Error = DomainError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +impl TryFrom<&str> for Email { + type Error = DomainError; + fn try_from(s: &str) -> Result { + Self::new(s) + } +} + +/// Unverified plaintext password. Not stored — only used for auth operations. +#[derive(Clone, PartialEq, Eq)] +pub struct Password(String); + +pub const MIN_PASSWORD_LENGTH: usize = 8; + +impl Password { + pub fn new(value: impl Into) -> Result { + let v = value.into(); + if v.len() < MIN_PASSWORD_LENGTH { + return Err(DomainError::Validation(format!( + "password must be at least {MIN_PASSWORD_LENGTH} characters" + ))); + } + Ok(Self(v)) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Password { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password(***)") + } +} + +/// Stored password hash — opaque to the domain, managed by PasswordHasher port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PasswordHash(String); + +impl PasswordHash { + pub fn new(hash: impl Into) -> Self { + Self(hash.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +#[cfg(test)] +#[path = "tests/value_objects.rs"] +mod tests; diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..dbfec13 --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "presentation" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +api-types = { workspace = true } +auth = { workspace = true, features = ["jwt"] } + +axum = "0.8" +tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } +utoipa-scalar = { version = "0.3", features = ["axum"], default-features = false } +utoipa-swagger-ui = { version = "9", features = ["axum", "vendored"] } + +async-trait = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/presentation/src/error.rs b/crates/presentation/src/error.rs new file mode 100644 index 0000000..dd41f28 --- /dev/null +++ b/crates/presentation/src/error.rs @@ -0,0 +1,69 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use domain::errors::DomainError; + +use api_types::errors::ErrorResponse; + +pub type ApiResult = Result; + +#[derive(Debug)] +pub enum ApiError { + NotFound(String), + Forbidden(String), + Conflict(String), + Validation(String), + Unauthorized, + Internal(String), +} + +impl ApiError { + pub fn internal(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } +} + +impl From for ApiError { + fn from(e: DomainError) -> Self { + match e { + DomainError::NotFound(msg) => Self::NotFound(msg), + DomainError::Forbidden(msg) => Self::Forbidden(msg), + DomainError::Conflict(msg) => Self::Conflict(msg), + DomainError::Validation(msg) => Self::Validation(msg), + DomainError::Repository(msg) => { + tracing::error!("repository error: {msg}"); + Self::Internal("database error".into()) + } + DomainError::Infrastructure(msg) => { + tracing::error!("infrastructure error: {msg}"); + Self::Internal("service unavailable".into()) + } + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, body) = match self { + Self::NotFound(msg) => (StatusCode::NOT_FOUND, ErrorResponse::not_found(msg)), + Self::Forbidden(msg) => (StatusCode::FORBIDDEN, ErrorResponse::forbidden(msg)), + Self::Conflict(msg) => (StatusCode::CONFLICT, ErrorResponse::conflict(msg)), + Self::Validation(msg) => ( + StatusCode::UNPROCESSABLE_ENTITY, + ErrorResponse::validation(msg), + ), + Self::Unauthorized => ( + StatusCode::UNAUTHORIZED, + ErrorResponse::new("UNAUTHORIZED", "authentication required"), + ), + Self::Internal(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorResponse::internal(msg), + ), + }; + + (status, Json(body)).into_response() + } +} diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs new file mode 100644 index 0000000..3e856ef --- /dev/null +++ b/crates/presentation/src/extractors.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::FromRequestParts, + http::{StatusCode, header, request::Parts}, +}; +use uuid::Uuid; + +use api_types::errors::ErrorResponse; +use domain::user::entity::UserId; + +use crate::state::PresentationState; + +/// Extracts the authenticated user from `Authorization: Bearer `. +/// Returns `401 Unauthorized` if the header is absent, malformed, or the token is invalid. +pub struct CurrentUser(pub domain::user::entity::User); + +impl FromRequestParts for CurrentUser { + type Rejection = (StatusCode, Json); + + fn from_request_parts( + parts: &mut Parts, + state: &PresentationState, + ) -> impl std::future::Future> + Send { + let validator = state.jwt_validator.clone(); + let user_repo = Arc::clone(&state.ctx.repos.user); + let auth_header = parts.headers.get(header::AUTHORIZATION).cloned(); + + async move { + let header_val = auth_header.ok_or_else(unauthorized)?; + let s = header_val.to_str().map_err(|_| unauthorized())?; + let token = s.strip_prefix("Bearer ").ok_or_else(unauthorized)?; + + let claims = validator + .validate_token(token.trim()) + .map_err(|_| unauthorized())?; + let uuid = Uuid::parse_str(&claims.sub).map_err(|_| unauthorized())?; + + let user = user_repo + .find_by_id(&UserId::from_uuid(uuid)) + .await + .map_err(|_| unauthorized())? + .ok_or_else(unauthorized)?; + + Ok(CurrentUser(user)) + } + } +} + +fn unauthorized() -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new( + "UNAUTHORIZED", + "authentication required", + )), + ) +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..1d70e62 --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,64 @@ +pub mod error; +pub mod extractors; +pub mod mapping; +pub mod openapi; +pub mod routes; +pub mod state; + +use std::path::PathBuf; + +use axum::Router; +use axum::http::{HeaderValue, Method, header}; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; + +pub use state::PresentationState; + +/// Build the Axum router with all API routes and OpenAPI docs. +/// +/// `spa_dir` — when `Some`, the built frontend is served at `/` as a fallback +/// so client-side routing works. API routes and docs take priority. +/// Set `SPA_DIR` env var in bootstrap to configure at runtime. +pub fn router(state: PresentationState, spa_dir: Option) -> Router { + let mut app = Router::new() + .nest("/api/v1", routes::api_router()) + .with_state(state); + + // OpenAPI docs at /docs and /scalar + app = openapi::serve(app); + + // Serve the SPA at root — must be last so API routes take priority. + if let Some(dir) = spa_dir { + let index = dir.join("index.html"); + tracing::info!("serving SPA from {}", dir.display()); + app = app.fallback_service(ServeDir::new(dir).fallback(ServeFile::new(index))); + } + + app +} + +/// Apply CORS and request tracing middleware. +pub fn apply_middleware(app: Router, cors_origins: Vec) -> Router { + let mut cors = CorsLayer::new() + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE]) + .allow_credentials(true); + + let origins: Vec = cors_origins.iter().filter_map(|o| o.parse().ok()).collect(); + + if !origins.is_empty() { + cors = cors.allow_origin(origins); + } + + app.layer(TraceLayer::new_for_http()).layer(cors) +} diff --git a/crates/presentation/src/mapping.rs b/crates/presentation/src/mapping.rs new file mode 100644 index 0000000..72bb7bd --- /dev/null +++ b/crates/presentation/src/mapping.rs @@ -0,0 +1,59 @@ +use api_types::{ + auth::UserResponse, + notes::{NoteLinkResponse, NoteResponse, NoteVersionResponse}, + tags::TagResponse, +}; +use domain::{ + note::entity::{Note, NoteLink, NoteVersion}, + tag::entity::Tag, + user::entity::User, +}; + +pub fn tag_response(t: Tag) -> TagResponse { + TagResponse { + id: t.id.as_uuid(), + name: t.name.into_inner(), + } +} + +pub fn note_response(n: Note) -> NoteResponse { + NoteResponse { + id: n.id.as_uuid(), + user_id: n.user_id.as_uuid(), + title: n.title.map(|t| t.into_inner()), + content: n.content, + color: n.color.into_inner(), + is_pinned: n.is_pinned, + is_archived: n.is_archived, + created_at: n.created_at, + updated_at: n.updated_at, + tags: n.tags.into_iter().map(tag_response).collect(), + } +} + +pub fn note_version_response(v: NoteVersion) -> NoteVersionResponse { + NoteVersionResponse { + id: v.id, + note_id: v.note_id.as_uuid(), + title: v.title, + content: v.content, + created_at: v.created_at, + } +} + +pub fn note_link_response(l: NoteLink) -> NoteLinkResponse { + NoteLinkResponse { + source_id: l.source_id.as_uuid(), + target_id: l.target_id.as_uuid(), + score: l.score, + created_at: l.created_at, + } +} + +pub fn user_response(u: User) -> UserResponse { + UserResponse { + id: u.id.as_uuid(), + email: u.email.into_inner(), + created_at: u.created_at, + } +} diff --git a/crates/presentation/src/openapi/auth.rs b/crates/presentation/src/openapi/auth.rs new file mode 100644 index 0000000..9f08603 --- /dev/null +++ b/crates/presentation/src/openapi/auth.rs @@ -0,0 +1,13 @@ +use api_types::auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::auth::login_handler, + crate::routes::auth::register_handler, + crate::routes::auth::me_handler, + ), + components(schemas(LoginRequest, RegisterRequest, AuthResponse, UserResponse)) +)] +pub struct AuthDoc; diff --git a/crates/presentation/src/openapi/data.rs b/crates/presentation/src/openapi/data.rs new file mode 100644 index 0000000..a189721 --- /dev/null +++ b/crates/presentation/src/openapi/data.rs @@ -0,0 +1,16 @@ +use api_types::{ + backup::{BackupData, BackupNote}, + config::ConfigResponse, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::data::get_config, + crate::routes::data::export_data, + crate::routes::data::import_data, + ), + components(schemas(ConfigResponse, BackupData, BackupNote)) +)] +pub struct DataDoc; diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs new file mode 100644 index 0000000..c363d86 --- /dev/null +++ b/crates/presentation/src/openapi/mod.rs @@ -0,0 +1,50 @@ +mod auth; +mod data; +mod notes; +mod tags; + +use axum::Router; +use utoipa::{ + Modify, OpenApi, + openapi::security::{Http, HttpAuthScheme, SecurityScheme}, +}; +use utoipa_scalar::{Scalar, Servable as _}; +use utoipa_swagger_ui::SwaggerUi; + +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_auth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } +} + +fn build() -> utoipa::openapi::OpenApi { + let mut api = auth::AuthDoc::openapi(); + api.info = utoipa::openapi::InfoBuilder::new() + .title("k-notes API") + .version("1.0.0") + .description(Some( + "Self-hosted note-taking API. \ + Authenticate with `POST /api/v1/auth/login` to receive a Bearer token.", + )) + .build(); + + api.merge(notes::NotesDoc::openapi()); + api.merge(tags::TagsDoc::openapi()); + api.merge(data::DataDoc::openapi()); + SecurityAddon.modify(&mut api); + api +} + +pub fn serve(router: Router) -> Router { + tracing::info!("API docs available at /docs (Swagger) and /scalar"); + let spec = build(); + router + .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) + .merge(Scalar::with_url("/scalar", spec)) +} diff --git a/crates/presentation/src/openapi/notes.rs b/crates/presentation/src/openapi/notes.rs new file mode 100644 index 0000000..b275a01 --- /dev/null +++ b/crates/presentation/src/openapi/notes.rs @@ -0,0 +1,38 @@ +use api_types::{ + notes::{ + AddTagRequest, ArchiveRequest, CreateNoteRequest, NoteLinkResponse, NoteResponse, + NoteVersionResponse, PinRequest, UpdateNoteRequest, + }, + tags::TagResponse, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::notes::list_notes, + crate::routes::notes::create_note, + crate::routes::notes::get_note, + crate::routes::notes::update_note, + crate::routes::notes::delete_note, + crate::routes::notes::pin_note, + crate::routes::notes::archive_note, + crate::routes::notes::search_notes, + crate::routes::notes::get_versions, + crate::routes::notes::get_related, + crate::routes::notes::add_tag, + crate::routes::notes::remove_tag, + ), + components(schemas( + NoteResponse, + NoteVersionResponse, + NoteLinkResponse, + CreateNoteRequest, + UpdateNoteRequest, + PinRequest, + ArchiveRequest, + AddTagRequest, + TagResponse, + )) +)] +pub struct NotesDoc; diff --git a/crates/presentation/src/openapi/tags.rs b/crates/presentation/src/openapi/tags.rs new file mode 100644 index 0000000..a533d23 --- /dev/null +++ b/crates/presentation/src/openapi/tags.rs @@ -0,0 +1,14 @@ +use api_types::tags::{CreateTagRequest, RenameTagRequest, TagResponse}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::tags::list_tags, + crate::routes::tags::create_tag, + crate::routes::tags::delete_tag, + crate::routes::tags::rename_tag, + ), + components(schemas(TagResponse, CreateTagRequest, RenameTagRequest)) +)] +pub struct TagsDoc; diff --git a/crates/presentation/src/routes/auth.rs b/crates/presentation/src/routes/auth.rs new file mode 100644 index 0000000..a0a06bc --- /dev/null +++ b/crates/presentation/src/routes/auth.rs @@ -0,0 +1,112 @@ +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + routing::{get, post}, +}; + +use api_types::auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}; +use application::auth::{ + commands::{LoginCommand, RegisterCommand}, + login, register, +}; + +use crate::{ + error::{ApiError, ApiResult}, + extractors::CurrentUser, + mapping::user_response, + state::PresentationState, +}; + +pub fn router() -> Router { + Router::new() + .route("/login", post(login_handler)) + .route("/register", post(register_handler)) + .route("/me", get(me_handler)) +} + +#[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, body = AuthResponse), + (status = 403, body = api_types::errors::ErrorResponse, description = "Invalid credentials"), + ) +)] +pub async fn login_handler( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + let user = login::execute( + &state.ctx, + LoginCommand { + email: payload.email, + password: payload.password, + }, + ) + .await + .map_err(ApiError::from)?; + + let token = state + .jwt_validator + .create_token(&user) + .map_err(|e| ApiError::internal(format!("jwt error: {e}")))?; + + Ok(Json(AuthResponse { + user: user_response(user), + access_token: token, + })) +} + +#[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, body = AuthResponse), + (status = 403, body = api_types::errors::ErrorResponse, description = "Registration disabled"), + (status = 409, body = api_types::errors::ErrorResponse, description = "Email already exists"), + ) +)] +pub async fn register_handler( + State(state): State, + Json(payload): Json, +) -> ApiResult<(StatusCode, Json)> { + if !state.ctx.config.allow_registration { + return Err(ApiError::Forbidden("registration is disabled".into())); + } + + let user = register::execute( + &state.ctx, + RegisterCommand { + email: payload.email, + password: payload.password, + }, + ) + .await + .map_err(ApiError::from)?; + + let token = state + .jwt_validator + .create_token(&user) + .map_err(|e| ApiError::internal(format!("jwt error: {e}")))?; + + Ok(( + StatusCode::CREATED, + Json(AuthResponse { + user: user_response(user), + access_token: token, + }), + )) +} + +#[utoipa::path( + get, path = "/api/v1/auth/me", + responses( + (status = 200, body = UserResponse), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn me_handler(CurrentUser(user): CurrentUser) -> Json { + Json(user_response(user)) +} diff --git a/crates/presentation/src/routes/data.rs b/crates/presentation/src/routes/data.rs new file mode 100644 index 0000000..1e6f80d --- /dev/null +++ b/crates/presentation/src/routes/data.rs @@ -0,0 +1,88 @@ +use axum::{Json, extract::State, http::StatusCode}; + +use api_types::{ + backup::{BackupData, BackupNote}, + config::ConfigResponse, +}; +use application::notes::{export_notes, import_notes}; + +use crate::{ + error::{ApiError, ApiResult}, + extractors::CurrentUser, + state::PresentationState, +}; + +#[utoipa::path( + get, path = "/api/v1/config", + responses((status = 200, body = ConfigResponse)) +)] +pub async fn get_config(State(state): State) -> Json { + Json(ConfigResponse { + allow_registration: state.ctx.config.allow_registration, + }) +} + +#[utoipa::path( + get, path = "/api/v1/export", + responses( + (status = 200, body = BackupData), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn export_data( + State(state): State, + CurrentUser(user): CurrentUser, +) -> ApiResult> { + let notes = export_notes::execute(&state.ctx, user.id.as_uuid()) + .await + .map_err(ApiError::from)?; + + Ok(Json(BackupData { + notes: notes + .into_iter() + .map(|n| BackupNote { + title: n.title, + content: n.content, + color: n.color, + is_pinned: n.is_pinned, + is_archived: n.is_archived, + tags: n.tags, + }) + .collect(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/import", + request_body = BackupData, + responses( + (status = 200, description = "Imported successfully"), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn import_data( + State(state): State, + CurrentUser(user): CurrentUser, + Json(payload): Json, +) -> ApiResult { + let notes = payload + .notes + .into_iter() + .map(|n| import_notes::ImportNote { + title: n.title, + content: n.content, + color: Some(n.color), + is_pinned: n.is_pinned, + is_archived: n.is_archived, + tags: n.tags, + }) + .collect(); + + import_notes::execute(&state.ctx, user.id.as_uuid(), notes) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::OK) +} diff --git a/crates/presentation/src/routes/mod.rs b/crates/presentation/src/routes/mod.rs new file mode 100644 index 0000000..ed57f8d --- /dev/null +++ b/crates/presentation/src/routes/mod.rs @@ -0,0 +1,42 @@ +pub mod auth; +pub mod data; +pub mod notes; +pub mod tags; + +use axum::{ + Router, + routing::{delete, get, patch, post}, +}; + +use crate::state::PresentationState; + +pub fn api_router() -> Router { + Router::new() + .nest("/auth", auth::router()) + // Config + .route("/config", get(data::get_config)) + // Export / Import + .route("/export", get(data::export_data)) + .route("/import", post(data::import_data)) + // Notes + .route("/notes", get(notes::list_notes).post(notes::create_note)) + .route( + "/notes/{id}", + get(notes::get_note) + .patch(notes::update_note) + .delete(notes::delete_note), + ) + .route("/notes/{id}/versions", get(notes::get_versions)) + .route("/notes/{id}/related", get(notes::get_related)) + .route("/notes/{id}/pin", patch(notes::pin_note)) + .route("/notes/{id}/archive", patch(notes::archive_note)) + .route("/notes/{id}/tags", post(notes::add_tag)) + .route("/notes/{id}/tags/{tag_id}", delete(notes::remove_tag)) + .route("/search", get(notes::search_notes)) + // Tags + .route("/tags", get(tags::list_tags).post(tags::create_tag)) + .route( + "/tags/{id}", + delete(tags::delete_tag).patch(tags::rename_tag), + ) +} diff --git a/crates/presentation/src/routes/notes.rs b/crates/presentation/src/routes/notes.rs new file mode 100644 index 0000000..80323bf --- /dev/null +++ b/crates/presentation/src/routes/notes.rs @@ -0,0 +1,418 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use uuid::Uuid; + +use api_types::notes::{ + AddTagRequest, ArchiveRequest, CreateNoteRequest, ListNotesParams, NoteLinkResponse, + NoteResponse, NoteVersionResponse, PinRequest, SearchParams, UpdateNoteRequest, +}; +use application::notes::{ + add_tag as uc_add_tag, archive_note as uc_archive_note, + commands::{ + AddTagCommand, ArchiveNoteCommand, CreateNoteCommand, DeleteNoteCommand, PinNoteCommand, + RemoveTagCommand, UpdateNoteCommand, + }, + create_note as uc_create_note, delete_note as uc_delete_note, get_note as uc_get_note, + get_related as uc_get_related, get_versions as uc_get_versions, list_notes as uc_list_notes, + pin_note as uc_pin_note, + queries::{GetNoteQuery, GetRelatedQuery, GetVersionsQuery, ListNotesQuery, SearchNotesQuery}, + remove_tag as uc_remove_tag, search_notes as uc_search_notes, update_note as uc_update_note, +}; +use domain::note::entity::NoteFilter; + +use crate::{ + error::{ApiError, ApiResult}, + extractors::CurrentUser, + mapping::{note_link_response, note_response, note_version_response}, + state::PresentationState, +}; + +#[utoipa::path( + get, path = "/api/v1/notes", + params(ListNotesParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn list_notes( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> ApiResult>> { + let user_id = user.id.as_uuid(); + + let filter = NoteFilter { + is_pinned: params.pinned, + is_archived: params.archived, + ..Default::default() + }; + + let notes = uc_list_notes::execute( + &state.ctx, + ListNotesQuery { + user_id, + filter, + tag_name: params.tag, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(notes.into_iter().map(note_response).collect())) +} + +#[utoipa::path( + post, path = "/api/v1/notes", + request_body = CreateNoteRequest, + responses( + (status = 201, body = NoteResponse), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn create_note( + State(state): State, + CurrentUser(user): CurrentUser, + Json(payload): Json, +) -> ApiResult<(StatusCode, Json)> { + let note = uc_create_note::execute( + &state.ctx, + CreateNoteCommand { + user_id: user.id.as_uuid(), + title: payload.title, + content: payload.content, + color: payload.color, + is_pinned: payload.is_pinned, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok((StatusCode::CREATED, Json(note_response(note)))) +} + +#[utoipa::path( + get, path = "/api/v1/notes/{id}", + params(("id" = Uuid, Path, description = "Note ID")), + responses( + (status = 200, body = NoteResponse), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_note( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> ApiResult> { + let note = uc_get_note::execute( + &state.ctx, + GetNoteQuery { + note_id: id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(note_response(note))) +} + +#[utoipa::path( + patch, path = "/api/v1/notes/{id}", + params(("id" = Uuid, Path, description = "Note ID")), + request_body = UpdateNoteRequest, + responses( + (status = 200, body = NoteResponse), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_note( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let note = uc_update_note::execute( + &state.ctx, + UpdateNoteCommand { + note_id: id, + user_id: user.id.as_uuid(), + title: payload.title, + content: payload.content, + color: payload.color, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(note_response(note))) +} + +#[utoipa::path( + delete, path = "/api/v1/notes/{id}", + params(("id" = Uuid, Path, description = "Note ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_note( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> ApiResult { + uc_delete_note::execute( + &state.ctx, + DeleteNoteCommand { + note_id: id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, path = "/api/v1/notes/{id}/pin", + params(("id" = Uuid, Path, description = "Note ID")), + request_body = PinRequest, + responses( + (status = 200, body = NoteResponse), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn pin_note( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let note = uc_pin_note::execute( + &state.ctx, + PinNoteCommand { + note_id: id, + user_id: user.id.as_uuid(), + pinned: payload.pinned, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(note_response(note))) +} + +#[utoipa::path( + patch, path = "/api/v1/notes/{id}/archive", + params(("id" = Uuid, Path, description = "Note ID")), + request_body = ArchiveRequest, + responses( + (status = 200, body = NoteResponse), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn archive_note( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let note = uc_archive_note::execute( + &state.ctx, + ArchiveNoteCommand { + note_id: id, + user_id: user.id.as_uuid(), + archived: payload.archived, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(note_response(note))) +} + +#[utoipa::path( + get, path = "/api/v1/search", + params(SearchParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn search_notes( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> ApiResult>> { + let notes = uc_search_notes::execute( + &state.ctx, + SearchNotesQuery { + user_id: user.id.as_uuid(), + query: params.q, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(notes.into_iter().map(note_response).collect())) +} + +#[utoipa::path( + get, path = "/api/v1/notes/{id}/versions", + params(("id" = Uuid, Path, description = "Note ID")), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_versions( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> ApiResult>> { + let versions = uc_get_versions::execute( + &state.ctx, + GetVersionsQuery { + note_id: id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json( + versions.into_iter().map(note_version_response).collect(), + )) +} + +#[utoipa::path( + get, path = "/api/v1/notes/{id}/related", + params(("id" = Uuid, Path, description = "Note ID")), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_related( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> ApiResult>> { + let links = uc_get_related::execute( + &state.ctx, + GetRelatedQuery { + note_id: id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(links.into_iter().map(note_link_response).collect())) +} + +#[utoipa::path( + post, path = "/api/v1/notes/{id}/tags", + params(("id" = Uuid, Path, description = "Note ID")), + request_body = AddTagRequest, + responses( + (status = 204, description = "Tag added"), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + (status = 409, body = api_types::errors::ErrorResponse, description = "Tag limit reached"), + ), + security(("bearer_auth" = [])) +)] +pub async fn add_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path(note_id): Path, + Json(payload): Json, +) -> ApiResult { + use application::tags::{commands::CreateTagCommand, create_tag}; + + let tag = create_tag::execute( + &state.ctx, + CreateTagCommand { + user_id: user.id.as_uuid(), + name: payload.tag_name, + }, + ) + .await + .map_err(ApiError::from)?; + + uc_add_tag::execute( + &state.ctx, + AddTagCommand { + note_id, + tag_id: tag.id.as_uuid(), + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, path = "/api/v1/notes/{id}/tags/{tag_id}", + params( + ("id" = Uuid, Path, description = "Note ID"), + ("tag_id" = Uuid, Path, description = "Tag ID"), + ), + responses( + (status = 204, description = "Tag removed"), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path((note_id, tag_id)): Path<(Uuid, Uuid)>, +) -> ApiResult { + uc_remove_tag::execute( + &state.ctx, + RemoveTagCommand { + note_id, + tag_id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/presentation/src/routes/tags.rs b/crates/presentation/src/routes/tags.rs new file mode 100644 index 0000000..b13ad4d --- /dev/null +++ b/crates/presentation/src/routes/tags.rs @@ -0,0 +1,133 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use uuid::Uuid; + +use api_types::tags::{CreateTagRequest, RenameTagRequest, TagResponse}; +use application::tags::{ + commands::{CreateTagCommand, DeleteTagCommand, RenameTagCommand}, + create_tag, delete_tag, list_tags, + queries::ListTagsQuery, + rename_tag, +}; + +use crate::{ + error::{ApiError, ApiResult}, + extractors::CurrentUser, + mapping::tag_response, + state::PresentationState, +}; + +#[utoipa::path( + get, path = "/api/v1/tags", + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn list_tags( + State(state): State, + CurrentUser(user): CurrentUser, +) -> ApiResult>> { + let tags = list_tags::execute( + &state.ctx, + ListTagsQuery { + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(tags.into_iter().map(tag_response).collect())) +} + +#[utoipa::path( + post, path = "/api/v1/tags", + request_body = CreateTagRequest, + responses( + (status = 201, body = TagResponse), + (status = 401, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn create_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Json(payload): Json, +) -> ApiResult<(StatusCode, Json)> { + let tag = create_tag::execute( + &state.ctx, + CreateTagCommand { + user_id: user.id.as_uuid(), + name: payload.name, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok((StatusCode::CREATED, Json(tag_response(tag)))) +} + +#[utoipa::path( + delete, path = "/api/v1/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> ApiResult { + delete_tag::execute( + &state.ctx, + DeleteTagCommand { + tag_id: id, + user_id: user.id.as_uuid(), + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, path = "/api/v1/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + request_body = RenameTagRequest, + responses( + (status = 200, body = TagResponse), + (status = 401, body = api_types::errors::ErrorResponse), + (status = 403, body = api_types::errors::ErrorResponse), + (status = 404, body = api_types::errors::ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn rename_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let tag = rename_tag::execute( + &state.ctx, + RenameTagCommand { + tag_id: id, + user_id: user.id.as_uuid(), + new_name: payload.name, + }, + ) + .await + .map_err(ApiError::from)?; + + Ok(Json(tag_response(tag))) +} diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs new file mode 100644 index 0000000..6f3621b --- /dev/null +++ b/crates/presentation/src/state.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use application::context::AppContext; +use auth::jwt::JwtValidator; + +#[derive(Clone)] +pub struct PresentationState { + pub ctx: AppContext, + pub jwt_validator: Arc, +} + +impl PresentationState { + pub fn new(ctx: AppContext, jwt_validator: JwtValidator) -> Self { + Self { + ctx, + jwt_validator: Arc::new(jwt_validator), + } + } +} diff --git a/crates/wiring/Cargo.toml b/crates/wiring/Cargo.toml new file mode 100644 index 0000000..6e91f6d --- /dev/null +++ b/crates/wiring/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wiring" +version = "0.1.0" +edition = "2024" + +[dependencies] +# domain + application +domain = { workspace = true } +application = { workspace = true } + +# adapters +sqlite = { workspace = true } +auth = { workspace = true } +event-publisher-memory = { workspace = true } +nats = { workspace = true } +fastembed-adapter = { workspace = true } +qdrant-adapter = { workspace = true } + +# utilities +tokio = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/wiring/src/config.rs b/crates/wiring/src/config.rs new file mode 100644 index 0000000..5798102 --- /dev/null +++ b/crates/wiring/src/config.rs @@ -0,0 +1,124 @@ +use std::time::Duration; + +use application::config::{AppConfig, SmartConfig}; +use nats::JetStreamConfig; + +/// Full wiring configuration, sourced from environment variables. +/// +/// Call `WiringConfig::from_env()` at startup. All fields have documented +/// env var names and defaults. +#[derive(Debug, Clone)] +pub struct WiringConfig { + /// `DATABASE_URL` — SQLite file path, e.g. `sqlite://data.db` + pub database_url: String, + + /// `NATS_URL` — if set, NATS JetStream is used for events. + /// If absent, an in-memory bus is used (suitable for single-process dev). + pub nats_url: Option, + + /// `QDRANT_URL` — if set, smart features (embeddings + semantic links) are + /// enabled. If absent, `AppContext::services.embedding` and `vector_store` + /// are `None`. + pub qdrant_url: Option, + + /// `QDRANT_COLLECTION` — collection name. Default: `"notes"`. + pub qdrant_collection: String, + + /// `QDRANT_VECTOR_SIZE` — must match the embedding model's output dimension. + /// Default: `384` (AllMiniLML6V2). + pub qdrant_vector_size: u64, + + /// `BASE_URL` — public base URL, e.g. `http://localhost:3000`. + pub base_url: String, + + /// `SMART_NEIGHBOUR_LIMIT` — max similar notes to link per note. Default: `10`. + pub smart_neighbour_limit: usize, + + /// `SMART_MIN_SIMILARITY` — cosine similarity threshold for links. Default: `0.7`. + pub smart_min_similarity: f32, + + /// `ALLOW_REGISTRATION` — set to `false` to disable the register endpoint. + /// Default: `true`. + pub allow_registration: bool, + + /// `NATS_STREAM_NAME` — JetStream stream name. Default: `"KNOTES"`. + pub nats_stream_name: String, + + /// `NATS_CONSUMER_NAME` — durable consumer name. Default: `"knotes-worker"`. + pub nats_consumer_name: String, + + /// `NATS_MAX_DELIVER` — max delivery attempts before a message is dead. + /// Default: `5`. + pub nats_max_deliver: i64, + + /// `ENABLE_EMBEDDINGS` — load the fastembed model and generate embeddings. + /// Should only be `true` in the worker process. The backend only needs + /// `VectorStore` (for querying related notes), not `EmbeddingGenerator`. + /// Default: `false`. + pub enable_embeddings: bool, +} + +impl WiringConfig { + pub fn from_env() -> anyhow::Result { + Ok(Self { + database_url: require_env("DATABASE_URL")?, + nats_url: optional_env("NATS_URL"), + qdrant_url: optional_env("QDRANT_URL"), + qdrant_collection: optional_env("QDRANT_COLLECTION").unwrap_or_else(|| "notes".into()), + qdrant_vector_size: parse_env("QDRANT_VECTOR_SIZE", 384)?, + base_url: optional_env("BASE_URL").unwrap_or_else(|| "http://localhost:3000".into()), + smart_neighbour_limit: parse_env("SMART_NEIGHBOUR_LIMIT", 10)?, + smart_min_similarity: parse_env("SMART_MIN_SIMILARITY", 0.7f32)?, + nats_stream_name: optional_env("NATS_STREAM_NAME").unwrap_or_else(|| "KNOTES".into()), + nats_consumer_name: optional_env("NATS_CONSUMER_NAME") + .unwrap_or_else(|| "knotes-worker".into()), + nats_max_deliver: parse_env("NATS_MAX_DELIVER", 5i64)?, + allow_registration: optional_env("ALLOW_REGISTRATION") + .map(|s| s != "false" && s != "0") + .unwrap_or(true), + enable_embeddings: optional_env("ENABLE_EMBEDDINGS") + .map(|s| s == "true" || s == "1") + .unwrap_or(false), + }) + } + + pub(crate) fn app_config(&self) -> AppConfig { + AppConfig { + base_url: self.base_url.clone(), + smart: SmartConfig { + neighbour_limit: self.smart_neighbour_limit, + min_similarity: self.smart_min_similarity, + }, + allow_registration: self.allow_registration, + } + } + + pub(crate) fn jetstream_config(&self) -> JetStreamConfig { + JetStreamConfig { + stream_name: self.nats_stream_name.clone(), + consumer_name: self.nats_consumer_name.clone(), + max_deliver: self.nats_max_deliver, + ack_wait: Duration::from_secs(30), + } + } +} + +fn require_env(key: &str) -> anyhow::Result { + std::env::var(key).map_err(|_| anyhow::anyhow!("{key} must be set")) +} + +fn optional_env(key: &str) -> Option { + std::env::var(key).ok().filter(|s| !s.is_empty()) +} + +fn parse_env(key: &str, default: T) -> anyhow::Result +where + T::Err: std::error::Error + Send + Sync + 'static, +{ + match std::env::var(key) { + Ok(val) => val + .parse::() + .map_err(|e| anyhow::anyhow!("invalid {key}={val}: {e}")), + Err(_) => Ok(default), + } +} diff --git a/crates/wiring/src/lib.rs b/crates/wiring/src/lib.rs new file mode 100644 index 0000000..9208723 --- /dev/null +++ b/crates/wiring/src/lib.rs @@ -0,0 +1,104 @@ +pub mod config; + +use std::sync::Arc; + +use application::context::{AppContext, Repositories, Services}; +use auth::password::Argon2PasswordHasher; +use domain::{ + events::{EventConsumer, EventPublisher}, + smart::ports::{EmbeddingGenerator, VectorStore}, +}; + +type OptEmbedding = Option>; +type OptVectorStore = Option>; +use event_publisher_memory::MemoryEventBus; +use fastembed_adapter::{FastEmbedConfig, FastEmbedGenerator}; +use qdrant_adapter::{QdrantConfig, QdrantVectorStore}; +use sqlite::{ + db::{connect, run_migrations}, + link::SqliteLinkRepository, + note::SqliteNoteRepository, + tag::SqliteTagRepository, + user::SqliteUserRepository, +}; + +pub use config::WiringConfig; + +/// Assemble a fully wired `AppContext` from the given configuration. +/// +/// Runs database migrations, connects to all configured external services, +/// and returns an `AppContext` ready to be handed to `WorkerService` or +/// the presentation layer. +pub async fn build_context(cfg: &WiringConfig) -> anyhow::Result { + // ── Database ────────────────────────────────────────────────────────────── + tracing::info!("connecting to database"); + let pool = connect(&cfg.database_url).await?; + run_migrations(&pool).await?; + tracing::info!("migrations applied"); + + let repos = Repositories { + note: Arc::new(SqliteNoteRepository::new(pool.clone())), + tag: Arc::new(SqliteTagRepository::new(pool.clone())), + user: Arc::new(SqliteUserRepository::new(pool.clone())), + link: Arc::new(SqliteLinkRepository::new(pool.clone())), + }; + + // ── Auth ────────────────────────────────────────────────────────────────── + let password_hasher = Arc::new(Argon2PasswordHasher); + + // ── Event bus ───────────────────────────────────────────────────────────── + let (event_publisher, event_consumer): (Arc, Arc) = + if let Some(ref url) = cfg.nats_url { + tracing::info!("connecting to NATS at {url}"); + let (pub_, con) = nats::setup(url, cfg.jetstream_config()) + .await + .map_err(|e| anyhow::anyhow!("nats setup failed: {e}"))?; + tracing::info!("NATS JetStream ready"); + (Arc::new(pub_), Arc::new(con)) + } else { + tracing::info!("no NATS_URL — using in-memory event bus"); + let bus = MemoryEventBus::new(); + (bus.publisher(), bus.consumer()) + }; + + // ── Smart features ──────────────────────────────────────────────────────── + // EmbeddingGenerator: only load the fastembed model in the worker. + // The backend only needs VectorStore (for querying related notes). + // Loading the model in both processes wastes ~150 MB per process. + let embedding: OptEmbedding = if cfg.enable_embeddings && cfg.qdrant_url.is_some() { + tracing::info!("loading fastembed embedding model"); + let embedder = FastEmbedGenerator::new(FastEmbedConfig::default()) + .map_err(|e| anyhow::anyhow!("fastembed init failed: {e}"))?; + Some(Arc::new(embedder) as Arc) + } else { + None + }; + + let vector_store: OptVectorStore = if let Some(ref url) = cfg.qdrant_url { + tracing::info!("connecting to qdrant at {url}"); + let qdrant = QdrantVectorStore::new(QdrantConfig { + url: url.clone(), + collection: cfg.qdrant_collection.clone(), + vector_size: cfg.qdrant_vector_size, + }) + .map_err(|e| anyhow::anyhow!("qdrant client init failed: {e}"))?; + qdrant.init(cfg.qdrant_vector_size).await?; + tracing::info!(collection = %cfg.qdrant_collection, "qdrant collection ready"); + Some(Arc::new(qdrant) as Arc) + } else { + tracing::info!("no QDRANT_URL — smart features disabled"); + None + }; + + Ok(AppContext { + repos, + services: Services { + password_hasher, + event_publisher, + event_consumer, + embedding, + vector_store, + }, + config: cfg.app_config(), + }) +} diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml new file mode 100644 index 0000000..c26d4d9 --- /dev/null +++ b/crates/worker/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "worker" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "worker" +path = "src/main.rs" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +wiring = { workspace = true } +async-trait = { workspace = true } +dotenvy = "0.15" +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/worker/src/handlers.rs b/crates/worker/src/handlers.rs new file mode 100644 index 0000000..85d9e55 --- /dev/null +++ b/crates/worker/src/handlers.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; + +use application::{ + context::AppContext, + smart::{delete_vectors, process_note}, +}; +use domain::{ + errors::DomainError, + events::{DomainEvent, EventHandler}, +}; + +/// Routes domain events to application use cases. +/// Smart feature use cases are skipped when the adapters are not configured +/// (embedding and vector_store are None in AppContext). +pub struct NoteEventHandler { + ctx: AppContext, +} + +impl NoteEventHandler { + pub fn new(ctx: AppContext) -> Self { + Self { ctx } + } +} + +#[async_trait] +impl EventHandler for NoteEventHandler { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::NoteCreated { note_id, user_id } + | DomainEvent::NoteUpdated { note_id, user_id } => { + process_note::execute(&self.ctx, *note_id, *user_id).await + } + DomainEvent::NoteDeleted { note_id, .. } => { + delete_vectors::execute(&self.ctx, *note_id).await + } + } + } +} diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs new file mode 100644 index 0000000..6b6fa43 --- /dev/null +++ b/crates/worker/src/main.rs @@ -0,0 +1,46 @@ +mod handlers; + +use std::sync::Arc; + +use application::worker::WorkerService; +use domain::events::EventHandler; +use handlers::NoteEventHandler; +use wiring::{WiringConfig, build_context}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + init_tracing("worker"); + + let wiring_cfg = WiringConfig::from_env()?; + let ctx = build_context(&wiring_cfg).await?; + + let handlers: Vec> = vec![Arc::new(NoteEventHandler::new(ctx.clone()))]; + + let consumer = Arc::clone(&ctx.services.event_consumer); + let worker = WorkerService::new(consumer, handlers); + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("shutdown signal received"); + let _ = shutdown_tx.send(true); + }); + + tracing::info!("worker started"); + worker.run(shutdown_rx).await; + tracing::info!("worker stopped"); + + Ok(()) +} + +fn init_tracing(service: &str) { + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("{service}=info").into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..0add65d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${IMAGE:-registry.gabrielkaszewski.dev/k-notes:latest}" + +docker buildx build --platform linux/amd64 \ + -t "$IMAGE" --push . + +echo "pushed $IMAGE" diff --git a/k-notes-frontend/src/components/create-note-dialog.tsx b/k-notes-frontend/src/components/create-note-dialog.tsx index eed9c92..857aeb4 100644 --- a/k-notes-frontend/src/components/create-note-dialog.tsx +++ b/k-notes-frontend/src/components/create-note-dialog.tsx @@ -59,14 +59,20 @@ export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange } {defaultTrigger} )} - - - {t("Create Note")} - - {t("Add a new note to your collection.")} - - - + +
+ + {t("Create Note")} + + {t("Add a new note to your collection.")} + + +
+ {/* Scrollable form body — ensures the submit button stays reachable when the + iOS keyboard pushes the viewport up. dvh accounts for the keyboard height. */} +
+ +
); diff --git a/k-notes-frontend/src/components/editor/editor.tsx b/k-notes-frontend/src/components/editor/editor.tsx index a06422c..7e7747d 100644 --- a/k-notes-frontend/src/components/editor/editor.tsx +++ b/k-notes-frontend/src/components/editor/editor.tsx @@ -49,7 +49,8 @@ export function Editor({ value, onChange, placeholder, className }: EditorProps) editorProps: { attributes: { class: cn( - "min-h-[100px] max-h-[400px] overflow-y-auto w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 prose dark:prose-invert max-w-none break-all min-w-0", + // text-base (16px) on all sizes prevents iOS Safari auto-zoom on focus. + "min-h-[100px] max-h-[40dvh] overflow-y-auto w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 prose dark:prose-invert max-w-none break-all min-w-0", className ), }, diff --git a/k-notes-frontend/src/components/note-card.tsx b/k-notes-frontend/src/components/note-card.tsx index 4f5d609..fa644c0 100644 --- a/k-notes-frontend/src/components/note-card.tsx +++ b/k-notes-frontend/src/components/note-card.tsx @@ -165,14 +165,14 @@ export function NoteCard({ note }: NoteCardProps) { - + {t("Edit Note")}
- +
diff --git a/k-notes-frontend/src/components/related-notes.tsx b/k-notes-frontend/src/components/related-notes.tsx index 84acb87..1d10e83 100644 --- a/k-notes-frontend/src/components/related-notes.tsx +++ b/k-notes-frontend/src/components/related-notes.tsx @@ -38,16 +38,16 @@ export function RelatedNotes({ noteId, onSelectNote }: RelatedNotesProps) {
{relatedLinks.map((link) => { - const targetNote = notes?.find((n: any) => n.id === link.target_note_id); + const targetNote = notes?.find((n: any) => n.id === link.target_id); if (!targetNote) return null; return ( - {/* Divider only if both OIDC and password login are enabled */} - {config?.password_login_enabled && ( -
-
- -
-
- - {t("Or continue with")} - -
-
- )} - - )} - - {/* Email/Password Form - only show if password login is enabled */} - {config?.password_login_enabled !== false && ( - - - ( - - {t("Email")} - - - - - - )} - /> - ( - - {t("Password")} - - - - - - )} - /> - - - - )} + + {config?.allow_registration !== false && ( @@ -145,4 +109,3 @@ export default function LoginPage() {
); } - diff --git a/k-notes-frontend/src/pages/register.tsx b/k-notes-frontend/src/pages/register.tsx index 0adb1a4..a5f35d0 100644 --- a/k-notes-frontend/src/pages/register.tsx +++ b/k-notes-frontend/src/pages/register.tsx @@ -17,8 +17,8 @@ import { useTranslation } from "react-i18next"; const registerSchema = z.object({ email: z.string().email("Invalid email address"), - password: z.string().min(6, "Password must be at least 6 characters"), - confirmPassword: z.string().min(6, "Password must be at least 6 characters"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(8, "Password must be at least 8 characters"), }).refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], @@ -31,36 +31,26 @@ export default function RegisterPage() { const { data: config, isLoading: isConfigLoading } = useConfig(); const navigate = useNavigate(); const { t } = useTranslation(); + const [settingsOpen, setSettingsOpen] = useState(false); useEffect(() => { if (!isConfigLoading && config?.allow_registration === false) { toast.error(t("Registration is currently disabled")); navigate("/login"); - } else if (!isConfigLoading && config?.password_login_enabled === false) { - // Registration requires password login to be enabled - toast.error(t("Registration is not available")); - navigate("/login"); } }, [config, isConfigLoading, navigate, t]); - if (isConfigLoading || config?.allow_registration === false || config?.password_login_enabled === false) { - return null; // Or a loading spinner + if (isConfigLoading || config?.allow_registration === false) { + return null; } const form = useForm({ resolver: zodResolver(registerSchema), - defaultValues: { - email: "", - password: "", - confirmPassword: "", - }, + defaultValues: { email: "", password: "", confirmPassword: "" }, }); const onSubmit = (data: RegisterFormValues) => { - register({ - email: data.email, - password: data.password, - }, { + register({ email: data.email, password: data.password }, { onError: (error: any) => { if (error instanceof ApiError) { toast.error(error.message); @@ -71,8 +61,6 @@ export default function RegisterPage() { }); }; - const [settingsOpen, setSettingsOpen] = useState(false); - return (