refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
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
|
||||
}
|
||||
6
crates/application/src/tags/mod.rs
Normal file
6
crates/application/src/tags/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod create_tag;
|
||||
pub mod delete_tag;
|
||||
pub mod list_tags;
|
||||
pub mod queries;
|
||||
pub mod rename_tag;
|
||||
5
crates/application/src/tags/queries.rs
Normal file
5
crates/application/src/tags/queries.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ListTagsQuery {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
31
crates/application/src/tags/rename_tag.rs
Normal file
31
crates/application/src/tags/rename_tag.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use domain::{
|
||||
errors::{DomainError, DomainResult},
|
||||
tag::{entity::Tag, entity::TagId, value_objects::TagName},
|
||||
user::entity::UserId,
|
||||
};
|
||||
|
||||
use super::commands::RenameTagCommand;
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: RenameTagCommand) -> DomainResult<Tag> {
|
||||
let tag_id = TagId::from_uuid(cmd.tag_id);
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let new_name = TagName::new(cmd.new_name)?;
|
||||
|
||||
let mut tag = ctx
|
||||
.repos
|
||||
.tag
|
||||
.find_by_id(&tag_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
|
||||
|
||||
if tag.user_id != user_id {
|
||||
return Err(DomainError::Forbidden(
|
||||
"cannot rename another user's tag".into(),
|
||||
));
|
||||
}
|
||||
|
||||
tag.name = new_name;
|
||||
ctx.repos.tag.save(&tag).await?;
|
||||
Ok(tag)
|
||||
}
|
||||
76
crates/application/src/tags/tests/create_tag.rs
Normal file
76
crates/application/src/tags/tests/create_tag.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::{
|
||||
tags::{commands::CreateTagCommand, create_tag},
|
||||
test_helpers::TestContext,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_new_tag() {
|
||||
let t = TestContext::new();
|
||||
let tag = create_tag::execute(
|
||||
&t.ctx,
|
||||
CreateTagCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
name: "work".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tag.name.as_ref(), "work");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_existing_tag_with_same_name() {
|
||||
let t = TestContext::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let first = create_tag::execute(
|
||||
&t.ctx,
|
||||
CreateTagCommand {
|
||||
user_id,
|
||||
name: "rust".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second = create_tag::execute(
|
||||
&t.ctx,
|
||||
CreateTagCommand {
|
||||
user_id,
|
||||
name: "rust".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_users_can_have_same_tag_name() {
|
||||
let t = TestContext::new();
|
||||
|
||||
let a = create_tag::execute(
|
||||
&t.ctx,
|
||||
CreateTagCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
name: "shared".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let b = create_tag::execute(
|
||||
&t.ctx,
|
||||
CreateTagCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
name: "shared".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
330
crates/application/src/test_helpers.rs
Normal file
330
crates/application/src/test_helpers.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::{DomainError, DomainResult},
|
||||
events::{DomainEvent, EventPublisher},
|
||||
note::{
|
||||
entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion},
|
||||
ports::{LinkRepository, NoteRepository},
|
||||
},
|
||||
tag::{
|
||||
entity::{Tag, TagId},
|
||||
ports::TagRepository,
|
||||
value_objects::TagName,
|
||||
},
|
||||
user::{
|
||||
entity::{User, UserId},
|
||||
ports::{PasswordHasher, UserRepository},
|
||||
value_objects::{Email, Password, PasswordHash},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{AppConfig, SmartConfig},
|
||||
context::{AppContext, Repositories, Services},
|
||||
};
|
||||
|
||||
// ── Note ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryNoteRepo {
|
||||
notes: Mutex<HashMap<NoteId, Note>>,
|
||||
versions: Mutex<HashMap<NoteId, Vec<NoteVersion>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NoteRepository for MemoryNoteRepo {
|
||||
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>> {
|
||||
Ok(self.notes.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>> {
|
||||
Ok(self
|
||||
.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.user_id == *user_id
|
||||
&& filter.is_pinned.map_or(true, |v| n.is_pinned == v)
|
||||
&& filter.is_archived.map_or(true, |v| n.is_archived == v)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>> {
|
||||
let q = query.to_lowercase();
|
||||
Ok(self
|
||||
.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.user_id == *user_id
|
||||
&& (n.content.to_lowercase().contains(&q)
|
||||
|| n.title
|
||||
.as_ref()
|
||||
.map_or(false, |t| t.as_ref().to_lowercase().contains(&q)))
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(&self, note: &Note) -> DomainResult<()> {
|
||||
self.notes.lock().unwrap().insert(note.id, note.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &NoteId) -> DomainResult<()> {
|
||||
self.notes.lock().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_version(&self, v: &NoteVersion) -> DomainResult<()> {
|
||||
self.versions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(v.note_id)
|
||||
.or_default()
|
||||
.push(v.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>> {
|
||||
Ok(self
|
||||
.versions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(note_id)
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryTagRepo {
|
||||
tags: Mutex<HashMap<TagId, Tag>>,
|
||||
note_tags: Mutex<HashMap<(NoteId, TagId), ()>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TagRepository for MemoryTagRepo {
|
||||
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>> {
|
||||
Ok(self.tags.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>> {
|
||||
Ok(self
|
||||
.tags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|t| t.user_id == *user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>> {
|
||||
Ok(self
|
||||
.tags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|t| t.user_id == *user_id && t.name == *name)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>> {
|
||||
let note_tags = self.note_tags.lock().unwrap();
|
||||
let tags = self.tags.lock().unwrap();
|
||||
Ok(note_tags
|
||||
.keys()
|
||||
.filter(|(nid, _)| nid == note_id)
|
||||
.filter_map(|(_, tid)| tags.get(tid).cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(&self, tag: &Tag) -> DomainResult<()> {
|
||||
self.tags.lock().unwrap().insert(tag.id, tag.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &TagId) -> DomainResult<()> {
|
||||
self.tags.lock().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
|
||||
self.note_tags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((*note_id, *tag_id), ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
|
||||
self.note_tags.lock().unwrap().remove(&(*note_id, *tag_id));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryUserRepo {
|
||||
users: Mutex<HashMap<UserId, User>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for MemoryUserRepo {
|
||||
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>> {
|
||||
Ok(self.users.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
|
||||
Ok(self
|
||||
.users
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.subject == subject)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>> {
|
||||
Ok(self
|
||||
.users
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.email.as_ref() == email.as_ref())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> DomainResult<()> {
|
||||
self.users.lock().unwrap().insert(user.id, user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> DomainResult<()> {
|
||||
self.users.lock().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Link ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryLinkRepo {
|
||||
links: Mutex<Vec<NoteLink>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LinkRepository for MemoryLinkRepo {
|
||||
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> {
|
||||
self.links.lock().unwrap().extend_from_slice(links);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> {
|
||||
self.links
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|l| l.source_id != *source_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>> {
|
||||
Ok(self
|
||||
.links
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|l| l.source_id == *note_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── PasswordHasher ───────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PlaintextHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for PlaintextHasher {
|
||||
async fn hash(&self, password: &Password) -> DomainResult<PasswordHash> {
|
||||
Ok(PasswordHash::new(format!("hashed:{}", password.as_ref())))
|
||||
}
|
||||
|
||||
async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult<bool> {
|
||||
Ok(hash.as_str() == format!("hashed:{}", password.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── EventPublisher ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RecordingPublisher {
|
||||
pub events: Mutex<Vec<DomainEvent>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for RecordingPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
self.events.lock().unwrap().push(event.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppContext builder ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct TestContext {
|
||||
pub ctx: AppContext,
|
||||
pub publisher: Arc<RecordingPublisher>,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
pub fn new() -> Self {
|
||||
use domain::events::EventConsumer;
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
struct NoopConsumer;
|
||||
impl EventConsumer for NoopConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<domain::events::EventEnvelope, DomainError>> {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
}
|
||||
|
||||
let publisher = Arc::new(RecordingPublisher::default());
|
||||
|
||||
let ctx = AppContext {
|
||||
repos: Repositories {
|
||||
note: Arc::new(MemoryNoteRepo::default()),
|
||||
tag: Arc::new(MemoryTagRepo::default()),
|
||||
user: Arc::new(MemoryUserRepo::default()),
|
||||
link: Arc::new(MemoryLinkRepo::default()),
|
||||
},
|
||||
services: Services {
|
||||
password_hasher: Arc::new(PlaintextHasher),
|
||||
event_publisher: Arc::clone(&publisher) as Arc<dyn EventPublisher>,
|
||||
embedding: None,
|
||||
vector_store: None,
|
||||
event_consumer: Arc::new(NoopConsumer),
|
||||
},
|
||||
config: AppConfig {
|
||||
base_url: "http://localhost:3000".into(),
|
||||
smart: SmartConfig::default(),
|
||||
allow_registration: true,
|
||||
},
|
||||
};
|
||||
|
||||
Self { ctx, publisher }
|
||||
}
|
||||
}
|
||||
85
crates/application/src/worker.rs
Normal file
85
crates/application/src/worker.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::{EventConsumer, EventEnvelope, EventHandler};
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const DEFAULT_CONCURRENCY: usize = 8;
|
||||
|
||||
pub struct WorkerService {
|
||||
consumer: Arc<dyn EventConsumer>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl WorkerService {
|
||||
pub fn new(consumer: Arc<dyn EventConsumer>, handlers: Vec<Arc<dyn EventHandler>>) -> Self {
|
||||
Self {
|
||||
consumer,
|
||||
handlers,
|
||||
semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||||
let handlers = Arc::new(self.handlers);
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let mut stream = self.consumer.consume();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("shutdown received, stopping event consumption");
|
||||
break;
|
||||
}
|
||||
item = stream.next() => {
|
||||
match item {
|
||||
Some(Ok(envelope)) => {
|
||||
let permit = self.semaphore.clone().acquire_owned().await;
|
||||
let Ok(permit) = permit else { break };
|
||||
let h = Arc::clone(&handlers);
|
||||
tasks.spawn(async move {
|
||||
dispatch(h, envelope).await;
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
Some(Err(e)) => tracing::error!("event consumer error: {e}"),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let in_flight = tasks.len();
|
||||
if in_flight > 0 {
|
||||
tracing::info!(in_flight, "draining in-flight tasks");
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(handlers: Arc<Vec<Arc<dyn EventHandler>>>, envelope: EventEnvelope) {
|
||||
let mut failed = false;
|
||||
|
||||
for handler in handlers.iter() {
|
||||
if let Err(e) = handler.handle(&envelope.event).await {
|
||||
tracing::warn!("event handler error: {e}");
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let result = if failed {
|
||||
// At least one handler failed — nack so the transport can redeliver.
|
||||
// With JetStream this triggers redelivery up to max_deliver times,
|
||||
// after which the message is considered dead (visible via advisory events).
|
||||
// Handlers must be idempotent since they may run again on redelivery.
|
||||
envelope.nack().await
|
||||
} else {
|
||||
envelope.ack().await
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!("ack/nack failed: {e}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user