refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
target/
|
target/
|
||||||
node_modules/
|
|
||||||
.git/
|
.git/
|
||||||
.fastembed_cache/
|
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
k-notes-frontend/
|
|
||||||
dist/
|
|
||||||
.env
|
.env
|
||||||
|
data/
|
||||||
|
# Frontend build artefacts are excluded individually; the source must reach the builder stage
|
||||||
|
k-notes-frontend/node_modules/
|
||||||
|
k-notes-frontend/dist/
|
||||||
|
|||||||
48
.env.example
Normal file
48
.env.example
Normal file
@@ -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=
|
||||||
@@ -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<dyn Repository>`**)** 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`.
|
|
||||||
93
ARCHITECTURE.mmd
Normal file
93
ARCHITECTURE.mmd
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
title: K-Notes — Hexagonal Architecture + DDD
|
||||||
|
---
|
||||||
|
graph TB
|
||||||
|
subgraph Binaries["Binaries (Composition Root)"]
|
||||||
|
BOOTSTRAP["bootstrap<br/><i>Axum HTTP server</i><br/>Routes, Handlers, SPA serving,<br/>OpenAPI docs (/docs, /scalar)"]
|
||||||
|
WORKER["worker<br/><i>Event consumer</i><br/>NoteEventHandler,<br/>Semaphore(8), graceful shutdown"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Wiring["wiring (Assembly)"]
|
||||||
|
CTX_BUILD["build_context()<br/><i>Reads env vars, connects DB,<br/>wires adapters into AppContext</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Application["Application Layer"]
|
||||||
|
direction TB
|
||||||
|
CTX["AppContext<br/><i>Repositories + Services + AppConfig</i>"]
|
||||||
|
subgraph UseCases["Use Cases (CQRS)"]
|
||||||
|
UC_AUTH["auth<br/>register, login"]
|
||||||
|
UC_NOTES["notes<br/>create, update, delete, get,<br/>list, search, pin, archive,<br/>add_tag, remove_tag,<br/>get_versions, get_related,<br/>export, import"]
|
||||||
|
UC_TAGS["tags<br/>create (get-or-create),<br/>delete, rename, list"]
|
||||||
|
UC_SMART["smart<br/>process_note, delete_vectors"]
|
||||||
|
end
|
||||||
|
WORKER_SVC["WorkerService<br/><i>EventConsumer + EventHandler[]<br/>Semaphore(8), JoinSet,<br/>shutdown watch channel</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Domain["Domain Layer (0 dependencies)"]
|
||||||
|
direction TB
|
||||||
|
subgraph Contexts["Business Contexts"]
|
||||||
|
D_NOTE["note/<br/>Note, NoteId, NoteVersion,<br/>NoteLink, NoteFilter,<br/>NoteTitle, NoteColor"]
|
||||||
|
D_TAG["tag/<br/>Tag, TagId, TagName"]
|
||||||
|
D_USER["user/<br/>User, UserId, Email,<br/>Password, PasswordHash"]
|
||||||
|
D_SMART["smart/<br/>EmbeddingGenerator port<br/>VectorStore port"]
|
||||||
|
end
|
||||||
|
subgraph Ports["Port Traits"]
|
||||||
|
P_REPOS["NoteRepository<br/>TagRepository<br/>UserRepository<br/>LinkRepository"]
|
||||||
|
P_AUTH["PasswordHasher"]
|
||||||
|
P_EVENTS["EventPublisher<br/>EventConsumer<br/>EventHandler"]
|
||||||
|
end
|
||||||
|
EVENTS["DomainEvent<br/><i>NoteCreated, NoteUpdated,<br/>NoteDeleted</i><br/>EventEnvelope (ack / nack)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ApiTypes["api-types (0 domain deps)"]
|
||||||
|
DTO["DTOs<br/><i>NoteResponse, TagResponse,<br/>UserResponse, AuthResponse,<br/>BackupData, ConfigResponse,<br/>ErrorResponse, ...</i>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Adapters["Adapters (implement Port Traits)"]
|
||||||
|
direction TB
|
||||||
|
subgraph Storage["Storage"]
|
||||||
|
A_SQLITE["sqlite<br/><i>SQLiteNoteRepository<br/>SqliteTagRepository<br/>SqliteUserRepository<br/>SqliteLinkRepository<br/>Migrations (FTS5)</i>"]
|
||||||
|
end
|
||||||
|
subgraph Auth["Auth"]
|
||||||
|
A_AUTH["auth<br/><i>Argon2PasswordHasher<br/>JwtValidator (HS256)<br/>OidcService (optional)</i>"]
|
||||||
|
end
|
||||||
|
subgraph Messaging["Messaging"]
|
||||||
|
A_NATS["nats<br/><i>JetStream publisher + consumer<br/>Explicit ack/nack, backoff,<br/>DLQ via max_deliver advisory</i>"]
|
||||||
|
A_MEM["event-publisher-memory<br/><i>Broadcast bus for dev/test</i>"]
|
||||||
|
A_PAYLOAD["event-payload<br/><i>DomainEvent ↔ JSON wire format</i>"]
|
||||||
|
end
|
||||||
|
subgraph Smart["Smart Features"]
|
||||||
|
A_FASTEMBED["fastembed<br/><i>FastEmbedGenerator<br/>AllMiniLML6V2 (384-dim)</i>"]
|
||||||
|
A_QDRANT["qdrant<br/><i>QdrantVectorStore<br/>upsert, find_similar, delete</i>"]
|
||||||
|
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
|
||||||
779
Cargo.lock
generated
779
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@@ -1,3 +1,47 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [ "notes-api", "notes-domain", "notes-infra", "notes-worker"]
|
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"
|
||||||
|
|||||||
95
Dockerfile
95
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
|
# ----- build -----
|
||||||
COPY . .
|
FROM rust:slim-bookworm AS builder
|
||||||
|
|
||||||
# Build the release binary
|
WORKDIR /build
|
||||||
RUN cargo build --release -p notes-api -p notes-worker
|
|
||||||
|
|
||||||
|
# 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
|
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
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /app/data/model-cache
|
||||||
|
|
||||||
# Install OpenSSL (required for many Rust networking crates) and CA certificates
|
COPY --from=builder /build/target/release/bootstrap ./bootstrap
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
COPY --from=builder /build/target/release/worker ./worker
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/notes-api .
|
# Copy ONNX Runtime shared library (required when smart features are enabled).
|
||||||
COPY --from=builder /app/target/release/notes-worker .
|
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
|
||||||
|
|
||||||
|
# Frontend dist — served at / by bootstrap when SPA_DIR is set.
|
||||||
# Create data directory for SQLite
|
COPY --from=spa-builder /spa/dist ./frontend/dist
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
ENV DATABASE_URL=sqlite:///app/data/notes.db
|
|
||||||
ENV SESSION_SECRET=supersecretchangeinproduction
|
|
||||||
|
|
||||||
EXPOSE 3000
|
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"]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
86
Makefile
86
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
|
DATABASE_URL ?= sqlite:data.db?mode=rwc
|
||||||
|
IMAGE ?= registry.gabrielkaszewski.dev/k-notes:latest
|
||||||
|
|
||||||
# Setup development environment
|
# ── Local dev ─────────────────────────────────────────────────────────────────
|
||||||
setup:
|
|
||||||
@echo "🔧 Setting up K-Notes development environment..."
|
|
||||||
cargo build --workspace
|
|
||||||
@echo "✅ Setup complete!"
|
|
||||||
|
|
||||||
# Run the development server
|
# Start the API server. JWT_SECRET is required; this default is dev-only.
|
||||||
dev:
|
dev:
|
||||||
@echo "🚀 Starting K-Notes API server..."
|
DATABASE_URL=$(DATABASE_URL) JWT_SECRET=dev-secret-not-for-production-use \
|
||||||
DATABASE_URL=$(DATABASE_URL) cargo run --package notes-api
|
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:
|
test:
|
||||||
@echo "🧪 Running tests..."
|
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Check code compiles
|
# Apply fmt + clippy fixes in one shot.
|
||||||
check:
|
fix:
|
||||||
cargo check --workspace
|
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:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
rm -f data.db data.db-wal data.db-shm
|
rm -f data.db data.db-wal data.db-shm
|
||||||
|
|
||||||
# Run migrations (done automatically on server start)
|
.PHONY: dev dev-worker dev-frontend build-frontend check fmt fmt-check clippy test fix \
|
||||||
migrate:
|
docker-build docker-push deploy clean
|
||||||
@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!"
|
|
||||||
|
|||||||
240
README.md
240
README.md
@@ -1,148 +1,188 @@
|
|||||||
# K-Notes
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Authentication**: Secure user registration and login.
|
- **Authentication** — JWT-based login and registration (disable registration via `ALLOW_REGISTRATION=false`)
|
||||||
- **Note Management**: Create, edit, pin, archive, and delete notes.
|
- **Note Management** — create, update, pin, archive, delete, version history
|
||||||
- **Rich Text**: Markdown support for note content.
|
- **Markdown** — content stored and served as Markdown
|
||||||
- **Version History**: Track changes, view history, note diffs, download versions, and restore previous states.
|
- **Tagging** — user-scoped tags with get-or-create semantics
|
||||||
- **Organization**: Tagging system for easy filtering.
|
- **Search** — full-text search via SQLite FTS5
|
||||||
- **Smart Features**: Semantic search and automatically generated related notes using local embeddings.
|
- **Smart Features** — semantic similarity links between notes using local embeddings (fastembed) and Qdrant; enabled when `QDRANT_URL` is set
|
||||||
- **Theme**: Dark and Light mode support.
|
- **Export / Import** — portable JSON backup and restore
|
||||||
- **Responsive**: Mobile-friendly UI built with Tailwind CSS.
|
- **API Docs** — Swagger UI at `/docs`, Scalar at `/scalar`
|
||||||
- **Architecture**:
|
- **SPA** — React frontend served at `/` by the same process
|
||||||
- **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`.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Language**: Rust
|
| Layer | Technology |
|
||||||
- **Framework**: Axum
|
|-------|-----------|
|
||||||
- **Database**: SQLite (Default) or Postgres (Supported via feature flag)
|
| Language | Rust |
|
||||||
- **Vector Database**: Qdrant (for Smart Features)
|
| HTTP | Axum 0.8 |
|
||||||
- **Dependency Injection**: Manual wiring for clear boundaries
|
| 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
|
### Frontend
|
||||||
- **Framework**: React + Vite
|
| Layer | Technology |
|
||||||
- **Language**: TypeScript
|
|-------|-----------|
|
||||||
- **Styling**: Tailwind CSS + Shadcn UI
|
| Framework | React + Vite |
|
||||||
- **State Management**: TanStack Query (React Query)
|
| 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
|
## Getting Started
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (recommended)
|
||||||
|
|
||||||
Run the entire stack with a single command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend**: http://localhost:8080
|
- **App + API**: http://localhost:3000
|
||||||
- **Backend**: 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).
|
#### Quickstart
|
||||||
2. Set up the environment variables (see `.env.example`).
|
|
||||||
3. Run the server:
|
|
||||||
|
|
||||||
```bash
|
```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`).
|
## API
|
||||||
- `DATABASE_URL`: Connection string for the database.
|
|
||||||
- `SESSION_SECRET`: Secret key for session encryption.
|
|
||||||
- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed origins.
|
|
||||||
|
|
||||||
**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:
|
| Method | Path | Auth | Description |
|
||||||
```bash
|
|--------|------|------|-------------|
|
||||||
cargo run -p notes-api --no-default-features --features notes-infra/postgres
|
| POST | `/auth/login` | — | Login, returns JWT |
|
||||||
```
|
| POST | `/auth/register` | — | Register (if enabled) |
|
||||||
*Note: Ensure your `DATABASE_URL` is set to a valid Postgres connection string.*
|
| 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):**
|
## Deployment
|
||||||
|
|
||||||
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):
|
|
||||||
|
|
||||||
```bash
|
```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`.
|
**Docker Compose** volumes to mount:
|
||||||
2. Install dependencies:
|
- `/app/data` — SQLite database file
|
||||||
|
- `/app/data/model-cache` — fastembed model cache (avoids re-download on restart)
|
||||||
```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/<db_type>`.
|
|
||||||
- Update `run_migrations` in `db.rs` to execute them.
|
|
||||||
|
|
||||||
This design ensures the `notes-api` layer remains completely agnostic to the underlying database technology.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── notes-api # API Interface (Axum, HTTP routes)
|
crates/ # New architecture (active)
|
||||||
├── notes-domain # Core Business Logic (Entities, Services, Ports)
|
adapters/ # Infrastructure adapters
|
||||||
├── notes-infra # Infrastructure (Database adapters, Repositories)
|
api-types/ # HTTP DTOs
|
||||||
├── k-notes-frontend # React Frontend Application
|
application/ # Use cases + WorkerService
|
||||||
├── migrations # SQLx Database Migrations
|
bootstrap/ # API server binary
|
||||||
└── compose.yml # Docker Composition
|
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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT — Copyright (c) 2025-2026 Gabriel Kaszewski
|
||||||
|
|||||||
66
compose.prod.yml
Normal file
66
compose.prod.yml
Normal file
@@ -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
|
||||||
54
compose.yml
54
compose.yml
@@ -4,60 +4,48 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
# In production, use a secure secret
|
|
||||||
- SESSION_SECRET=dev_secret_key_12345
|
|
||||||
- DATABASE_URL=sqlite:///app/data/notes.db
|
- 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
|
- HOST=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- ALLOW_REGISTRATION=true
|
- ALLOW_REGISTRATION=true
|
||||||
|
- NATS_URL=nats://nats:4222
|
||||||
|
# Uncomment to enable smart features:
|
||||||
|
# - QDRANT_URL=http://qdrant:6334
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build: .
|
build: .
|
||||||
command: ["./notes-worker"]
|
command: ["./worker"]
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:///app/data/notes.db
|
- DATABASE_URL=sqlite:///app/data/notes.db
|
||||||
- BROKER_URL=nats://nats:4222
|
- NATS_URL=nats://nats:4222
|
||||||
- QDRANT_URL=http://qdrant:6334
|
- ENABLE_EMBEDDINGS=true
|
||||||
- EMBEDDING_PROVIDER=fastembed
|
# Uncomment to enable smart features:
|
||||||
|
# - QDRANT_URL=http://qdrant:6334
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- nats
|
- nats
|
||||||
- qdrant
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./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:
|
nats:
|
||||||
image: nats:alpine
|
image: nats:alpine
|
||||||
container_name: k_notes_nats
|
# --jetstream enables persistent messaging required by the worker
|
||||||
|
command: ["--jetstream"]
|
||||||
ports:
|
ports:
|
||||||
- "4222:4222"
|
- "4222:4222"
|
||||||
- "6222:6222"
|
|
||||||
- "8222:8222"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
qdrant:
|
# Uncomment to enable smart features (semantic search + related notes)
|
||||||
image: qdrant/qdrant:latest
|
# qdrant:
|
||||||
container_name: k_notes_qdrant
|
# image: qdrant/qdrant:latest
|
||||||
ports:
|
# ports:
|
||||||
- "6333:6333"
|
# - "6334:6334"
|
||||||
- "6334:6334"
|
|
||||||
volumes:
|
|
||||||
- ./data/qdrant_storage:/qdrant/storage:z
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# Optional: Define volumes explicitly if needed
|
|
||||||
# volumes:
|
# volumes:
|
||||||
# backend_data:
|
# - ./data/qdrant_storage:/qdrant/storage:z
|
||||||
|
# restart: unless-stopped
|
||||||
|
|||||||
27
crates/adapters/auth/Cargo.toml
Normal file
27
crates/adapters/auth/Cargo.toml
Normal file
@@ -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 }
|
||||||
30
crates/adapters/auth/src/config.rs
Normal file
30
crates/adapters/auth/src/config.rs
Normal file
@@ -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<String>,
|
||||||
|
pub redirect_url: String,
|
||||||
|
/// Optional audience / resource ID for token validation.
|
||||||
|
pub resource_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config for JWT. Validated when constructing JwtValidator.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JwtConfig {
|
||||||
|
pub secret: String,
|
||||||
|
pub issuer: Option<String>,
|
||||||
|
pub audience: Option<String>,
|
||||||
|
pub expiry_hours: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtConfig {
|
||||||
|
pub fn new(secret: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
secret: secret.into(),
|
||||||
|
issuer: None,
|
||||||
|
audience: None,
|
||||||
|
expiry_hours: 24,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
crates/adapters/auth/src/jwt.rs
Normal file
100
crates/adapters/auth/src/jwt.rs
Normal file
@@ -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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aud: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String, JwtError> {
|
||||||
|
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<JwtClaims, JwtError> {
|
||||||
|
decode::<JwtClaims>(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;
|
||||||
8
crates/adapters/auth/src/lib.rs
Normal file
8
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod password;
|
||||||
|
|
||||||
|
#[cfg(feature = "jwt")]
|
||||||
|
pub mod jwt;
|
||||||
|
|
||||||
|
#[cfg(feature = "oidc")]
|
||||||
|
pub mod oidc;
|
||||||
178
crates/adapters/auth/src/oidc.rs
Normal file
178
crates/adapters/auth/src/oidc.rs
Normal file
@@ -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<CoreErrorResponseType>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcService {
|
||||||
|
pub async fn new(config: OidcConfig) -> Result<Self> {
|
||||||
|
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<OidcUser> {
|
||||||
|
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<EmptyAdditionalClaims, CoreGenderClaim> = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
51
crates/adapters/auth/src/password.rs
Normal file
51
crates/adapters/auth/src/password.rs
Normal file
@@ -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<DomainPasswordHash> {
|
||||||
|
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<bool> {
|
||||||
|
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;
|
||||||
68
crates/adapters/auth/src/tests/jwt.rs
Normal file
68
crates/adapters/auth/src/tests/jwt.rs
Normal file
@@ -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)));
|
||||||
|
}
|
||||||
36
crates/adapters/auth/src/tests/password.rs
Normal file
36
crates/adapters/auth/src/tests/password.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
11
crates/adapters/event-payload/Cargo.toml
Normal file
11
crates/adapters/event-payload/Cargo.toml
Normal file
@@ -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 }
|
||||||
86
crates/adapters/event-payload/src/lib.rs
Normal file
86
crates/adapters/event-payload/src/lib.rs
Normal file
@@ -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<Vec<u8>, DomainError> {
|
||||||
|
serde_json::to_vec(self)
|
||||||
|
.map_err(|e| DomainError::Infrastructure(format!("serialize failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_json(bytes: &[u8]) -> Result<Self, DomainError> {
|
||||||
|
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<EventPayload> for DomainEvent {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(payload: EventPayload) -> Result<Self, Self::Error> {
|
||||||
|
fn parse(s: &str) -> Result<Uuid, DomainError> {
|
||||||
|
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;
|
||||||
88
crates/adapters/event-payload/src/tests/lib.rs
Normal file
88
crates/adapters/event-payload/src/tests/lib.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
11
crates/adapters/event-publisher-memory/Cargo.toml
Normal file
11
crates/adapters/event-publisher-memory/Cargo.toml
Normal file
@@ -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 }
|
||||||
85
crates/adapters/event-publisher-memory/src/lib.rs
Normal file
85
crates/adapters/event-publisher-memory/src/lib.rs
Normal file
@@ -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<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryEventBus {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publisher(&self) -> Arc<MemoryEventPublisher> {
|
||||||
|
Arc::new(MemoryEventPublisher {
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consumer(&self) -> Arc<MemoryEventConsumer> {
|
||||||
|
Arc::new(MemoryEventConsumer {
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryEventBus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MemoryEventPublisher {
|
||||||
|
sender: broadcast::Sender<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventConsumer for MemoryEventConsumer {
|
||||||
|
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||||
|
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;
|
||||||
75
crates/adapters/event-publisher-memory/src/tests/lib.rs
Normal file
75
crates/adapters/event-publisher-memory/src/tests/lib.rs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
14
crates/adapters/fastembed/Cargo.toml
Normal file
14
crates/adapters/fastembed/Cargo.toml
Normal file
@@ -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 }
|
||||||
88
crates/adapters/fastembed/src/lib.rs
Normal file
88
crates/adapters/fastembed/src/lib.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
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<Mutex<TextEmbedding>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FastEmbedGenerator {
|
||||||
|
/// Initialise the model. Downloads and caches model files on first call.
|
||||||
|
pub fn new(config: FastEmbedConfig) -> Result<Self, DomainError> {
|
||||||
|
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<Vec<f32>> {
|
||||||
|
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;
|
||||||
33
crates/adapters/fastembed/src/tests/lib.rs
Normal file
33
crates/adapters/fastembed/src/tests/lib.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
17
crates/adapters/nats/Cargo.toml
Normal file
17
crates/adapters/nats/Cargo.toml
Normal file
@@ -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 }
|
||||||
104
crates/adapters/nats/src/consumer.rs
Normal file
104
crates/adapters/nats/src/consumer.rs
Normal file
@@ -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<consumer::Consumer<pull::Config>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsEventConsumer {
|
||||||
|
pub(crate) fn new(consumer: consumer::Consumer<pull::Config>) -> Self {
|
||||||
|
Self {
|
||||||
|
consumer: Arc::new(consumer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventConsumer for NatsEventConsumer {
|
||||||
|
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
92
crates/adapters/nats/src/lib.rs
Normal file
92
crates/adapters/nats/src/lib.rs
Normal file
@@ -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<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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<pull::Config> = 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),
|
||||||
|
))
|
||||||
|
}
|
||||||
34
crates/adapters/nats/src/publisher.rs
Normal file
34
crates/adapters/nats/src/publisher.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/adapters/qdrant/Cargo.toml
Normal file
14
crates/adapters/qdrant/Cargo.toml
Normal file
@@ -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 }
|
||||||
139
crates/adapters/qdrant/src/lib.rs
Normal file
139
crates/adapters/qdrant/src/lib.rs
Normal file
@@ -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<Self, Box<QdrantError>> {
|
||||||
|
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<Vec<(NoteId, f32)>> {
|
||||||
|
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;
|
||||||
45
crates/adapters/qdrant/src/tests/lib.rs
Normal file
45
crates/adapters/qdrant/src/tests/lib.rs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
15
crates/adapters/sqlite/Cargo.toml
Normal file
15
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -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 }
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add password_hash column to users table
|
||||||
|
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT 'DEFAULT';
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
43
crates/adapters/sqlite/src/db.rs
Normal file
43
crates/adapters/sqlite/src/db.rs
Normal file
@@ -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<SqlitePool, sqlx::Error> {
|
||||||
|
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<DateTime<Utc>, 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<T> {
|
||||||
|
fn repo(self) -> Result<T, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RepoExt<T> for Result<T, sqlx::Error> {
|
||||||
|
fn repo(self) -> Result<T, DomainError> {
|
||||||
|
self.map_err(|e| DomainError::Repository(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod db;
|
||||||
|
pub mod link;
|
||||||
|
pub mod note;
|
||||||
|
pub mod tag;
|
||||||
|
pub mod user;
|
||||||
103
crates/adapters/sqlite/src/link.rs
Normal file
103
crates/adapters/sqlite/src/link.rs
Normal file
@@ -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<LinkRow> for NoteLink {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(row: LinkRow) -> Result<Self, Self::Error> {
|
||||||
|
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<Vec<NoteLink>> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
281
crates/adapters/sqlite/src/note.rs
Normal file
281
crates/adapters/sqlite/src/note.rs
Normal file
@@ -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<String>,
|
||||||
|
content: String,
|
||||||
|
color: String,
|
||||||
|
is_pinned: i32,
|
||||||
|
is_archived: i32,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
tags_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<NoteRow> for Note {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(row: NoteRow) -> Result<Self, Self::Error> {
|
||||||
|
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<Vec<Tag>, DomainError> {
|
||||||
|
let values: Vec<serde_json::Value> = 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<String>,
|
||||||
|
content: String,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<VersionRow> for NoteVersion {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(row: VersionRow) -> Result<Self, Self::Error> {
|
||||||
|
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<Option<Note>> {
|
||||||
|
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<Vec<Note>> {
|
||||||
|
let base = format!("{NOTE_SELECT} WHERE n.user_id = ");
|
||||||
|
let mut qb: QueryBuilder<Sqlite> = 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::<NoteRow>()
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.repo()?
|
||||||
|
.into_iter()
|
||||||
|
.map(Note::try_from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>> {
|
||||||
|
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<Vec<NoteVersion>> {
|
||||||
|
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;
|
||||||
157
crates/adapters/sqlite/src/tag.rs
Normal file
157
crates/adapters/sqlite/src/tag.rs
Normal file
@@ -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<TagRow> for Tag {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(row: TagRow) -> Result<Self, Self::Error> {
|
||||||
|
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<Option<Tag>> {
|
||||||
|
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<Vec<Tag>> {
|
||||||
|
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<Option<Tag>> {
|
||||||
|
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<Vec<Tag>> {
|
||||||
|
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;
|
||||||
119
crates/adapters/sqlite/src/tests/note.rs
Normal file
119
crates/adapters/sqlite/src/tests/note.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
82
crates/adapters/sqlite/src/tests/tag.rs
Normal file
82
crates/adapters/sqlite/src/tests/tag.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
84
crates/adapters/sqlite/src/tests/user.rs
Normal file
84
crates/adapters/sqlite/src/tests/user.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
129
crates/adapters/sqlite/src/user.rs
Normal file
129
crates/adapters/sqlite/src/user.rs
Normal file
@@ -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<String>,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<UserRow> for User {
|
||||||
|
type Error = domain::errors::DomainError;
|
||||||
|
|
||||||
|
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
|
||||||
|
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<Option<User>> {
|
||||||
|
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<Option<User>> {
|
||||||
|
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<Option<User>> {
|
||||||
|
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;
|
||||||
10
crates/api-types/Cargo.toml
Normal file
10
crates/api-types/Cargo.toml
Normal file
@@ -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"] }
|
||||||
29
crates/api-types/src/auth.rs
Normal file
29
crates/api-types/src/auth.rs
Normal file
@@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned after a successful login or register.
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub user: UserResponse,
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
||||||
18
crates/api-types/src/backup.rs
Normal file
18
crates/api-types/src/backup.rs
Normal file
@@ -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<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_archived: bool,
|
||||||
|
/// Tag names associated with this note.
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct BackupData {
|
||||||
|
pub notes: Vec<BackupNote>,
|
||||||
|
}
|
||||||
6
crates/api-types/src/config.rs
Normal file
6
crates/api-types/src/config.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct ConfigResponse {
|
||||||
|
pub allow_registration: bool,
|
||||||
|
}
|
||||||
36
crates/api-types/src/errors.rs
Normal file
36
crates/api-types/src/errors.rs
Normal file
@@ -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<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::new("NOT_FOUND", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||||
|
Self::new("FORBIDDEN", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
|
Self::new("CONFLICT", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validation(message: impl Into<String>) -> Self {
|
||||||
|
Self::new("VALIDATION_ERROR", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self::new("INTERNAL_ERROR", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/api-types/src/lib.rs
Normal file
6
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod config;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod notes;
|
||||||
|
pub mod tags;
|
||||||
82
crates/api-types/src/notes.rs
Normal file
82
crates/api-types/src/notes.rs
Normal file
@@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub content: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateNoteRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default, utoipa::IntoParams)]
|
||||||
|
pub struct ListNotesParams {
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
pub archived: Option<bool>,
|
||||||
|
/// Filter by tag name.
|
||||||
|
pub tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_archived: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub tags: Vec<TagResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct NoteVersionResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub note_id: Uuid,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
}
|
||||||
18
crates/api-types/src/tags.rs
Normal file
18
crates/api-types/src/tags.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
13
crates/application/Cargo.toml
Normal file
13
crates/application/Cargo.toml
Normal file
@@ -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 }
|
||||||
9
crates/application/src/auth/commands.rs
Normal file
9
crates/application/src/auth/commands.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
37
crates/application/src/auth/login.rs
Normal file
37
crates/application/src/auth/login.rs
Normal file
@@ -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<User> {
|
||||||
|
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;
|
||||||
3
crates/application/src/auth/mod.rs
Normal file
3
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
31
crates/application/src/auth/register.rs
Normal file
31
crates/application/src/auth/register.rs
Normal file
@@ -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<User> {
|
||||||
|
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;
|
||||||
66
crates/application/src/auth/tests/login.rs
Normal file
66
crates/application/src/auth/tests/login.rs
Normal file
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
56
crates/application/src/auth/tests/register.rs
Normal file
56
crates/application/src/auth/tests/register.rs
Normal file
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
33
crates/application/src/config.rs
Normal file
33
crates/application/src/config.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
crates/application/src/context.rs
Normal file
37
crates/application/src/context.rs
Normal file
@@ -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<dyn NoteRepository>,
|
||||||
|
pub tag: Arc<dyn TagRepository>,
|
||||||
|
pub user: Arc<dyn UserRepository>,
|
||||||
|
pub link: Arc<dyn LinkRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Services {
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
/// None when smart features are not configured.
|
||||||
|
pub embedding: Option<Arc<dyn EmbeddingGenerator>>,
|
||||||
|
/// None when smart features are not configured.
|
||||||
|
pub vector_store: Option<Arc<dyn VectorStore>>,
|
||||||
|
pub event_consumer: Arc<dyn EventConsumer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppContext {
|
||||||
|
pub repos: Repositories,
|
||||||
|
pub services: Services,
|
||||||
|
pub config: AppConfig,
|
||||||
|
}
|
||||||
10
crates/application/src/lib.rs
Normal file
10
crates/application/src/lib.rs
Normal file
@@ -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;
|
||||||
51
crates/application/src/notes/add_tag.rs
Normal file
51
crates/application/src/notes/add_tag.rs
Normal file
@@ -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;
|
||||||
30
crates/application/src/notes/archive_note.rs
Normal file
30
crates/application/src/notes/archive_note.rs
Normal file
@@ -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<Note> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
46
crates/application/src/notes/commands.rs
Normal file
46
crates/application/src/notes/commands.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct CreateNoteCommand {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateNoteCommand {
|
||||||
|
pub note_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
46
crates/application/src/notes/create_note.rs
Normal file
46
crates/application/src/notes/create_note.rs
Normal file
@@ -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<Note> {
|
||||||
|
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;
|
||||||
44
crates/application/src/notes/delete_note.rs
Normal file
44
crates/application/src/notes/delete_note.rs
Normal file
@@ -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;
|
||||||
33
crates/application/src/notes/export_notes.rs
Normal file
33
crates/application/src/notes/export_notes.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use domain::{errors::DomainResult, note::entity::NoteFilter, user::entity::UserId};
|
||||||
|
|
||||||
|
use crate::context::AppContext;
|
||||||
|
|
||||||
|
pub struct ExportedNote {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_archived: bool,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> DomainResult<Vec<ExportedNote>> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
28
crates/application/src/notes/get_note.rs
Normal file
28
crates/application/src/notes/get_note.rs
Normal file
@@ -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<Note> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
28
crates/application/src/notes/get_related.rs
Normal file
28
crates/application/src/notes/get_related.rs
Normal file
@@ -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<Vec<NoteLink>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
28
crates/application/src/notes/get_versions.rs
Normal file
28
crates/application/src/notes/get_versions.rs
Normal file
@@ -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<Vec<NoteVersion>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
73
crates/application/src/notes/import_notes.rs
Normal file
73
crates/application/src/notes/import_notes.rs
Normal file
@@ -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<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_archived: bool,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
notes: Vec<ImportNote>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
22
crates/application/src/notes/list_notes.rs
Normal file
22
crates/application/src/notes/list_notes.rs
Normal file
@@ -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<Vec<Note>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
16
crates/application/src/notes/mod.rs
Normal file
16
crates/application/src/notes/mod.rs
Normal file
@@ -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;
|
||||||
30
crates/application/src/notes/pin_note.rs
Normal file
30
crates/application/src/notes/pin_note.rs
Normal file
@@ -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<Note> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
33
crates/application/src/notes/queries.rs
Normal file
33
crates/application/src/notes/queries.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
30
crates/application/src/notes/remove_tag.rs
Normal file
30
crates/application/src/notes/remove_tag.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
9
crates/application/src/notes/search_notes.rs
Normal file
9
crates/application/src/notes/search_notes.rs
Normal file
@@ -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<Vec<Note>> {
|
||||||
|
let user_id = UserId::from_uuid(q.user_id);
|
||||||
|
ctx.repos.note.search(&user_id, &q.query).await
|
||||||
|
}
|
||||||
98
crates/application/src/notes/tests/add_tag.rs
Normal file
98
crates/application/src/notes/tests/add_tag.rs
Normal file
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
50
crates/application/src/notes/tests/create_note.rs
Normal file
50
crates/application/src/notes/tests/create_note.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
73
crates/application/src/notes/tests/delete_note.rs
Normal file
73
crates/application/src/notes/tests/delete_note.rs
Normal file
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
115
crates/application/src/notes/tests/update_note.rs
Normal file
115
crates/application/src/notes/tests/update_note.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
63
crates/application/src/notes/update_note.rs
Normal file
63
crates/application/src/notes/update_note.rs
Normal file
@@ -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<Note> {
|
||||||
|
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;
|
||||||
12
crates/application/src/smart/delete_vectors.rs
Normal file
12
crates/application/src/smart/delete_vectors.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
2
crates/application/src/smart/mod.rs
Normal file
2
crates/application/src/smart/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod delete_vectors;
|
||||||
|
pub mod process_note;
|
||||||
48
crates/application/src/smart/process_note.rs
Normal file
48
crates/application/src/smart/process_note.rs
Normal file
@@ -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<NoteLink> = 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;
|
||||||
113
crates/application/src/smart/tests/process_note.rs
Normal file
113
crates/application/src/smart/tests/process_note.rs
Normal file
@@ -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<Vec<f32>> {
|
||||||
|
Ok(vec![1.0, 0.0, 0.0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeVectorStore {
|
||||||
|
upserted: Mutex<Vec<NoteId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<(NoteId, f32)>> {
|
||||||
|
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<dyn VectorStore>);
|
||||||
|
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());
|
||||||
|
}
|
||||||
17
crates/application/src/tags/commands.rs
Normal file
17
crates/application/src/tags/commands.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
26
crates/application/src/tags/create_tag.rs
Normal file
26
crates/application/src/tags/create_tag.rs
Normal file
@@ -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<Tag> {
|
||||||
|
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;
|
||||||
28
crates/application/src/tags/delete_tag.rs
Normal file
28
crates/application/src/tags/delete_tag.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
9
crates/application/src/tags/list_tags.rs
Normal file
9
crates/application/src/tags/list_tags.rs
Normal file
@@ -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<Vec<Tag>> {
|
||||||
|
let user_id = UserId::from_uuid(q.user_id);
|
||||||
|
ctx.repos.tag.find_by_user(&user_id).await
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user