feat: Add related notes functionality with new API endpoint and frontend components, and update note search route.

This commit is contained in:
2025-12-26 00:35:32 +01:00
parent 64f8118228
commit c8276ac306
12 changed files with 245 additions and 37 deletions

View File

@@ -172,3 +172,23 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
pub struct ConfigResponse {
pub allow_registration: bool,
}
/// Note Link response DTO
#[derive(Debug, Serialize)]
pub struct NoteLinkResponse {
pub source_note_id: Uuid,
pub target_note_id: Uuid,
pub score: f32,
pub created_at: DateTime<Utc>,
}
impl From<notes_domain::entities::NoteLink> for NoteLinkResponse {
fn from(link: notes_domain::entities::NoteLink) -> Self {
Self {
source_note_id: link.source_note_id,
target_note_id: link.target_note_id,
score: link.score,
created_at: link.created_at,
}
}
}

View File

@@ -44,8 +44,8 @@ async fn main() -> anyhow::Result<()> {
let db_config = DatabaseConfig::new(&config.database_url);
use notes_infra::factory::{
build_database_pool, build_note_repository, build_session_store, build_tag_repository,
build_user_repository,
build_database_pool, build_link_repository, build_note_repository, build_session_store,
build_tag_repository, build_user_repository,
};
let pool = build_database_pool(&db_config)
.await
@@ -73,6 +73,9 @@ async fn main() -> anyhow::Result<()> {
let user_repo = build_user_repository(&pool)
.await
.map_err(|e| anyhow::anyhow!(e))?;
let link_repo = build_link_repository(&pool)
.await
.map_err(|e| anyhow::anyhow!(e))?;
// Create services
use notes_domain::{NoteService, TagService, UserService};
@@ -91,6 +94,7 @@ async fn main() -> anyhow::Result<()> {
note_repo,
tag_repo,
user_repo.clone(),
link_repo,
note_service,
tag_service,
user_service,
@@ -119,35 +123,36 @@ async fn main() -> anyhow::Result<()> {
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
// Parse CORS origins
let mut cors = CorsLayer::new()
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
])
.allow_credentials(true);
// let mut cors = CorsLayer::new()
// .allow_methods([
// axum::http::Method::GET,
// axum::http::Method::POST,
// axum::http::Method::PATCH,
// axum::http::Method::DELETE,
// axum::http::Method::OPTIONS,
// ])
// .allow_headers([
// axum::http::header::AUTHORIZATION,
// axum::http::header::ACCEPT,
// axum::http::header::CONTENT_TYPE,
// ])
// .allow_credentials(true);
let mut cors = CorsLayer::very_permissive();
// Add allowed origins
let mut allowed_origins = Vec::new();
for origin in &config.cors_allowed_origins {
tracing::debug!("Allowing CORS origin: {}", origin);
if let Ok(value) = origin.parse::<axum::http::HeaderValue>() {
allowed_origins.push(value);
} else {
tracing::warn!("Invalid CORS origin: {}", origin);
}
}
// let mut allowed_origins = Vec::new();
// for origin in &config.cors_allowed_origins {
// tracing::debug!("Allowing CORS origin: {}", origin);
// if let Ok(value) = origin.parse::<axum::http::HeaderValue>() {
// allowed_origins.push(value);
// } else {
// tracing::warn!("Invalid CORS origin: {}", origin);
// }
// }
if !allowed_origins.is_empty() {
cors = cors.allow_origin(allowed_origins);
}
// if !allowed_origins.is_empty() {
// cors = cors.allow_origin(allowed_origins);
// }
// Build the application
let app = Router::new()

View File

@@ -30,6 +30,7 @@ pub fn api_v1_router() -> Router<AppState> {
.delete(notes::delete_note),
)
.route("/notes/{id}/versions", get(notes::list_note_versions))
.route("/notes/{id}/related", get(notes::get_related_notes))
// Search route
.route("/search", get(notes::search_notes))
// Import/Export routes

View File

@@ -184,7 +184,7 @@ pub async fn delete_note(
}
/// Search notes
/// GET /api/v1/search
/// GET /api/v1/notes/search
pub async fn search_notes(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
@@ -225,3 +225,30 @@ pub async fn list_note_versions(
Ok(Json(response))
}
/// Get related notes
/// GET /api/v1/notes/:id/related
pub async fn get_related_notes(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<crate::dto::NoteLinkResponse>>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
// Verify access to the source note
state.note_service.get_note(id, user_id).await?;
// Get links
let links = state.link_repo.get_links_for_note(id).await?;
let response: Vec<crate::dto::NoteLinkResponse> = links
.into_iter()
.map(crate::dto::NoteLinkResponse::from)
.collect();
Ok(Json(response))
}

View File

@@ -11,6 +11,7 @@ pub struct AppState {
pub note_repo: Arc<dyn NoteRepository>,
pub tag_repo: Arc<dyn TagRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
pub note_service: Arc<NoteService>,
pub tag_service: Arc<TagService>,
pub user_service: Arc<UserService>,
@@ -23,6 +24,7 @@ impl AppState {
note_repo: Arc<dyn NoteRepository>,
tag_repo: Arc<dyn TagRepository>,
user_repo: Arc<dyn UserRepository>,
link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
note_service: Arc<NoteService>,
tag_service: Arc<TagService>,
user_service: Arc<UserService>,
@@ -33,6 +35,7 @@ impl AppState {
note_repo,
tag_repo,
user_repo,
link_repo,
note_service,
tag_service,
user_service,