Newtypes and broker refactor
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -114,42 +114,6 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-nats"
|
|
||||||
version = "0.39.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a798aab0c0203b31d67d501e5ed1f3ac6c36a329899ce47fc93c3bea53f3ae89"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"bytes",
|
|
||||||
"futures",
|
|
||||||
"memchr",
|
|
||||||
"nkeys",
|
|
||||||
"nuid",
|
|
||||||
"once_cell",
|
|
||||||
"pin-project",
|
|
||||||
"portable-atomic",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"regex",
|
|
||||||
"ring",
|
|
||||||
"rustls-native-certs 0.7.3",
|
|
||||||
"rustls-pemfile",
|
|
||||||
"rustls-webpki 0.102.8",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_nanos",
|
|
||||||
"serde_repr",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tokio-util",
|
|
||||||
"tokio-websockets",
|
|
||||||
"tracing",
|
|
||||||
"tryhard",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-nats"
|
name = "async-nats"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
@@ -2171,7 +2135,6 @@ name = "notes-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats 0.39.0",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.8.8",
|
"axum 0.8.8",
|
||||||
"axum-login",
|
"axum-login",
|
||||||
@@ -2203,6 +2166,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"futures-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
@@ -2215,11 +2179,15 @@ dependencies = [
|
|||||||
name = "notes-infra"
|
name = "notes-infra"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"fastembed",
|
"fastembed",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"notes-domain",
|
"notes-domain",
|
||||||
"qdrant-client",
|
"qdrant-client",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
@@ -2235,7 +2203,6 @@ name = "notes-worker"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats 0.45.0",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Editor } from "@/components/editor/editor";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const noteSchema = (t: any) => z.object({
|
const noteSchema = (t: any) => z.object({
|
||||||
title: z.string().min(1, t("Title is required")).max(200, t("Title too long")),
|
title: z.string().min(0, t("Title too long")).max(200, t("Title too long")),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
is_pinned: z.boolean().default(false),
|
is_pinned: z.boolean().default(false),
|
||||||
tags: z.string().optional(), // Comma separated for now
|
tags: z.string().optional(), // Comma separated for now
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ export default function PrivacyPolicyPage() {
|
|||||||
const appName = "K-Notes";
|
const appName = "K-Notes";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
<div className="min-h-screen bg-linear-to-br from-background via-background to-muted/20">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-12 space-y-8">
|
<div className="max-w-4xl mx-auto px-4 py-12 space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-4 mb-12">
|
<div className="text-center space-y-4 mb-12">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Shield className="h-16 w-16 text-primary" />
|
<Shield className="h-16 w-16 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
|
<h1 className="text-4xl font-bold bg-clip-text text-transparent bg-linear-gradient-to-r from-primary to-primary/60">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
|||||||
45
migrations/20251231000000_nullable_title.sql
Normal file
45
migrations/20251231000000_nullable_title.sql
Normal file
@@ -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;
|
||||||
@@ -16,7 +16,7 @@ postgres = [
|
|||||||
"tower-sessions-sqlx-store/postgres",
|
"tower-sessions-sqlx-store/postgres",
|
||||||
"sqlx/postgres",
|
"sqlx/postgres",
|
||||||
]
|
]
|
||||||
smart-features = ["notes-infra/smart-features", "dep:async-nats"]
|
smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notes-domain = { path = "../notes-domain" }
|
notes-domain = { path = "../notes-domain" }
|
||||||
@@ -36,7 +36,6 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
|
|||||||
password-auth = "1.0"
|
password-auth = "1.0"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
async-nats = { version = "0.39", optional = true }
|
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use notes_domain::{Note, Tag};
|
|||||||
/// Request to create a new note
|
/// Request to create a new note
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct CreateNoteRequest {
|
pub struct CreateNoteRequest {
|
||||||
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
|
#[validate(length(max = 200, message = "Title must be at most 200 characters"))]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -29,7 +29,7 @@ pub struct CreateNoteRequest {
|
|||||||
/// Request to update an existing note (all fields optional)
|
/// Request to update an existing note (all fields optional)
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct UpdateNoteRequest {
|
pub struct UpdateNoteRequest {
|
||||||
#[validate(length(min = 1, max = 200, message = "Title must be 1-200 characters"))]
|
#[validate(length(max = 200, message = "Title must be at most 200 characters"))]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
@@ -68,7 +68,7 @@ impl From<Tag> for TagResponse {
|
|||||||
fn from(tag: Tag) -> Self {
|
fn from(tag: Tag) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
name: tag.name,
|
name: tag.name.into_inner(), // Convert TagName to String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ impl From<Note> for NoteResponse {
|
|||||||
fn from(note: Note) -> Self {
|
fn from(note: Note) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
title: note.title_str().to_string(), // Convert Option<NoteTitle> to String
|
||||||
content: note.content,
|
content: note.content,
|
||||||
color: note.color,
|
color: note.color,
|
||||||
is_pinned: note.is_pinned,
|
is_pinned: note.is_pinned,
|
||||||
@@ -160,7 +160,7 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
|
|||||||
Self {
|
Self {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
note_id: version.note_id,
|
note_id: version.note_id,
|
||||||
title: version.title,
|
title: version.title.unwrap_or_default(), // Convert Option<String> to String
|
||||||
content: version.content,
|
content: version.content,
|
||||||
created_at: version.created_at,
|
created_at: version.created_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ mod auth;
|
|||||||
mod config;
|
mod config;
|
||||||
mod dto;
|
mod dto;
|
||||||
mod error;
|
mod error;
|
||||||
#[cfg(feature = "smart-features")]
|
|
||||||
mod nats_broker;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
@@ -81,13 +79,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
// Connect to NATS (before creating services that depend on it)
|
// Connect to message broker via factory
|
||||||
#[cfg(feature = "smart-features")]
|
#[cfg(feature = "smart-features")]
|
||||||
let nats_client = {
|
let message_broker = {
|
||||||
tracing::info!("Connecting to NATS: {}", config.broker_url);
|
use notes_infra::factory::{BrokerProvider, build_message_broker};
|
||||||
async_nats::connect(&config.broker_url)
|
tracing::info!("Connecting to message broker: {}", config.broker_url);
|
||||||
|
let provider = BrokerProvider::Nats {
|
||||||
|
url: config.broker_url.clone(),
|
||||||
|
};
|
||||||
|
build_message_broker(&provider)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("NATS connection failed: {}", e))?
|
.map_err(|e| anyhow::anyhow!("Broker connection failed: {}", e))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create services
|
// Create services
|
||||||
@@ -95,9 +97,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Build NoteService with optional MessageBroker
|
// Build NoteService with optional MessageBroker
|
||||||
#[cfg(feature = "smart-features")]
|
#[cfg(feature = "smart-features")]
|
||||||
let note_service = {
|
let note_service = match message_broker {
|
||||||
let broker = Arc::new(nats_broker::NatsMessageBroker::new(nats_client.clone()));
|
Some(broker) => Arc::new(
|
||||||
Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()).with_message_broker(broker))
|
NoteService::new(note_repo.clone(), tag_repo.clone()).with_message_broker(broker),
|
||||||
|
),
|
||||||
|
None => Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone())),
|
||||||
};
|
};
|
||||||
#[cfg(not(feature = "smart-features"))]
|
#[cfg(not(feature = "smart-features"))]
|
||||||
let note_service = Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()));
|
let note_service = Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()));
|
||||||
@@ -115,8 +119,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
note_service,
|
note_service,
|
||||||
tag_service,
|
tag_service,
|
||||||
user_service,
|
user_service,
|
||||||
#[cfg(feature = "smart-features")]
|
|
||||||
nats_client,
|
|
||||||
config.clone(),
|
config.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_dev_user(pool: ¬es_infra::db::DatabasePool) -> anyhow::Result<()> {
|
async fn create_dev_user(pool: ¬es_infra::db::DatabasePool) -> anyhow::Result<()> {
|
||||||
use notes_domain::User;
|
use notes_domain::{Email, User};
|
||||||
use notes_infra::factory::build_user_repository;
|
use notes_infra::factory::build_user_repository;
|
||||||
use password_auth::generate_hash;
|
use password_auth::generate_hash;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -201,10 +203,12 @@ async fn create_dev_user(pool: ¬es_infra::db::DatabasePool) -> anyhow::Result
|
|||||||
let dev_user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
let dev_user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
if user_repo.find_by_id(dev_user_id).await?.is_none() {
|
if user_repo.find_by_id(dev_user_id).await?.is_none() {
|
||||||
let hash = generate_hash("password");
|
let hash = generate_hash("password");
|
||||||
|
let dev_email = Email::try_from("dev@localhost.com")
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid dev email: {}", e))?;
|
||||||
let user = User::with_id(
|
let user = User::with_id(
|
||||||
dev_user_id,
|
dev_user_id,
|
||||||
"dev|local",
|
"dev|local",
|
||||||
"dev@localhost.com",
|
dev_email,
|
||||||
Some(hash),
|
Some(hash),
|
||||||
chrono::Utc::now(),
|
chrono::Utc::now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
//! NATS message broker adapter for domain MessageBroker port
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use notes_domain::{DomainError, DomainResult, MessageBroker, Note};
|
|
||||||
|
|
||||||
/// NATS adapter implementing the MessageBroker port
|
|
||||||
pub struct NatsMessageBroker {
|
|
||||||
client: async_nats::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NatsMessageBroker {
|
|
||||||
pub fn new(client: async_nats::Client) -> Self {
|
|
||||||
Self { client }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl MessageBroker for NatsMessageBroker {
|
|
||||||
async fn publish_note_updated(&self, note: &Note) -> DomainResult<()> {
|
|
||||||
let payload = serde_json::to_vec(note).map_err(|e| {
|
|
||||||
DomainError::RepositoryError(format!("Failed to serialize note: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
self.client
|
|
||||||
.publish("notes.updated", payload.into())
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("Failed to publish event: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ use axum::{Json, extract::State, http::StatusCode};
|
|||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use notes_domain::User;
|
use notes_domain::{Email, User};
|
||||||
use password_auth::generate_hash;
|
use password_auth::generate_hash;
|
||||||
|
|
||||||
use crate::auth::{AuthBackend, AuthUser, Credentials};
|
use crate::auth::{AuthBackend, AuthUser, Credentials};
|
||||||
@@ -43,9 +43,12 @@ pub async fn register(
|
|||||||
// Hash password
|
// Hash password
|
||||||
let password_hash = generate_hash(&payload.password);
|
let password_hash = generate_hash(&payload.password);
|
||||||
|
|
||||||
// Create use
|
// Parse email string to Email newtype
|
||||||
// For local registration, we use email as subject
|
let email = Email::try_from(payload.email)
|
||||||
let user = User::new_local(&payload.email, &password_hash);
|
.map_err(|e| ApiError::validation(format!("Invalid email: {}", e)))?;
|
||||||
|
|
||||||
|
// Create user - for local registration, we use email as subject
|
||||||
|
let user = User::new_local(email, &password_hash);
|
||||||
|
|
||||||
state.user_repo.save(&user).await.map_err(ApiError::from)?;
|
state.user_repo.save(&user).await.map_err(ApiError::from)?;
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ pub async fn me(
|
|||||||
|
|
||||||
Ok(Json(crate::dto::UserResponse {
|
Ok(Json(crate::dto::UserResponse {
|
||||||
id: user.0.id,
|
id: user.0.id,
|
||||||
email: user.0.email.clone(),
|
email: user.0.email_str().to_string(), // Convert Email to String
|
||||||
created_at: user.0.created_at,
|
created_at: user.0.created_at,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use uuid::Uuid;
|
|||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use axum_login::AuthUser;
|
use axum_login::AuthUser;
|
||||||
use notes_domain::{CreateNoteRequest as DomainCreateNote, UpdateNoteRequest as DomainUpdateNote};
|
use notes_domain::{
|
||||||
|
CreateNoteRequest as DomainCreateNote, NoteTitle, TagName,
|
||||||
|
UpdateNoteRequest as DomainUpdateNote,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
|
use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
|
||||||
@@ -71,11 +74,30 @@ pub async fn create_note(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ApiError::validation(e.to_string()))?;
|
.map_err(|e| ApiError::validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse title into NoteTitle (optional - empty string becomes None)
|
||||||
|
let title: Option<NoteTitle> = if payload.title.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
NoteTitle::try_from(payload.title)
|
||||||
|
.map_err(|e| ApiError::validation(format!("Invalid title: {}", e)))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse tags into TagName values
|
||||||
|
let tags: Vec<TagName> = payload
|
||||||
|
.tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
TagName::try_from(s).map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
let domain_req = DomainCreateNote {
|
let domain_req = DomainCreateNote {
|
||||||
user_id,
|
user_id,
|
||||||
title: payload.title,
|
title,
|
||||||
content: payload.content,
|
content: payload.content,
|
||||||
tags: payload.tags,
|
tags,
|
||||||
color: payload.color,
|
color: payload.color,
|
||||||
is_pinned: payload.is_pinned,
|
is_pinned: payload.is_pinned,
|
||||||
};
|
};
|
||||||
@@ -126,15 +148,40 @@ pub async fn update_note(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ApiError::validation(e.to_string()))?;
|
.map_err(|e| ApiError::validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse optional title - Some(string) -> Some(Some(NoteTitle)) or Some(None) for empty
|
||||||
|
let title: Option<Option<NoteTitle>> = match payload.title {
|
||||||
|
Some(t) if t.trim().is_empty() => Some(None), // Set title to None
|
||||||
|
Some(t) => {
|
||||||
|
Some(Some(NoteTitle::try_from(t).map_err(|e| {
|
||||||
|
ApiError::validation(format!("Invalid title: {}", e))
|
||||||
|
})?))
|
||||||
|
}
|
||||||
|
None => None, // Don't update title
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse optional tags
|
||||||
|
let tags: Option<Vec<TagName>> = match payload.tags {
|
||||||
|
Some(tag_strings) => Some(
|
||||||
|
tag_strings
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
TagName::try_from(s)
|
||||||
|
.map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let domain_req = DomainUpdateNote {
|
let domain_req = DomainUpdateNote {
|
||||||
id,
|
id,
|
||||||
user_id,
|
user_id,
|
||||||
title: payload.title,
|
title,
|
||||||
content: payload.content,
|
content: payload.content,
|
||||||
is_pinned: payload.is_pinned,
|
is_pinned: payload.is_pinned,
|
||||||
is_archived: payload.is_archived,
|
is_archived: payload.is_archived,
|
||||||
color: payload.color,
|
color: payload.color,
|
||||||
tags: payload.tags,
|
tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
let note = state.note_service.update_note(domain_req).await?;
|
let note = state.note_service.update_note(domain_req).await?;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use axum_login::{AuthSession, AuthUser};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
use notes_domain::TagName;
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
|
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
@@ -51,7 +53,11 @@ pub async fn create_tag(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ApiError::validation(e.to_string()))?;
|
.map_err(|e| ApiError::validation(e.to_string()))?;
|
||||||
|
|
||||||
let tag = state.tag_service.create_tag(user_id, &payload.name).await?;
|
// Parse string to TagName at API boundary
|
||||||
|
let tag_name = TagName::try_from(payload.name)
|
||||||
|
.map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?;
|
||||||
|
|
||||||
|
let tag = state.tag_service.create_tag(user_id, tag_name).await?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
|
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
|
||||||
}
|
}
|
||||||
@@ -75,10 +81,11 @@ pub async fn rename_tag(
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ApiError::validation(e.to_string()))?;
|
.map_err(|e| ApiError::validation(e.to_string()))?;
|
||||||
|
|
||||||
let tag = state
|
// Parse string to TagName at API boundary
|
||||||
.tag_service
|
let new_name = TagName::try_from(payload.name)
|
||||||
.rename_tag(id, user_id, &payload.name)
|
.map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?;
|
||||||
.await?;
|
|
||||||
|
let tag = state.tag_service.rename_tag(id, user_id, new_name).await?;
|
||||||
|
|
||||||
Ok(Json(TagResponse::from(tag)))
|
Ok(Json(TagResponse::from(tag)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ pub struct AppState {
|
|||||||
pub note_service: Arc<NoteService>,
|
pub note_service: Arc<NoteService>,
|
||||||
pub tag_service: Arc<TagService>,
|
pub tag_service: Arc<TagService>,
|
||||||
pub user_service: Arc<UserService>,
|
pub user_service: Arc<UserService>,
|
||||||
#[cfg(feature = "smart-features")]
|
|
||||||
pub nats_client: async_nats::Client,
|
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +28,6 @@ impl AppState {
|
|||||||
note_service: Arc<NoteService>,
|
note_service: Arc<NoteService>,
|
||||||
tag_service: Arc<TagService>,
|
tag_service: Arc<TagService>,
|
||||||
user_service: Arc<UserService>,
|
user_service: Arc<UserService>,
|
||||||
#[cfg(feature = "smart-features")] nats_client: async_nats::Client,
|
|
||||||
config: Config,
|
config: Config,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -42,8 +39,6 @@ impl AppState {
|
|||||||
note_service,
|
note_service,
|
||||||
tag_service,
|
tag_service,
|
||||||
user_service,
|
user_service,
|
||||||
#[cfg(feature = "smart-features")]
|
|
||||||
nats_client,
|
|
||||||
config,
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde_json = "1.0.146"
|
|||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||||
|
futures-core = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["rt", "macros"] }
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::value_objects::{Email, NoteTitle, TagName};
|
||||||
|
|
||||||
/// Maximum number of tags allowed per note (business rule)
|
/// Maximum number of tags allowed per note (business rule)
|
||||||
pub const MAX_TAGS_PER_NOTE: usize = 10;
|
pub const MAX_TAGS_PER_NOTE: usize = 10;
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ pub struct User {
|
|||||||
/// OIDC subject identifier (unique per identity provider)
|
/// OIDC subject identifier (unique per identity provider)
|
||||||
/// For local auth, this can be the same as email
|
/// For local auth, this can be the same as email
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub email: String,
|
/// Validated email address
|
||||||
|
pub email: Email,
|
||||||
/// Password hash for local authentication (Argon2 etc.)
|
/// Password hash for local authentication (Argon2 etc.)
|
||||||
pub password_hash: Option<String>,
|
pub password_hash: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
@@ -28,22 +31,22 @@ pub struct User {
|
|||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
/// Create a new user with the current timestamp
|
/// Create a new user with the current timestamp
|
||||||
pub fn new(subject: impl Into<String>, email: impl Into<String>) -> Self {
|
pub fn new(subject: impl Into<String>, email: Email) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
subject: subject.into(),
|
subject: subject.into(),
|
||||||
email: email.into(),
|
email,
|
||||||
password_hash: None,
|
password_hash: None,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new user with password hash
|
/// Create a new user with password hash
|
||||||
pub fn new_local(email: impl Into<String>, password_hash: impl Into<String>) -> Self {
|
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
|
||||||
let email = email.into();
|
let subject = email.as_ref().to_string();
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
subject: email.clone(), // Use email as subject for local auth
|
subject, // Use email as subject for local auth
|
||||||
email,
|
email,
|
||||||
password_hash: Some(password_hash.into()),
|
password_hash: Some(password_hash.into()),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
@@ -51,21 +54,27 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a user with a specific ID (for reconstruction from storage)
|
/// Create a user with a specific ID (for reconstruction from storage)
|
||||||
|
/// This accepts raw strings for compatibility with database reads.
|
||||||
pub fn with_id(
|
pub fn with_id(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
subject: impl Into<String>,
|
subject: impl Into<String>,
|
||||||
email: impl Into<String>,
|
email: Email,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
subject: subject.into(),
|
subject: subject.into(),
|
||||||
email: email.into(),
|
email,
|
||||||
password_hash,
|
password_hash,
|
||||||
created_at,
|
created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get email as string reference (convenience method)
|
||||||
|
pub fn email_str(&self) -> &str {
|
||||||
|
self.email.as_ref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tag that can be attached to notes.
|
/// A tag that can be attached to notes.
|
||||||
@@ -74,27 +83,29 @@ impl User {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
/// Validated tag name (1-50 chars, trimmed, lowercase)
|
||||||
|
pub name: TagName,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tag {
|
impl Tag {
|
||||||
/// Create a new tag for a user
|
/// Create a new tag for a user
|
||||||
pub fn new(name: impl Into<String>, user_id: Uuid) -> Self {
|
pub fn new(name: TagName, user_id: Uuid) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: name.into(),
|
name,
|
||||||
user_id,
|
user_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a tag with a specific ID (for reconstruction from storage)
|
/// Create a tag with a specific ID (for reconstruction from storage)
|
||||||
pub fn with_id(id: Uuid, name: impl Into<String>, user_id: Uuid) -> Self {
|
pub fn with_id(id: Uuid, name: TagName, user_id: Uuid) -> Self {
|
||||||
Self {
|
Self { id, name, user_id }
|
||||||
id,
|
|
||||||
name: name.into(),
|
|
||||||
user_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get name as string reference (convenience method)
|
||||||
|
pub fn name_str(&self) -> &str {
|
||||||
|
self.name.as_ref()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +113,13 @@ impl Tag {
|
|||||||
///
|
///
|
||||||
/// Notes support Markdown content and can be pinned or archived.
|
/// Notes support Markdown content and can be pinned or archived.
|
||||||
/// Each note can have up to [`MAX_TAGS_PER_NOTE`] tags.
|
/// Each note can have up to [`MAX_TAGS_PER_NOTE`] tags.
|
||||||
|
/// Title is optional - users may create notes without a title.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub title: String,
|
/// Optional title (max 200 chars when present)
|
||||||
|
pub title: Option<NoteTitle>,
|
||||||
/// Content stored as Markdown text
|
/// Content stored as Markdown text
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Background color of the note (hex or name)
|
/// Background color of the note (hex or name)
|
||||||
@@ -125,12 +138,12 @@ fn default_color() -> String {
|
|||||||
|
|
||||||
impl Note {
|
impl Note {
|
||||||
/// Create a new note with the current timestamp
|
/// Create a new note with the current timestamp
|
||||||
pub fn new(user_id: Uuid, title: impl Into<String>, content: impl Into<String>) -> Self {
|
pub fn new(user_id: Uuid, title: Option<NoteTitle>, content: impl Into<String>) -> Self {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id,
|
||||||
title: title.into(),
|
title,
|
||||||
content: content.into(),
|
content: content.into(),
|
||||||
color: default_color(),
|
color: default_color(),
|
||||||
is_pinned: false,
|
is_pinned: false,
|
||||||
@@ -160,8 +173,8 @@ impl Note {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update the note's title
|
/// Update the note's title
|
||||||
pub fn set_title(&mut self, title: impl Into<String>) {
|
pub fn set_title(&mut self, title: Option<NoteTitle>) {
|
||||||
self.title = title.into();
|
self.title = title;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +193,11 @@ impl Note {
|
|||||||
pub fn tag_count(&self) -> usize {
|
pub fn tag_count(&self) -> usize {
|
||||||
self.tags.len()
|
self.tags.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get title as string reference, returns empty string if None
|
||||||
|
pub fn title_str(&self) -> &str {
|
||||||
|
self.title.as_ref().map(|t| t.as_ref()).unwrap_or("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A snapshot of a note's state at a specific point in time.
|
/// A snapshot of a note's state at a specific point in time.
|
||||||
@@ -187,13 +205,14 @@ impl Note {
|
|||||||
pub struct NoteVersion {
|
pub struct NoteVersion {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub note_id: Uuid,
|
pub note_id: Uuid,
|
||||||
pub title: String,
|
/// Title at the time of snapshot (stored as string for historical purposes)
|
||||||
|
pub title: Option<String>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoteVersion {
|
impl NoteVersion {
|
||||||
pub fn new(note_id: Uuid, title: String, content: String) -> Self {
|
pub fn new(note_id: Uuid, title: Option<String>, content: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
note_id,
|
note_id,
|
||||||
@@ -268,27 +287,31 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_user_has_unique_id() {
|
fn test_new_user_has_unique_id() {
|
||||||
let user1 = User::new("subject1", "user1@example.com");
|
let email1 = Email::try_from("user1@example.com").unwrap();
|
||||||
let user2 = User::new("subject2", "user2@example.com");
|
let email2 = Email::try_from("user2@example.com").unwrap();
|
||||||
|
let user1 = User::new("subject1", email1);
|
||||||
|
let user2 = User::new("subject2", email2);
|
||||||
|
|
||||||
assert_ne!(user1.id, user2.id);
|
assert_ne!(user1.id, user2.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_user_sets_fields_correctly() {
|
fn test_new_user_sets_fields_correctly() {
|
||||||
let user = User::new("oidc|123456", "test@example.com");
|
let email = Email::try_from("test@example.com").unwrap();
|
||||||
|
let user = User::new("oidc|123456", email);
|
||||||
|
|
||||||
assert_eq!(user.subject, "oidc|123456");
|
assert_eq!(user.subject, "oidc|123456");
|
||||||
assert_eq!(user.email, "test@example.com");
|
assert_eq!(user.email_str(), "test@example.com");
|
||||||
assert!(user.password_hash.is_none());
|
assert!(user.password_hash.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_local_user_sets_fields_correctly() {
|
fn test_new_local_user_sets_fields_correctly() {
|
||||||
let user = User::new_local("local@example.com", "hashed_secret");
|
let email = Email::try_from("local@example.com").unwrap();
|
||||||
|
let user = User::new_local(email, "hashed_secret");
|
||||||
|
|
||||||
assert_eq!(user.subject, "local@example.com");
|
assert_eq!(user.subject, "local@example.com");
|
||||||
assert_eq!(user.email, "local@example.com");
|
assert_eq!(user.email_str(), "local@example.com");
|
||||||
assert_eq!(user.password_hash, Some("hashed_secret".to_string()));
|
assert_eq!(user.password_hash, Some("hashed_secret".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,17 +319,12 @@ mod tests {
|
|||||||
fn test_user_with_id_preserves_all_fields() {
|
fn test_user_with_id_preserves_all_fields() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let created_at = Utc::now();
|
let created_at = Utc::now();
|
||||||
let user = User::with_id(
|
let email = Email::try_from("email@test.com").unwrap();
|
||||||
id,
|
let user = User::with_id(id, "subject", email, Some("hash".to_string()), created_at);
|
||||||
"subject",
|
|
||||||
"email@test.com",
|
|
||||||
Some("hash".to_string()),
|
|
||||||
created_at,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(user.id, id);
|
assert_eq!(user.id, id);
|
||||||
assert_eq!(user.subject, "subject");
|
assert_eq!(user.subject, "subject");
|
||||||
assert_eq!(user.email, "email@test.com");
|
assert_eq!(user.email_str(), "email@test.com");
|
||||||
assert_eq!(user.password_hash, Some("hash".to_string()));
|
assert_eq!(user.password_hash, Some("hash".to_string()));
|
||||||
assert_eq!(user.created_at, created_at);
|
assert_eq!(user.created_at, created_at);
|
||||||
}
|
}
|
||||||
@@ -318,8 +336,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_new_tag_has_unique_id() {
|
fn test_new_tag_has_unique_id() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let tag1 = Tag::new("work", user_id);
|
let name1 = TagName::try_from("work").unwrap();
|
||||||
let tag2 = Tag::new("personal", user_id);
|
let name2 = TagName::try_from("personal").unwrap();
|
||||||
|
let tag1 = Tag::new(name1, user_id);
|
||||||
|
let tag2 = Tag::new(name2, user_id);
|
||||||
|
|
||||||
assert_ne!(tag1.id, tag2.id);
|
assert_ne!(tag1.id, tag2.id);
|
||||||
}
|
}
|
||||||
@@ -327,20 +347,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_new_tag_associates_with_user() {
|
fn test_new_tag_associates_with_user() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let tag = Tag::new("important", user_id);
|
let name = TagName::try_from("important").unwrap();
|
||||||
|
let tag = Tag::new(name, user_id);
|
||||||
|
|
||||||
assert_eq!(tag.user_id, user_id);
|
assert_eq!(tag.user_id, user_id);
|
||||||
assert_eq!(tag.name, "important");
|
assert_eq!(tag.name_str(), "important");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tag_with_id_preserves_all_fields() {
|
fn test_tag_with_id_preserves_all_fields() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let tag = Tag::with_id(id, "my-tag", user_id);
|
let name = TagName::try_from("my-tag").unwrap();
|
||||||
|
let tag = Tag::with_id(id, name, user_id);
|
||||||
|
|
||||||
assert_eq!(tag.id, id);
|
assert_eq!(tag.id, id);
|
||||||
assert_eq!(tag.name, "my-tag");
|
assert_eq!(tag.name_str(), "my-tag");
|
||||||
assert_eq!(tag.user_id, user_id);
|
assert_eq!(tag.user_id, user_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,8 +373,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_new_note_has_unique_id() {
|
fn test_new_note_has_unique_id() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let note1 = Note::new(user_id, "Title 1", "Content 1");
|
let title1 = NoteTitle::try_from("Title 1").ok();
|
||||||
let note2 = Note::new(user_id, "Title 2", "Content 2");
|
let title2 = NoteTitle::try_from("Title 2").ok();
|
||||||
|
let note1 = Note::new(user_id, title1, "Content 1");
|
||||||
|
let note2 = Note::new(user_id, title2, "Content 2");
|
||||||
|
|
||||||
assert_ne!(note1.id, note2.id);
|
assert_ne!(note1.id, note2.id);
|
||||||
}
|
}
|
||||||
@@ -360,20 +384,32 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_new_note_defaults() {
|
fn test_new_note_defaults() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let note = Note::new(user_id, "My Note", "# Hello World");
|
let title = NoteTitle::try_from("My Note").ok();
|
||||||
|
let note = Note::new(user_id, title, "# Hello World");
|
||||||
|
|
||||||
assert_eq!(note.user_id, user_id);
|
assert_eq!(note.user_id, user_id);
|
||||||
assert_eq!(note.title, "My Note");
|
assert_eq!(note.title_str(), "My Note");
|
||||||
assert_eq!(note.content, "# Hello World");
|
assert_eq!(note.content, "# Hello World");
|
||||||
assert!(!note.is_pinned);
|
assert!(!note.is_pinned);
|
||||||
assert!(!note.is_archived);
|
assert!(!note.is_archived);
|
||||||
assert!(note.tags.is_empty());
|
assert!(note.tags.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_note_without_title() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let note = Note::new(user_id, None, "Content without title");
|
||||||
|
|
||||||
|
assert!(note.title.is_none());
|
||||||
|
assert_eq!(note.title_str(), "");
|
||||||
|
assert_eq!(note.content, "Content without title");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_note_set_pinned_updates_timestamp() {
|
fn test_note_set_pinned_updates_timestamp() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let mut note = Note::new(user_id, "Title", "Content");
|
let title = NoteTitle::try_from("Title").ok();
|
||||||
|
let mut note = Note::new(user_id, title, "Content");
|
||||||
let original_updated_at = note.updated_at;
|
let original_updated_at = note.updated_at;
|
||||||
|
|
||||||
// Small delay to ensure timestamp changes
|
// Small delay to ensure timestamp changes
|
||||||
@@ -387,7 +423,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_note_set_archived_updates_timestamp() {
|
fn test_note_set_archived_updates_timestamp() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let mut note = Note::new(user_id, "Title", "Content");
|
let title = NoteTitle::try_from("Title").ok();
|
||||||
|
let mut note = Note::new(user_id, title, "Content");
|
||||||
let original_updated_at = note.updated_at;
|
let original_updated_at = note.updated_at;
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
@@ -400,7 +437,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_note_can_add_tag_when_under_limit() {
|
fn test_note_can_add_tag_when_under_limit() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let note = Note::new(user_id, "Title", "Content");
|
let title = NoteTitle::try_from("Title").ok();
|
||||||
|
let note = Note::new(user_id, title, "Content");
|
||||||
|
|
||||||
assert!(note.can_add_tag());
|
assert!(note.can_add_tag());
|
||||||
}
|
}
|
||||||
@@ -408,11 +446,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_note_cannot_add_tag_when_at_limit() {
|
fn test_note_cannot_add_tag_when_at_limit() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let mut note = Note::new(user_id, "Title", "Content");
|
let title = NoteTitle::try_from("Title").ok();
|
||||||
|
let mut note = Note::new(user_id, title, "Content");
|
||||||
|
|
||||||
// Add MAX_TAGS_PER_NOTE tags
|
// Add MAX_TAGS_PER_NOTE tags
|
||||||
for i in 0..MAX_TAGS_PER_NOTE {
|
for i in 0..MAX_TAGS_PER_NOTE {
|
||||||
note.tags.push(Tag::new(format!("tag-{}", i), user_id));
|
let tag_name = TagName::try_from(format!("tag-{}", i)).unwrap();
|
||||||
|
note.tags.push(Tag::new(tag_name, user_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(!note.can_add_tag());
|
assert!(!note.can_add_tag());
|
||||||
@@ -422,20 +462,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_note_set_title_updates_timestamp() {
|
fn test_note_set_title_updates_timestamp() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let mut note = Note::new(user_id, "Original", "Content");
|
let title = NoteTitle::try_from("Original").ok();
|
||||||
|
let mut note = Note::new(user_id, title, "Content");
|
||||||
let original_updated_at = note.updated_at;
|
let original_updated_at = note.updated_at;
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
note.set_title("Updated Title");
|
let new_title = NoteTitle::try_from("Updated Title").ok();
|
||||||
|
note.set_title(new_title);
|
||||||
|
|
||||||
assert_eq!(note.title, "Updated Title");
|
assert_eq!(note.title_str(), "Updated Title");
|
||||||
assert!(note.updated_at > original_updated_at);
|
assert!(note.updated_at > original_updated_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_note_set_content_updates_timestamp() {
|
fn test_note_set_content_updates_timestamp() {
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let mut note = Note::new(user_id, "Title", "Original");
|
let title = NoteTitle::try_from("Title").ok();
|
||||||
|
let mut note = Note::new(user_id, title, "Original");
|
||||||
let original_updated_at = note.updated_at;
|
let original_updated_at = note.updated_at;
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
//! - **Errors**: Domain-specific error types
|
//! - **Errors**: Domain-specific error types
|
||||||
//! - **Repositories**: Port traits defining data access interfaces
|
//! - **Repositories**: Port traits defining data access interfaces
|
||||||
//! - **Services**: Use cases orchestrating business logic
|
//! - **Services**: Use cases orchestrating business logic
|
||||||
|
//! - **Value Objects**: Validated newtypes for domain primitives
|
||||||
|
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
// Re-export commonly used types at crate root
|
// Re-export commonly used types at crate root
|
||||||
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User};
|
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User};
|
||||||
@@ -20,3 +22,7 @@ pub use errors::{DomainError, DomainResult};
|
|||||||
pub use ports::MessageBroker;
|
pub use ports::MessageBroker;
|
||||||
pub use repositories::{NoteRepository, TagRepository, UserRepository};
|
pub use repositories::{NoteRepository, TagRepository, UserRepository};
|
||||||
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService};
|
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService};
|
||||||
|
pub use value_objects::{
|
||||||
|
Email, MAX_NOTE_TITLE_LENGTH, MAX_TAG_NAME_LENGTH, MIN_PASSWORD_LENGTH, NoteTitle, Password,
|
||||||
|
TagName, ValidationError,
|
||||||
|
};
|
||||||
|
|||||||
@@ -42,4 +42,10 @@ pub trait LinkRepository: Send + Sync {
|
|||||||
pub trait MessageBroker: Send + Sync {
|
pub trait MessageBroker: Send + Sync {
|
||||||
/// Publish an event when a note is created or updated.
|
/// Publish an event when a note is created or updated.
|
||||||
async fn publish_note_updated(&self, note: &Note) -> DomainResult<()>;
|
async fn publish_note_updated(&self, note: &Note) -> DomainResult<()>;
|
||||||
|
|
||||||
|
/// Subscribe to note update events.
|
||||||
|
/// Returns a stream of notes that have been updated.
|
||||||
|
async fn subscribe_note_updates(
|
||||||
|
&self,
|
||||||
|
) -> DomainResult<std::pin::Pin<Box<dyn futures_core::Stream<Item = Note> + Send>>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ pub trait TagRepository: Send + Sync {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::value_objects::NoteTitle;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ pub(crate) mod tests {
|
|||||||
.values()
|
.values()
|
||||||
.filter(|n| n.user_id == user_id)
|
.filter(|n| n.user_id == user_id)
|
||||||
.filter(|n| {
|
.filter(|n| {
|
||||||
n.title.to_lowercase().contains(&query_lower)
|
n.title_str().to_lowercase().contains(&query_lower)
|
||||||
|| n.content.to_lowercase().contains(&query_lower)
|
|| n.content.to_lowercase().contains(&query_lower)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -171,14 +172,15 @@ pub(crate) mod tests {
|
|||||||
async fn test_mock_note_repository_save_and_find() {
|
async fn test_mock_note_repository_save_and_find() {
|
||||||
let repo = MockNoteRepository::new();
|
let repo = MockNoteRepository::new();
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let note = Note::new(user_id, "Test Note", "Test content");
|
let title = NoteTitle::try_from("Test Note").ok();
|
||||||
|
let note = Note::new(user_id, title, "Test content");
|
||||||
let note_id = note.id;
|
let note_id = note.id;
|
||||||
|
|
||||||
repo.save(¬e).await.unwrap();
|
repo.save(¬e).await.unwrap();
|
||||||
let found = repo.find_by_id(note_id).await.unwrap();
|
let found = repo.find_by_id(note_id).await.unwrap();
|
||||||
|
|
||||||
assert!(found.is_some());
|
assert!(found.is_some());
|
||||||
assert_eq!(found.unwrap().title, "Test Note");
|
assert_eq!(found.unwrap().title_str(), "Test Note");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -186,11 +188,13 @@ pub(crate) mod tests {
|
|||||||
let repo = MockNoteRepository::new();
|
let repo = MockNoteRepository::new();
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
let mut pinned_note = Note::new(user_id, "Pinned", "Content");
|
let title_pinned = NoteTitle::try_from("Pinned").ok();
|
||||||
|
let mut pinned_note = Note::new(user_id, title_pinned, "Content");
|
||||||
pinned_note.is_pinned = true;
|
pinned_note.is_pinned = true;
|
||||||
repo.save(&pinned_note).await.unwrap();
|
repo.save(&pinned_note).await.unwrap();
|
||||||
|
|
||||||
let regular_note = Note::new(user_id, "Regular", "Content");
|
let title_regular = NoteTitle::try_from("Regular").ok();
|
||||||
|
let regular_note = Note::new(user_id, title_regular, "Content");
|
||||||
repo.save(®ular_note).await.unwrap();
|
repo.save(®ular_note).await.unwrap();
|
||||||
|
|
||||||
let pinned_only = repo
|
let pinned_only = repo
|
||||||
@@ -199,7 +203,7 @@ pub(crate) mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(pinned_only.len(), 1);
|
assert_eq!(pinned_only.len(), 1);
|
||||||
assert_eq!(pinned_only[0].title, "Pinned");
|
assert_eq!(pinned_only[0].title_str(), "Pinned");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -207,17 +211,19 @@ pub(crate) mod tests {
|
|||||||
let repo = MockNoteRepository::new();
|
let repo = MockNoteRepository::new();
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
let note1 = Note::new(user_id, "Shopping List", "Buy milk and eggs");
|
let title1 = NoteTitle::try_from("Shopping List").ok();
|
||||||
let note2 = Note::new(user_id, "Meeting Notes", "Discuss project timeline");
|
let title2 = NoteTitle::try_from("Meeting Notes").ok();
|
||||||
|
let note1 = Note::new(user_id, title1, "Buy milk and eggs");
|
||||||
|
let note2 = Note::new(user_id, title2, "Discuss project timeline");
|
||||||
repo.save(¬e1).await.unwrap();
|
repo.save(¬e1).await.unwrap();
|
||||||
repo.save(¬e2).await.unwrap();
|
repo.save(¬e2).await.unwrap();
|
||||||
|
|
||||||
let results = repo.search(user_id, "milk").await.unwrap();
|
let results = repo.search(user_id, "milk").await.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].title, "Shopping List");
|
assert_eq!(results[0].title_str(), "Shopping List");
|
||||||
|
|
||||||
let results = repo.search(user_id, "notes").await.unwrap();
|
let results = repo.search(user_id, "notes").await.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].title, "Meeting Notes");
|
assert_eq!(results[0].title_str(), "Meeting Notes");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Domain services for K-Notes
|
//! Domain services for K-Notes
|
||||||
//!
|
//!
|
||||||
//! Services orchestrate business logic, enforce rules, and coordinate
|
//! Services orchestrate business logic, enforce rules, and coordinate
|
||||||
//! between repositories. They are the "use cases" of the application.
|
//! between repositories. They are the \"use cases\" of the application.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -10,14 +10,17 @@ use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, Use
|
|||||||
use crate::errors::{DomainError, DomainResult};
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::ports::MessageBroker;
|
use crate::ports::MessageBroker;
|
||||||
use crate::repositories::{NoteRepository, TagRepository, UserRepository};
|
use crate::repositories::{NoteRepository, TagRepository, UserRepository};
|
||||||
|
use crate::value_objects::{Email, NoteTitle, TagName};
|
||||||
|
|
||||||
/// Request to create a new note
|
/// Request to create a new note
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CreateNoteRequest {
|
pub struct CreateNoteRequest {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub title: String,
|
/// Title is optional - notes can have no title
|
||||||
|
pub title: Option<NoteTitle>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub tags: Vec<String>,
|
/// Tags are pre-validated TagName values
|
||||||
|
pub tags: Vec<TagName>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub is_pinned: bool,
|
pub is_pinned: bool,
|
||||||
}
|
}
|
||||||
@@ -27,12 +30,14 @@ pub struct CreateNoteRequest {
|
|||||||
pub struct UpdateNoteRequest {
|
pub struct UpdateNoteRequest {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid, // For authorization check
|
pub user_id: Uuid, // For authorization check
|
||||||
pub title: Option<String>,
|
/// None means "don't change", Some(None) means "remove title", Some(Some(t)) means "set title"
|
||||||
|
pub title: Option<Option<NoteTitle>>,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub is_pinned: Option<bool>,
|
pub is_pinned: Option<bool>,
|
||||||
pub is_archived: Option<bool>,
|
pub is_archived: Option<bool>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub tags: Option<Vec<String>>,
|
/// Pre-validated TagName values
|
||||||
|
pub tags: Option<Vec<TagName>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service for Note operations
|
/// Service for Note operations
|
||||||
@@ -70,10 +75,8 @@ impl NoteService {
|
|||||||
|
|
||||||
/// Create a new note with optional tags
|
/// Create a new note with optional tags
|
||||||
pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult<Note> {
|
pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult<Note> {
|
||||||
// Validate title is not empty
|
// Title validation is handled by NoteTitle type - no need for runtime check
|
||||||
if req.title.trim().is_empty() {
|
// Tags are pre-validated as TagName values
|
||||||
return Err(DomainError::validation("Title cannot be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tag count
|
// Validate tag count
|
||||||
if req.tags.len() > MAX_TAGS_PER_NOTE {
|
if req.tags.len() > MAX_TAGS_PER_NOTE {
|
||||||
@@ -89,7 +92,9 @@ impl NoteService {
|
|||||||
|
|
||||||
// Process tags
|
// Process tags
|
||||||
for tag_name in &req.tags {
|
for tag_name in &req.tags {
|
||||||
let tag = self.get_or_create_tag(req.user_id, tag_name).await?;
|
let tag = self
|
||||||
|
.get_or_create_tag(req.user_id, tag_name.clone())
|
||||||
|
.await?;
|
||||||
note.tags.push(tag);
|
note.tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +129,15 @@ impl NoteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create version snapshot (save current state)
|
// Create version snapshot (save current state)
|
||||||
let version = NoteVersion::new(note.id, note.title.clone(), note.content.clone());
|
let version = NoteVersion::new(
|
||||||
|
note.id,
|
||||||
|
note.title.as_ref().map(|t| t.as_ref().to_string()),
|
||||||
|
note.content.clone(),
|
||||||
|
);
|
||||||
self.note_repo.save_version(&version).await?;
|
self.note_repo.save_version(&version).await?;
|
||||||
|
|
||||||
// Apply updates
|
// Apply updates - title is already validated via NoteTitle type
|
||||||
if let Some(title) = req.title {
|
if let Some(title) = req.title {
|
||||||
if title.trim().is_empty() {
|
|
||||||
return Err(DomainError::validation("Title cannot be empty"));
|
|
||||||
}
|
|
||||||
note.set_title(title);
|
note.set_title(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,7 @@ impl NoteService {
|
|||||||
|
|
||||||
// Add new tags
|
// Add new tags
|
||||||
note.tags.clear();
|
note.tags.clear();
|
||||||
for tag_name in &tag_names {
|
for tag_name in tag_names {
|
||||||
let tag = self.get_or_create_tag(note.user_id, tag_name).await?;
|
let tag = self.get_or_create_tag(note.user_id, tag_name).await?;
|
||||||
self.tag_repo.add_to_note(tag.id, note.id).await?;
|
self.tag_repo.add_to_note(tag.id, note.id).await?;
|
||||||
note.tags.push(tag);
|
note.tags.push(tag);
|
||||||
@@ -247,11 +253,9 @@ impl NoteService {
|
|||||||
///
|
///
|
||||||
/// Handles race conditions gracefully: if a concurrent request creates
|
/// Handles race conditions gracefully: if a concurrent request creates
|
||||||
/// the same tag, we catch the unique constraint violation and retry the lookup.
|
/// the same tag, we catch the unique constraint violation and retry the lookup.
|
||||||
async fn get_or_create_tag(&self, user_id: Uuid, name: &str) -> DomainResult<Tag> {
|
async fn get_or_create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult<Tag> {
|
||||||
let name = name.trim().to_lowercase();
|
|
||||||
|
|
||||||
// First, try to find existing tag
|
// First, try to find existing tag
|
||||||
if let Some(tag) = self.tag_repo.find_by_name(user_id, &name).await? {
|
if let Some(tag) = self.tag_repo.find_by_name(user_id, name.as_ref()).await? {
|
||||||
return Ok(tag);
|
return Ok(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +268,7 @@ impl NoteService {
|
|||||||
// Retry the lookup
|
// Retry the lookup
|
||||||
tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup");
|
tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup");
|
||||||
self.tag_repo
|
self.tag_repo
|
||||||
.find_by_name(user_id, &name)
|
.find_by_name(user_id, name.as_ref())
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::validation("Tag creation race condition"))
|
.ok_or_else(|| DomainError::validation("Tag creation race condition"))
|
||||||
}
|
}
|
||||||
@@ -283,16 +287,16 @@ impl TagService {
|
|||||||
Self { tag_repo }
|
Self { tag_repo }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new tag
|
/// Create a new tag (TagName is pre-validated)
|
||||||
pub async fn create_tag(&self, user_id: Uuid, name: &str) -> DomainResult<Tag> {
|
pub async fn create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult<Tag> {
|
||||||
let name = name.trim().to_lowercase();
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(DomainError::validation("Tag name cannot be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tag already exists
|
// Check if tag already exists
|
||||||
if self.tag_repo.find_by_name(user_id, &name).await?.is_some() {
|
if self
|
||||||
return Err(DomainError::TagAlreadyExists(name));
|
.tag_repo
|
||||||
|
.find_by_name(user_id, name.as_ref())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(DomainError::TagAlreadyExists(name.into_inner()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tag = Tag::new(name, user_id);
|
let tag = Tag::new(name, user_id);
|
||||||
@@ -322,13 +326,13 @@ impl TagService {
|
|||||||
self.tag_repo.delete(id).await
|
self.tag_repo.delete(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename a tag
|
/// Rename a tag (new_name is pre-validated TagName)
|
||||||
pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult<Tag> {
|
pub async fn rename_tag(
|
||||||
let new_name = new_name.trim().to_lowercase();
|
&self,
|
||||||
if new_name.is_empty() {
|
id: Uuid,
|
||||||
return Err(DomainError::validation("Tag name cannot be empty"));
|
user_id: Uuid,
|
||||||
}
|
new_name: TagName,
|
||||||
|
) -> DomainResult<Tag> {
|
||||||
// Find the existing tag
|
// Find the existing tag
|
||||||
let mut tag = self
|
let mut tag = self
|
||||||
.tag_repo
|
.tag_repo
|
||||||
@@ -344,9 +348,13 @@ impl TagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if new name already exists (and it's not the same tag)
|
// Check if new name already exists (and it's not the same tag)
|
||||||
if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? {
|
if let Some(existing) = self
|
||||||
|
.tag_repo
|
||||||
|
.find_by_name(user_id, new_name.as_ref())
|
||||||
|
.await?
|
||||||
|
{
|
||||||
if existing.id != id {
|
if existing.id != id {
|
||||||
return Err(DomainError::TagAlreadyExists(new_name));
|
return Err(DomainError::TagAlreadyExists(new_name.into_inner()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +380,7 @@ impl UserService {
|
|||||||
pub async fn find_or_create_by_subject(
|
pub async fn find_or_create_by_subject(
|
||||||
&self,
|
&self,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
email: &str,
|
email: Email,
|
||||||
) -> DomainResult<User> {
|
) -> DomainResult<User> {
|
||||||
if let Some(user) = self.user_repo.find_by_subject(subject).await? {
|
if let Some(user) = self.user_repo.find_by_subject(subject).await? {
|
||||||
Ok(user)
|
Ok(user)
|
||||||
@@ -505,7 +513,7 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
.find(|t| t.user_id == user_id && t.name == name)
|
.find(|t| t.user_id == user_id && t.name.as_ref() == name)
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +582,7 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
.find(|u| u.email == email)
|
.find(|u| u.email_str() == email)
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,9 +611,10 @@ mod tests {
|
|||||||
async fn test_create_note_success() {
|
async fn test_create_note_success() {
|
||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
|
let title = NoteTitle::try_from("My Note").ok();
|
||||||
let req = CreateNoteRequest {
|
let req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "My Note".to_string(),
|
title,
|
||||||
content: "# Hello World".to_string(),
|
content: "# Hello World".to_string(),
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
color: None,
|
color: None,
|
||||||
@@ -614,7 +623,7 @@ mod tests {
|
|||||||
|
|
||||||
let note = service.create_note(req).await.unwrap();
|
let note = service.create_note(req).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(note.title, "My Note");
|
assert_eq!(note.title_str(), "My Note");
|
||||||
assert_eq!(note.content, "# Hello World");
|
assert_eq!(note.content, "# Hello World");
|
||||||
assert_eq!(note.user_id, user_id);
|
assert_eq!(note.user_id, user_id);
|
||||||
assert_eq!(note.color, "DEFAULT");
|
assert_eq!(note.color, "DEFAULT");
|
||||||
@@ -622,14 +631,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_note_with_tags() {
|
async fn test_create_note_without_title() {
|
||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
let req = CreateNoteRequest {
|
let req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "Tagged Note".to_string(),
|
title: None,
|
||||||
|
content: "Content without title".to_string(),
|
||||||
|
tags: vec![],
|
||||||
|
color: None,
|
||||||
|
is_pinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = service.create_note(req).await.unwrap();
|
||||||
|
|
||||||
|
assert!(note.title.is_none());
|
||||||
|
assert_eq!(note.title_str(), "");
|
||||||
|
assert_eq!(note.content, "Content without title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_note_with_tags() {
|
||||||
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
|
let title = NoteTitle::try_from("Tagged Note").ok();
|
||||||
|
let tags = vec![
|
||||||
|
TagName::try_from("work").unwrap(),
|
||||||
|
TagName::try_from("important").unwrap(),
|
||||||
|
];
|
||||||
|
let req = CreateNoteRequest {
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
content: "Content".to_string(),
|
content: "Content".to_string(),
|
||||||
tags: vec!["work".to_string(), "important".to_string()],
|
tags,
|
||||||
color: None,
|
color: None,
|
||||||
is_pinned: false,
|
is_pinned: false,
|
||||||
};
|
};
|
||||||
@@ -637,38 +671,22 @@ mod tests {
|
|||||||
let note = service.create_note(req).await.unwrap();
|
let note = service.create_note(req).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(note.tags.len(), 2);
|
assert_eq!(note.tags.len(), 2);
|
||||||
assert!(note.tags.iter().any(|t| t.name == "work"));
|
assert!(note.tags.iter().any(|t| t.name_str() == "work"));
|
||||||
assert!(note.tags.iter().any(|t| t.name == "important"));
|
assert!(note.tags.iter().any(|t| t.name_str() == "important"));
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_note_empty_title_fails() {
|
|
||||||
let (service, user_id) = create_note_service();
|
|
||||||
|
|
||||||
let req = CreateNoteRequest {
|
|
||||||
user_id,
|
|
||||||
title: " ".to_string(), // Whitespace only
|
|
||||||
content: "Content".to_string(),
|
|
||||||
tags: vec![],
|
|
||||||
color: None,
|
|
||||||
is_pinned: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = service.create_note(req).await;
|
|
||||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_note_too_many_tags_fails() {
|
async fn test_create_note_too_many_tags_fails() {
|
||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
let tags: Vec<String> = (0..=MAX_TAGS_PER_NOTE)
|
let tags: Vec<TagName> = (0..=MAX_TAGS_PER_NOTE)
|
||||||
.map(|i| format!("tag-{}", i))
|
.map(|i| TagName::try_from(format!("tag-{}", i)).unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let title = NoteTitle::try_from("Note").ok();
|
||||||
let req = CreateNoteRequest {
|
let req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "Note".to_string(),
|
title,
|
||||||
content: "Content".to_string(),
|
content: "Content".to_string(),
|
||||||
tags,
|
tags,
|
||||||
color: None,
|
color: None,
|
||||||
@@ -684,9 +702,10 @@ mod tests {
|
|||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
// Create a note first
|
// Create a note first
|
||||||
|
let title = NoteTitle::try_from("Original").ok();
|
||||||
let create_req = CreateNoteRequest {
|
let create_req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "Original".to_string(),
|
title,
|
||||||
content: "Original content".to_string(),
|
content: "Original content".to_string(),
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
color: None,
|
color: None,
|
||||||
@@ -695,10 +714,11 @@ mod tests {
|
|||||||
let note = service.create_note(create_req).await.unwrap();
|
let note = service.create_note(create_req).await.unwrap();
|
||||||
|
|
||||||
// Update it
|
// Update it
|
||||||
|
let new_title = NoteTitle::try_from("Updated").ok();
|
||||||
let update_req = UpdateNoteRequest {
|
let update_req = UpdateNoteRequest {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
user_id,
|
user_id,
|
||||||
title: Some("Updated".to_string()),
|
title: Some(new_title),
|
||||||
content: None,
|
content: None,
|
||||||
is_pinned: Some(true),
|
is_pinned: Some(true),
|
||||||
is_archived: None,
|
is_archived: None,
|
||||||
@@ -707,7 +727,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let updated = service.update_note(update_req).await.unwrap();
|
let updated = service.update_note(update_req).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(updated.title, "Updated");
|
assert_eq!(updated.title_str(), "Updated");
|
||||||
assert_eq!(updated.content, "Original content"); // Unchanged
|
assert_eq!(updated.content, "Original content"); // Unchanged
|
||||||
assert!(updated.is_pinned);
|
assert!(updated.is_pinned);
|
||||||
assert_eq!(updated.color, "red");
|
assert_eq!(updated.color, "red");
|
||||||
@@ -719,9 +739,10 @@ mod tests {
|
|||||||
let other_user = Uuid::new_v4();
|
let other_user = Uuid::new_v4();
|
||||||
|
|
||||||
// Create a note
|
// Create a note
|
||||||
|
let title = NoteTitle::try_from("My Note").ok();
|
||||||
let create_req = CreateNoteRequest {
|
let create_req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "My Note".to_string(),
|
title,
|
||||||
content: "Content".to_string(),
|
content: "Content".to_string(),
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
color: None,
|
color: None,
|
||||||
@@ -730,10 +751,11 @@ mod tests {
|
|||||||
let note = service.create_note(create_req).await.unwrap();
|
let note = service.create_note(create_req).await.unwrap();
|
||||||
|
|
||||||
// Try to update with different user
|
// Try to update with different user
|
||||||
|
let new_title = NoteTitle::try_from("Hacked").ok();
|
||||||
let update_req = UpdateNoteRequest {
|
let update_req = UpdateNoteRequest {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
user_id: other_user,
|
user_id: other_user,
|
||||||
title: Some("Hacked".to_string()),
|
title: Some(new_title),
|
||||||
content: None,
|
content: None,
|
||||||
is_pinned: None,
|
is_pinned: None,
|
||||||
is_archived: None,
|
is_archived: None,
|
||||||
@@ -749,9 +771,10 @@ mod tests {
|
|||||||
async fn test_delete_note_success() {
|
async fn test_delete_note_success() {
|
||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
|
let title = NoteTitle::try_from("To Delete").ok();
|
||||||
let create_req = CreateNoteRequest {
|
let create_req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "To Delete".to_string(),
|
title,
|
||||||
content: "Content".to_string(),
|
content: "Content".to_string(),
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
color: None,
|
color: None,
|
||||||
@@ -772,14 +795,16 @@ mod tests {
|
|||||||
let results = service.search_notes(user_id, " ").await.unwrap();
|
let results = service.search_notes(user_id, " ").await.unwrap();
|
||||||
assert!(results.is_empty());
|
assert!(results.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_note_creates_version() {
|
async fn test_update_note_creates_version() {
|
||||||
let (service, user_id) = create_note_service();
|
let (service, user_id) = create_note_service();
|
||||||
|
|
||||||
// Create original note
|
// Create original note
|
||||||
|
let title = NoteTitle::try_from("Original Title").ok();
|
||||||
let create_req = CreateNoteRequest {
|
let create_req = CreateNoteRequest {
|
||||||
user_id,
|
user_id,
|
||||||
title: "Original Title".to_string(),
|
title,
|
||||||
content: "Original Content".to_string(),
|
content: "Original Content".to_string(),
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
color: None,
|
color: None,
|
||||||
@@ -788,10 +813,11 @@ mod tests {
|
|||||||
let note = service.create_note(create_req).await.unwrap();
|
let note = service.create_note(create_req).await.unwrap();
|
||||||
|
|
||||||
// Update the note
|
// Update the note
|
||||||
|
let new_title = NoteTitle::try_from("New Title").ok();
|
||||||
let update_req = UpdateNoteRequest {
|
let update_req = UpdateNoteRequest {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
user_id,
|
user_id,
|
||||||
title: Some("New Title".to_string()),
|
title: Some(new_title),
|
||||||
content: Some("New Content".to_string()),
|
content: Some("New Content".to_string()),
|
||||||
is_pinned: None,
|
is_pinned: None,
|
||||||
is_archived: None,
|
is_archived: None,
|
||||||
@@ -809,7 +835,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(versions.len(), 1);
|
assert_eq!(versions.len(), 1);
|
||||||
let version = &versions[0];
|
let version = &versions[0];
|
||||||
assert_eq!(version.title, "Original Title");
|
assert_eq!(version.title, Some("Original Title".to_string()));
|
||||||
assert_eq!(version.content, "Original Content");
|
assert_eq!(version.content, "Original Content");
|
||||||
assert_eq!(version.note_id, note.id);
|
assert_eq!(version.note_id, note.id);
|
||||||
}
|
}
|
||||||
@@ -828,26 +854,22 @@ mod tests {
|
|||||||
async fn test_create_tag_success() {
|
async fn test_create_tag_success() {
|
||||||
let (service, user_id) = create_tag_service();
|
let (service, user_id) = create_tag_service();
|
||||||
|
|
||||||
let tag = service.create_tag(user_id, "Work").await.unwrap();
|
let name = TagName::try_from("Work").unwrap();
|
||||||
|
let tag = service.create_tag(user_id, name).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(tag.name, "work"); // Lowercase
|
assert_eq!(tag.name_str(), "work"); // Lowercase
|
||||||
assert_eq!(tag.user_id, user_id);
|
assert_eq!(tag.user_id, user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_tag_empty_fails() {
|
|
||||||
let (service, user_id) = create_tag_service();
|
|
||||||
|
|
||||||
let result = service.create_tag(user_id, " ").await;
|
|
||||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_duplicate_tag_fails() {
|
async fn test_create_duplicate_tag_fails() {
|
||||||
let (service, user_id) = create_tag_service();
|
let (service, user_id) = create_tag_service();
|
||||||
|
|
||||||
service.create_tag(user_id, "work").await.unwrap();
|
let name1 = TagName::try_from("work").unwrap();
|
||||||
let result = service.create_tag(user_id, "WORK").await; // Case-insensitive
|
service.create_tag(user_id, name1).await.unwrap();
|
||||||
|
|
||||||
|
let name2 = TagName::try_from("WORK").unwrap(); // Case-insensitive
|
||||||
|
let result = service.create_tag(user_id, name2).await;
|
||||||
|
|
||||||
assert!(matches!(result, Err(DomainError::TagAlreadyExists(_))));
|
assert!(matches!(result, Err(DomainError::TagAlreadyExists(_))));
|
||||||
}
|
}
|
||||||
@@ -865,26 +887,29 @@ mod tests {
|
|||||||
async fn test_find_or_create_creates_new_user() {
|
async fn test_find_or_create_creates_new_user() {
|
||||||
let service = create_user_service();
|
let service = create_user_service();
|
||||||
|
|
||||||
|
let email = Email::try_from("test@example.com").unwrap();
|
||||||
let user = service
|
let user = service
|
||||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
.find_or_create_by_subject("oidc|123", email)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(user.subject, "oidc|123");
|
assert_eq!(user.subject, "oidc|123");
|
||||||
assert_eq!(user.email, "test@example.com");
|
assert_eq!(user.email_str(), "test@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_find_or_create_returns_existing_user() {
|
async fn test_find_or_create_returns_existing_user() {
|
||||||
let service = create_user_service();
|
let service = create_user_service();
|
||||||
|
|
||||||
|
let email1 = Email::try_from("test@example.com").unwrap();
|
||||||
let user1 = service
|
let user1 = service
|
||||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
.find_or_create_by_subject("oidc|123", email1)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
let email2 = Email::try_from("test@example.com").unwrap();
|
||||||
let user2 = service
|
let user2 = service
|
||||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
.find_or_create_by_subject("oidc|123", email2)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
496
notes-domain/src/value_objects.rs
Normal file
496
notes-domain/src/value_objects.rs
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
//! Value Objects for K-Notes Domain
|
||||||
|
//!
|
||||||
|
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
|
||||||
|
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Error
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Errors that occur when parsing/validating value objects
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ValidationError {
|
||||||
|
#[error("Invalid email format: {0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
|
#[error("Password must be at least {min} characters, got {actual}")]
|
||||||
|
PasswordTooShort { min: usize, actual: usize },
|
||||||
|
|
||||||
|
#[error("Tag name must be 1-{max} characters, got {actual}")]
|
||||||
|
InvalidTagNameLength { max: usize, actual: usize },
|
||||||
|
|
||||||
|
#[error("Tag name cannot be empty")]
|
||||||
|
EmptyTagName,
|
||||||
|
|
||||||
|
#[error("Note title cannot exceed {max} characters, got {actual}")]
|
||||||
|
TitleTooLong { max: usize, actual: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A validated email address.
|
||||||
|
///
|
||||||
|
/// Simple validation: must contain exactly one `@` with non-empty parts on both sides.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Email(String);
|
||||||
|
|
||||||
|
impl Email {
|
||||||
|
/// Minimum validation: contains @ with non-empty local and domain parts
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
|
let value = value.into();
|
||||||
|
let trimmed = value.trim().to_lowercase();
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
let parts: Vec<&str> = trimmed.split('@').collect();
|
||||||
|
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||||
|
return Err(ValidationError::InvalidEmail(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain must contain at least one dot
|
||||||
|
if !parts[1].contains('.') {
|
||||||
|
return Err(ValidationError::InvalidEmail(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the inner value
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Email {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Email {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Email {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Email {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Email {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Email {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Self::new(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Password
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A validated password input (NOT the hash).
|
||||||
|
///
|
||||||
|
/// Enforces minimum length of 6 characters.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct Password(String);
|
||||||
|
|
||||||
|
/// Minimum password length
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 6;
|
||||||
|
|
||||||
|
impl Password {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
|
let value = value.into();
|
||||||
|
|
||||||
|
if value.len() < MIN_PASSWORD_LENGTH {
|
||||||
|
return Err(ValidationError::PasswordTooShort {
|
||||||
|
min: MIN_PASSWORD_LENGTH,
|
||||||
|
actual: value.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Password {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentionally hide password content in Debug
|
||||||
|
impl fmt::Debug for Password {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Password(***)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Password {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Password {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Password {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Self::new(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TagName
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A validated tag name.
|
||||||
|
///
|
||||||
|
/// Enforces: 1-50 characters, trimmed and lowercase.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct TagName(String);
|
||||||
|
|
||||||
|
/// Maximum tag name length
|
||||||
|
pub const MAX_TAG_NAME_LENGTH: usize = 50;
|
||||||
|
|
||||||
|
impl TagName {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
|
let value = value.into();
|
||||||
|
let trimmed = value.trim().to_lowercase();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ValidationError::EmptyTagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.len() > MAX_TAG_NAME_LENGTH {
|
||||||
|
return Err(ValidationError::InvalidTagNameLength {
|
||||||
|
max: MAX_TAG_NAME_LENGTH,
|
||||||
|
actual: trimmed.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for TagName {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TagName {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for TagName {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for TagName {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for TagName {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TagName {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Self::new(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NoteTitle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A validated note title.
|
||||||
|
///
|
||||||
|
/// Enforces: maximum 200 characters when present. Trimmed but preserves case.
|
||||||
|
/// Note: This is for the inner value; the title on a Note is Option<NoteTitle>.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NoteTitle(String);
|
||||||
|
|
||||||
|
/// Maximum note title length
|
||||||
|
pub const MAX_NOTE_TITLE_LENGTH: usize = 200;
|
||||||
|
|
||||||
|
impl NoteTitle {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
|
let value = value.into();
|
||||||
|
let trimmed = value.trim().to_string();
|
||||||
|
|
||||||
|
if trimmed.len() > MAX_NOTE_TITLE_LENGTH {
|
||||||
|
return Err(ValidationError::TitleTooLong {
|
||||||
|
max: MAX_NOTE_TITLE_LENGTH,
|
||||||
|
actual: trimmed.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow empty strings - this becomes None at the Note level
|
||||||
|
Ok(Self(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from optional string, returning None for empty/whitespace
|
||||||
|
pub fn from_optional(value: Option<String>) -> Result<Option<Self>, ValidationError> {
|
||||||
|
match value {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(s) => {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Self::new(trimmed).map(Some)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the title is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for NoteTitle {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NoteTitle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for NoteTitle {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for NoteTitle {
|
||||||
|
type Error = ValidationError;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for NoteTitle {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for NoteTitle {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Self::new(s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
mod email_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_email() {
|
||||||
|
assert!(Email::new("user@example.com").is_ok());
|
||||||
|
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
|
||||||
|
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_normalizes() {
|
||||||
|
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||||
|
assert_eq!(email.as_ref(), "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email_no_at() {
|
||||||
|
assert!(Email::new("userexample.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email_no_domain() {
|
||||||
|
assert!(Email::new("user@").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email_no_local() {
|
||||||
|
assert!(Email::new("@example.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email_no_dot_in_domain() {
|
||||||
|
assert!(Email::new("user@localhost").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod password_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_password() {
|
||||||
|
assert!(Password::new("secret123").is_ok());
|
||||||
|
assert!(Password::new("123456").is_ok()); // Exactly 6 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_password_too_short() {
|
||||||
|
assert!(Password::new("12345").is_err()); // 5 chars
|
||||||
|
assert!(Password::new("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_password_debug_hides_content() {
|
||||||
|
let password = Password::new("supersecret").unwrap();
|
||||||
|
let debug = format!("{:?}", password);
|
||||||
|
assert!(!debug.contains("supersecret"));
|
||||||
|
assert!(debug.contains("***"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod tag_name_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_tag_name() {
|
||||||
|
assert!(TagName::new("work").is_ok());
|
||||||
|
assert!(TagName::new(" WORK ").is_ok()); // Should trim and lowercase
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tag_name_normalizes() {
|
||||||
|
let tag = TagName::new(" Important ").unwrap();
|
||||||
|
assert_eq!(tag.as_ref(), "important");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_tag_name_fails() {
|
||||||
|
assert!(TagName::new("").is_err());
|
||||||
|
assert!(TagName::new(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tag_name_max_length() {
|
||||||
|
let long_name = "a".repeat(MAX_TAG_NAME_LENGTH);
|
||||||
|
assert!(TagName::new(&long_name).is_ok());
|
||||||
|
|
||||||
|
let too_long = "a".repeat(MAX_TAG_NAME_LENGTH + 1);
|
||||||
|
assert!(TagName::new(&too_long).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod note_title_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_title() {
|
||||||
|
assert!(NoteTitle::new("My Note").is_ok());
|
||||||
|
assert!(NoteTitle::new("").is_ok()); // Empty is valid for NoteTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_title_trims() {
|
||||||
|
let title = NoteTitle::new(" My Note ").unwrap();
|
||||||
|
assert_eq!(title.as_ref(), "My Note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_title_max_length() {
|
||||||
|
let long_title = "a".repeat(MAX_NOTE_TITLE_LENGTH);
|
||||||
|
assert!(NoteTitle::new(&long_title).is_ok());
|
||||||
|
|
||||||
|
let too_long = "a".repeat(MAX_NOTE_TITLE_LENGTH + 1);
|
||||||
|
assert!(NoteTitle::new(&too_long).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_optional_none() {
|
||||||
|
assert_eq!(NoteTitle::from_optional(None).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_optional_empty() {
|
||||||
|
assert_eq!(NoteTitle::from_optional(Some("".into())).unwrap(), None);
|
||||||
|
assert_eq!(NoteTitle::from_optional(Some(" ".into())).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_optional_valid() {
|
||||||
|
let result = NoteTitle::from_optional(Some("My Note".into())).unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert_eq!(result.unwrap().as_ref(), "My Note");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["sqlite", "smart-features"]
|
default = ["sqlite", "smart-features", "broker-nats"]
|
||||||
sqlite = ["sqlx/sqlite", "tower-sessions-sqlx-store/sqlite"]
|
sqlite = ["sqlx/sqlite", "tower-sessions-sqlx-store/sqlite"]
|
||||||
postgres = ["sqlx/postgres", "tower-sessions-sqlx-store/postgres"]
|
postgres = ["sqlx/postgres", "tower-sessions-sqlx-store/postgres"]
|
||||||
smart-features = ["dep:fastembed", "dep:qdrant-client"]
|
smart-features = ["dep:fastembed", "dep:qdrant-client"]
|
||||||
|
broker-nats = ["dep:async-nats", "dep:futures-util"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notes-domain = { path = "../notes-domain" }
|
notes-domain = { path = "../notes-domain" }
|
||||||
@@ -23,3 +24,7 @@ tower-sessions-sqlx-store = { version = "0.15.0", default-features = false }
|
|||||||
fastembed = { version = "5.4", optional = true }
|
fastembed = { version = "5.4", optional = true }
|
||||||
qdrant-client = { version = "1.16", optional = true }
|
qdrant-client = { version = "1.16", optional = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
async-nats = { version = "0.45", optional = true }
|
||||||
|
futures-util = { version = "0.3", optional = true }
|
||||||
|
futures-core = "0.3"
|
||||||
|
|||||||
7
notes-infra/src/broker/mod.rs
Normal file
7
notes-infra/src/broker/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! Message broker adapters for various backends.
|
||||||
|
//!
|
||||||
|
//! This module provides implementations of the `MessageBroker` port
|
||||||
|
//! for different messaging backends.
|
||||||
|
|
||||||
|
#[cfg(feature = "broker-nats")]
|
||||||
|
pub mod nats;
|
||||||
66
notes-infra/src/broker/nats.rs
Normal file
66
notes-infra/src/broker/nats.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! NATS message broker adapter
|
||||||
|
//!
|
||||||
|
//! Implements the `MessageBroker` port for NATS messaging.
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use notes_domain::{DomainError, DomainResult, MessageBroker, Note};
|
||||||
|
|
||||||
|
/// NATS adapter implementing the MessageBroker port.
|
||||||
|
pub struct NatsMessageBroker {
|
||||||
|
client: async_nats::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsMessageBroker {
|
||||||
|
/// Create a new NATS message broker by connecting to the given URL.
|
||||||
|
pub async fn connect(url: &str) -> Result<Self, async_nats::ConnectError> {
|
||||||
|
let client = async_nats::connect(url).await?;
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a NATS message broker from an existing client.
|
||||||
|
pub fn from_client(client: async_nats::Client) -> Self {
|
||||||
|
Self { client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MessageBroker for NatsMessageBroker {
|
||||||
|
async fn publish_note_updated(&self, note: &Note) -> DomainResult<()> {
|
||||||
|
let payload = serde_json::to_vec(note).map_err(|e| {
|
||||||
|
DomainError::RepositoryError(format!("Failed to serialize note: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.publish("notes.updated", payload.into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Failed to publish event: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe_note_updates(
|
||||||
|
&self,
|
||||||
|
) -> DomainResult<Pin<Box<dyn futures_core::Stream<Item = Note> + Send>>> {
|
||||||
|
let subscriber = self
|
||||||
|
.client
|
||||||
|
.subscribe("notes.updated")
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Failed to subscribe: {}", e)))?;
|
||||||
|
|
||||||
|
// Transform the NATS message stream into a Note stream
|
||||||
|
let note_stream = subscriber.filter_map(|msg| async move {
|
||||||
|
match serde_json::from_slice::<Note>(&msg.payload) {
|
||||||
|
Ok(note) => Some(note),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to deserialize note from message: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Box::pin(note_stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,37 @@ pub async fn build_vector_store(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for message broker providers.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BrokerProvider {
|
||||||
|
/// NATS message broker (requires `broker-nats` feature).
|
||||||
|
#[cfg(feature = "broker-nats")]
|
||||||
|
Nats { url: String },
|
||||||
|
/// No message broker (messaging disabled).
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a message broker based on the provider configuration.
|
||||||
|
/// Returns `None` if `BrokerProvider::None` is specified.
|
||||||
|
pub async fn build_message_broker(
|
||||||
|
provider: &BrokerProvider,
|
||||||
|
) -> FactoryResult<Option<Arc<dyn notes_domain::MessageBroker>>> {
|
||||||
|
match provider {
|
||||||
|
#[cfg(feature = "broker-nats")]
|
||||||
|
BrokerProvider::Nats { url } => {
|
||||||
|
let broker = crate::broker::nats::NatsMessageBroker::connect(url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
FactoryError::Infrastructure(notes_domain::DomainError::RepositoryError(
|
||||||
|
format!("NATS connection failed: {}", e),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Some(Arc::new(broker)))
|
||||||
|
}
|
||||||
|
BrokerProvider::None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
pub async fn build_link_repository(
|
pub async fn build_link_repository(
|
||||||
pool: &DatabasePool,
|
pool: &DatabasePool,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
//! - [`db::create_pool`] - Create a database connection pool
|
//! - [`db::create_pool`] - Create a database connection pool
|
||||||
//! - [`db::run_migrations`] - Run database migrations
|
//! - [`db::run_migrations`] - Run database migrations
|
||||||
|
|
||||||
|
#[cfg(feature = "broker-nats")]
|
||||||
|
pub mod broker;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
#[cfg(feature = "smart-features")]
|
#[cfg(feature = "smart-features")]
|
||||||
pub mod embeddings;
|
pub mod embeddings;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use notes_domain::{DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag};
|
use notes_domain::{
|
||||||
|
DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteTitle, NoteVersion, Tag,
|
||||||
|
TagName,
|
||||||
|
};
|
||||||
|
|
||||||
/// SQLite adapter for NoteRepository
|
/// SQLite adapter for NoteRepository
|
||||||
pub struct SqliteNoteRepository {
|
pub struct SqliteNoteRepository {
|
||||||
@@ -23,7 +26,7 @@ impl SqliteNoteRepository {
|
|||||||
struct NoteRowWithTags {
|
struct NoteRowWithTags {
|
||||||
id: String,
|
id: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
title: String,
|
title: Option<String>, // Title can be NULL in the database
|
||||||
content: String,
|
content: String,
|
||||||
color: String,
|
color: String,
|
||||||
is_pinned: i32,
|
is_pinned: i32,
|
||||||
@@ -68,7 +71,12 @@ fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, DomainError> {
|
|||||||
let user_id = Uuid::parse_str(user_id_str)
|
let user_id = Uuid::parse_str(user_id_str)
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag user_id: {}", e)))?;
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag user_id: {}", e)))?;
|
||||||
|
|
||||||
Ok(Tag::with_id(id, name.to_string(), user_id))
|
// Parse TagName from stored string
|
||||||
|
let tag_name = TagName::try_from(name.to_string()).map_err(|e| {
|
||||||
|
DomainError::RepositoryError(format!("Invalid tag name in DB: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Tag::with_id(id, tag_name, user_id))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -84,10 +92,18 @@ impl NoteRowWithTags {
|
|||||||
let updated_at = parse_datetime(&self.updated_at)?;
|
let updated_at = parse_datetime(&self.updated_at)?;
|
||||||
let tags = parse_tags_json(&self.tags_json)?;
|
let tags = parse_tags_json(&self.tags_json)?;
|
||||||
|
|
||||||
|
// Parse optional title - empty string or NULL maps to None
|
||||||
|
let title: Option<NoteTitle> = match self.title {
|
||||||
|
Some(t) if !t.trim().is_empty() => Some(NoteTitle::try_from(t).map_err(|e| {
|
||||||
|
DomainError::RepositoryError(format!("Invalid title in DB: {}", e))
|
||||||
|
})?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Note {
|
Ok(Note {
|
||||||
id,
|
id,
|
||||||
user_id,
|
user_id,
|
||||||
title: self.title,
|
title,
|
||||||
content: self.content,
|
content: self.content,
|
||||||
color: self.color,
|
color: self.color,
|
||||||
is_pinned: self.is_pinned != 0,
|
is_pinned: self.is_pinned != 0,
|
||||||
@@ -103,7 +119,7 @@ impl NoteRowWithTags {
|
|||||||
struct NoteVersionRow {
|
struct NoteVersionRow {
|
||||||
id: String,
|
id: String,
|
||||||
note_id: String,
|
note_id: String,
|
||||||
title: String,
|
title: Option<String>, // Title can be NULL
|
||||||
content: String,
|
content: String,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
}
|
}
|
||||||
@@ -126,7 +142,7 @@ impl NoteVersionRow {
|
|||||||
Ok(NoteVersion {
|
Ok(NoteVersion {
|
||||||
id,
|
id,
|
||||||
note_id,
|
note_id,
|
||||||
title: self.title,
|
title: self.title, // Already Option<String>
|
||||||
content: self.content,
|
content: self.content,
|
||||||
created_at,
|
created_at,
|
||||||
})
|
})
|
||||||
@@ -222,6 +238,8 @@ impl NoteRepository for SqliteNoteRepository {
|
|||||||
let is_archived: i32 = if note.is_archived { 1 } else { 0 };
|
let is_archived: i32 = if note.is_archived { 1 } else { 0 };
|
||||||
let created_at = note.created_at.to_rfc3339();
|
let created_at = note.created_at.to_rfc3339();
|
||||||
let updated_at = note.updated_at.to_rfc3339();
|
let updated_at = note.updated_at.to_rfc3339();
|
||||||
|
// Convert Option<NoteTitle> to Option<&str> for binding
|
||||||
|
let title_str: Option<&str> = note.title.as_ref().map(|t| t.as_ref());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -238,7 +256,7 @@ impl NoteRepository for SqliteNoteRepository {
|
|||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&user_id)
|
.bind(&user_id)
|
||||||
.bind(¬e.title)
|
.bind(title_str)
|
||||||
.bind(¬e.content)
|
.bind(¬e.content)
|
||||||
.bind(¬e.color)
|
.bind(¬e.color)
|
||||||
.bind(is_pinned)
|
.bind(is_pinned)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
|||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, SqlitePool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use notes_domain::{DomainError, DomainResult, Tag, TagRepository};
|
use notes_domain::{DomainError, DomainResult, Tag, TagName, TagRepository};
|
||||||
|
|
||||||
/// SQLite adapter for TagRepository
|
/// SQLite adapter for TagRepository
|
||||||
pub struct SqliteTagRepository {
|
pub struct SqliteTagRepository {
|
||||||
@@ -33,7 +33,11 @@ impl TryFrom<TagRow> for Tag {
|
|||||||
let user_id = Uuid::parse_str(&row.user_id)
|
let user_id = Uuid::parse_str(&row.user_id)
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
|
||||||
|
|
||||||
Ok(Tag::with_id(id, row.name, user_id))
|
// Parse TagName from stored string - was validated when originally stored
|
||||||
|
let name = TagName::try_from(row.name)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag name in DB: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Tag::with_id(id, name, user_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ impl TagRepository for SqliteTagRepository {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&tag.name)
|
.bind(tag.name.as_ref()) // Use .as_ref() to get the inner &str
|
||||||
.bind(&user_id)
|
.bind(&user_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -160,7 +164,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations};
|
use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations};
|
||||||
use crate::user_repository::SqliteUserRepository;
|
use crate::user_repository::SqliteUserRepository;
|
||||||
use notes_domain::{User, UserRepository};
|
use notes_domain::{Email, User, UserRepository};
|
||||||
|
|
||||||
async fn setup_test_db() -> SqlitePool {
|
async fn setup_test_db() -> SqlitePool {
|
||||||
let config = DatabaseConfig::in_memory();
|
let config = DatabaseConfig::in_memory();
|
||||||
@@ -172,7 +176,8 @@ mod tests {
|
|||||||
|
|
||||||
async fn create_test_user(pool: &SqlitePool) -> User {
|
async fn create_test_user(pool: &SqlitePool) -> User {
|
||||||
let user_repo = SqliteUserRepository::new(pool.clone());
|
let user_repo = SqliteUserRepository::new(pool.clone());
|
||||||
let user = User::new("test|user", "test@example.com");
|
let email = Email::try_from("test@example.com").unwrap();
|
||||||
|
let user = User::new("test|user", email);
|
||||||
user_repo.save(&user).await.unwrap();
|
user_repo.save(&user).await.unwrap();
|
||||||
user
|
user
|
||||||
}
|
}
|
||||||
@@ -183,12 +188,13 @@ mod tests {
|
|||||||
let user = create_test_user(&pool).await;
|
let user = create_test_user(&pool).await;
|
||||||
let repo = SqliteTagRepository::new(pool);
|
let repo = SqliteTagRepository::new(pool);
|
||||||
|
|
||||||
let tag = Tag::new("work", user.id);
|
let name = TagName::try_from("work").unwrap();
|
||||||
|
let tag = Tag::new(name, user.id);
|
||||||
repo.save(&tag).await.unwrap();
|
repo.save(&tag).await.unwrap();
|
||||||
|
|
||||||
let found = repo.find_by_id(tag.id).await.unwrap();
|
let found = repo.find_by_id(tag.id).await.unwrap();
|
||||||
assert!(found.is_some());
|
assert!(found.is_some());
|
||||||
assert_eq!(found.unwrap().name, "work");
|
assert_eq!(found.unwrap().name_str(), "work");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -197,7 +203,8 @@ mod tests {
|
|||||||
let user = create_test_user(&pool).await;
|
let user = create_test_user(&pool).await;
|
||||||
let repo = SqliteTagRepository::new(pool);
|
let repo = SqliteTagRepository::new(pool);
|
||||||
|
|
||||||
let tag = Tag::new("important", user.id);
|
let name = TagName::try_from("important").unwrap();
|
||||||
|
let tag = Tag::new(name, user.id);
|
||||||
repo.save(&tag).await.unwrap();
|
repo.save(&tag).await.unwrap();
|
||||||
|
|
||||||
let found = repo.find_by_name(user.id, "important").await.unwrap();
|
let found = repo.find_by_name(user.id, "important").await.unwrap();
|
||||||
@@ -211,13 +218,15 @@ mod tests {
|
|||||||
let user = create_test_user(&pool).await;
|
let user = create_test_user(&pool).await;
|
||||||
let repo = SqliteTagRepository::new(pool);
|
let repo = SqliteTagRepository::new(pool);
|
||||||
|
|
||||||
repo.save(&Tag::new("alpha", user.id)).await.unwrap();
|
let name_alpha = TagName::try_from("alpha").unwrap();
|
||||||
repo.save(&Tag::new("beta", user.id)).await.unwrap();
|
let name_beta = TagName::try_from("beta").unwrap();
|
||||||
|
repo.save(&Tag::new(name_alpha, user.id)).await.unwrap();
|
||||||
|
repo.save(&Tag::new(name_beta, user.id)).await.unwrap();
|
||||||
|
|
||||||
let tags = repo.find_by_user(user.id).await.unwrap();
|
let tags = repo.find_by_user(user.id).await.unwrap();
|
||||||
assert_eq!(tags.len(), 2);
|
assert_eq!(tags.len(), 2);
|
||||||
// Should be sorted alphabetically
|
// Should be sorted alphabetically
|
||||||
assert_eq!(tags[0].name, "alpha");
|
assert_eq!(tags[0].name_str(), "alpha");
|
||||||
assert_eq!(tags[1].name, "beta");
|
assert_eq!(tags[1].name_str(), "beta");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, SqlitePool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use notes_domain::{DomainError, DomainResult, User, UserRepository};
|
use notes_domain::{DomainError, DomainResult, Email, User, UserRepository};
|
||||||
|
|
||||||
/// SQLite adapter for UserRepository
|
/// SQLite adapter for UserRepository
|
||||||
pub struct SqliteUserRepository {
|
pub struct SqliteUserRepository {
|
||||||
@@ -43,10 +43,14 @@ impl TryFrom<UserRow> for User {
|
|||||||
})
|
})
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
|
||||||
|
|
||||||
|
// Parse email from string - it was validated when originally stored
|
||||||
|
let email = Email::try_from(row.email)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
|
||||||
|
|
||||||
Ok(User::with_id(
|
Ok(User::with_id(
|
||||||
id,
|
id,
|
||||||
row.subject,
|
row.subject,
|
||||||
row.email,
|
email,
|
||||||
row.password_hash,
|
row.password_hash,
|
||||||
created_at,
|
created_at,
|
||||||
))
|
))
|
||||||
@@ -108,7 +112,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&user.subject)
|
.bind(&user.subject)
|
||||||
.bind(&user.email)
|
.bind(user.email.as_ref()) // Use .as_ref() to get the inner &str
|
||||||
.bind(&user.password_hash)
|
.bind(&user.password_hash)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -148,14 +152,15 @@ mod tests {
|
|||||||
let pool = setup_test_db().await;
|
let pool = setup_test_db().await;
|
||||||
let repo = SqliteUserRepository::new(pool);
|
let repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
let user = User::new("oidc|123", "test@example.com");
|
let email = Email::try_from("test@example.com").unwrap();
|
||||||
|
let user = User::new("oidc|123", email);
|
||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
|
|
||||||
let found = repo.find_by_id(user.id).await.unwrap();
|
let found = repo.find_by_id(user.id).await.unwrap();
|
||||||
assert!(found.is_some());
|
assert!(found.is_some());
|
||||||
let found = found.unwrap();
|
let found = found.unwrap();
|
||||||
assert_eq!(found.subject, "oidc|123");
|
assert_eq!(found.subject, "oidc|123");
|
||||||
assert_eq!(found.email, "test@example.com");
|
assert_eq!(found.email_str(), "test@example.com");
|
||||||
assert!(found.password_hash.is_none());
|
assert!(found.password_hash.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +169,14 @@ mod tests {
|
|||||||
let pool = setup_test_db().await;
|
let pool = setup_test_db().await;
|
||||||
let repo = SqliteUserRepository::new(pool);
|
let repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
let user = User::new_local("local@example.com", "hashed_pw");
|
let email = Email::try_from("local@example.com").unwrap();
|
||||||
|
let user = User::new_local(email, "hashed_pw");
|
||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
|
|
||||||
let found = repo.find_by_id(user.id).await.unwrap();
|
let found = repo.find_by_id(user.id).await.unwrap();
|
||||||
assert!(found.is_some());
|
assert!(found.is_some());
|
||||||
let found = found.unwrap();
|
let found = found.unwrap();
|
||||||
assert_eq!(found.email, "local@example.com");
|
assert_eq!(found.email_str(), "local@example.com");
|
||||||
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
|
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +185,8 @@ mod tests {
|
|||||||
let pool = setup_test_db().await;
|
let pool = setup_test_db().await;
|
||||||
let repo = SqliteUserRepository::new(pool);
|
let repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
let user = User::new("google|456", "user@gmail.com");
|
let email = Email::try_from("user@gmail.com").unwrap();
|
||||||
|
let user = User::new("google|456", email);
|
||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
|
|
||||||
let found = repo.find_by_subject("google|456").await.unwrap();
|
let found = repo.find_by_subject("google|456").await.unwrap();
|
||||||
@@ -192,7 +199,8 @@ mod tests {
|
|||||||
let pool = setup_test_db().await;
|
let pool = setup_test_db().await;
|
||||||
let repo = SqliteUserRepository::new(pool);
|
let repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
let user = User::new("test|789", "delete@test.com");
|
let email = Email::try_from("delete@test.com").unwrap();
|
||||||
|
let user = User::new("test|789", email);
|
||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
repo.delete(user.id).await.unwrap();
|
repo.delete(user.id).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ edition = "2024"
|
|||||||
default = ["sqlite", "smart-features"]
|
default = ["sqlite", "smart-features"]
|
||||||
sqlite = ["notes-infra/sqlite", "sqlx/sqlite"]
|
sqlite = ["notes-infra/sqlite", "sqlx/sqlite"]
|
||||||
# postgres = ["notes-infra/postgres", "sqlx/postgres"]
|
# postgres = ["notes-infra/postgres", "sqlx/postgres"]
|
||||||
smart-features = ["notes-infra/smart-features"]
|
smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
async-nats = "0.45.0"
|
|
||||||
notes-domain = { path = "../notes-domain" }
|
notes-domain = { path = "../notes-domain" }
|
||||||
notes-infra = { path = "../notes-infra", default-features = false }
|
notes-infra = { path = "../notes-infra", default-features = false }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use notes_domain::services::SmartNoteService;
|
|||||||
use notes_infra::{
|
use notes_infra::{
|
||||||
DatabaseConfig,
|
DatabaseConfig,
|
||||||
factory::{
|
factory::{
|
||||||
build_database_pool, build_embedding_generator, build_link_repository, build_vector_store,
|
BrokerProvider, build_database_pool, build_embedding_generator, build_link_repository,
|
||||||
|
build_message_broker, build_vector_store,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,10 +27,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let config = Config::from_env();
|
let config = Config::from_env();
|
||||||
let nats_client = async_nats::connect(&config.broker_url).await?;
|
|
||||||
|
|
||||||
#[cfg(feature = "smart-features")]
|
#[cfg(feature = "smart-features")]
|
||||||
{
|
{
|
||||||
|
// Connect to message broker via factory
|
||||||
|
tracing::info!("Connecting to message broker: {}", config.broker_url);
|
||||||
|
let broker_provider = BrokerProvider::Nats {
|
||||||
|
url: config.broker_url.clone(),
|
||||||
|
};
|
||||||
|
let broker = build_message_broker(&broker_provider)
|
||||||
|
.await?
|
||||||
|
.expect("Message broker required for worker");
|
||||||
|
|
||||||
let db_config = DatabaseConfig::new(config.database_url.clone());
|
let db_config = DatabaseConfig::new(config.database_url.clone());
|
||||||
let db_pool = build_database_pool(&db_config).await?;
|
let db_pool = build_database_pool(&db_config).await?;
|
||||||
|
|
||||||
@@ -45,27 +54,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
config.embedding_provider
|
config.embedding_provider
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to note update events
|
// Subscribe to note update events via the broker's stream API
|
||||||
let mut subscriber = nats_client.subscribe("notes.updated").await?;
|
let mut note_stream = broker.subscribe_note_updates().await?;
|
||||||
tracing::info!("Worker listening on 'notes.updated'...");
|
tracing::info!("Worker listening on 'notes.updated'...");
|
||||||
|
|
||||||
while let Some(msg) = subscriber.next().await {
|
while let Some(note) = note_stream.next().await {
|
||||||
// Parse message payload (assuming the payload IS the Note JSON)
|
|
||||||
let note_result: Result<notes_domain::Note, _> = serde_json::from_slice(&msg.payload);
|
|
||||||
|
|
||||||
match note_result {
|
|
||||||
Ok(note) => {
|
|
||||||
tracing::info!("Processing smart features for note: {}", note.id);
|
tracing::info!("Processing smart features for note: {}", note.id);
|
||||||
match smart_service.process_note(¬e).await {
|
match smart_service.process_note(¬e).await {
|
||||||
Ok(_) => tracing::info!("Successfully processed note {}", note.id),
|
Ok(_) => tracing::info!("Successfully processed note {}", note.id),
|
||||||
Err(e) => tracing::error!("Failed to process note {}: {}", note.id, e),
|
Err(e) => tracing::error!("Failed to process note {}: {}", note.id, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to deserialize note from message: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "smart-features"))]
|
#[cfg(not(feature = "smart-features"))]
|
||||||
|
|||||||
Reference in New Issue
Block a user