diff --git a/Cargo.lock b/Cargo.lock
index f3039ae..8e01c73 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -114,42 +114,6 @@ dependencies = [
"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]]
name = "async-nats"
version = "0.45.0"
@@ -2171,7 +2135,6 @@ name = "notes-api"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-nats 0.39.0",
"async-trait",
"axum 0.8.8",
"axum-login",
@@ -2203,6 +2166,7 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
+ "futures-core",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -2215,11 +2179,15 @@ dependencies = [
name = "notes-infra"
version = "0.1.0"
dependencies = [
+ "async-nats",
"async-trait",
"chrono",
"fastembed",
+ "futures-core",
+ "futures-util",
"notes-domain",
"qdrant-client",
+ "serde",
"serde_json",
"sqlx",
"thiserror 2.0.17",
@@ -2235,7 +2203,6 @@ name = "notes-worker"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-nats 0.45.0",
"async-trait",
"bytes",
"chrono",
diff --git a/k-notes-frontend/src/components/note-form.tsx b/k-notes-frontend/src/components/note-form.tsx
index 2069eea..dc171b8 100644
--- a/k-notes-frontend/src/components/note-form.tsx
+++ b/k-notes-frontend/src/components/note-form.tsx
@@ -11,7 +11,7 @@ import { Editor } from "@/components/editor/editor";
import { useTranslation } from "react-i18next";
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(),
is_pinned: z.boolean().default(false),
tags: z.string().optional(), // Comma separated for now
diff --git a/k-notes-frontend/src/pages/privacy-policy.tsx b/k-notes-frontend/src/pages/privacy-policy.tsx
index c2490b9..6c11b15 100644
--- a/k-notes-frontend/src/pages/privacy-policy.tsx
+++ b/k-notes-frontend/src/pages/privacy-policy.tsx
@@ -6,14 +6,14 @@ export default function PrivacyPolicyPage() {
const appName = "K-Notes";
return (
-
+
{/* Header */}
-
+
Privacy Policy
diff --git a/migrations/20251231000000_nullable_title.sql b/migrations/20251231000000_nullable_title.sql
new file mode 100644
index 0000000..c7329a3
--- /dev/null
+++ b/migrations/20251231000000_nullable_title.sql
@@ -0,0 +1,45 @@
+-- Allow NULL titles in notes table
+-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
+
+-- Step 1: Create new table with nullable title
+CREATE TABLE notes_new (
+ id TEXT PRIMARY KEY NOT NULL,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT, -- Now nullable
+ content TEXT NOT NULL DEFAULT '',
+ color TEXT NOT NULL DEFAULT 'DEFAULT',
+ is_pinned INTEGER NOT NULL DEFAULT 0,
+ is_archived INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- Step 2: Copy data from old table
+INSERT INTO notes_new (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at)
+SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at FROM notes;
+
+-- Step 3: Drop old table
+DROP TABLE notes;
+
+-- Step 4: Rename new table
+ALTER TABLE notes_new RENAME TO notes;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_notes_user_id ON notes(user_id);
+CREATE INDEX idx_notes_is_pinned ON notes(is_pinned);
+CREATE INDEX idx_notes_is_archived ON notes(is_archived);
+CREATE INDEX idx_notes_updated_at ON notes(updated_at);
+
+-- Step 6: Recreate FTS triggers
+CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
+ INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
+END;
+
+CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
+ INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
+END;
+
+CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
+ INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
+ INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
+END;
diff --git a/notes-api/Cargo.toml b/notes-api/Cargo.toml
index 8a2be79..d5b9e64 100644
--- a/notes-api/Cargo.toml
+++ b/notes-api/Cargo.toml
@@ -16,7 +16,7 @@ postgres = [
"tower-sessions-sqlx-store/postgres",
"sqlx/postgres",
]
-smart-features = ["notes-infra/smart-features", "dep:async-nats"]
+smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"]
[dependencies]
notes-domain = { path = "../notes-domain" }
@@ -36,7 +36,6 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
password-auth = "1.0"
time = "0.3"
async-trait = "0.1.89"
-async-nats = { version = "0.39", optional = true }
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs
index 0e31a23..46d9fbd 100644
--- a/notes-api/src/dto.rs
+++ b/notes-api/src/dto.rs
@@ -10,7 +10,7 @@ use notes_domain::{Note, Tag};
/// Request to create a new note
#[derive(Debug, Deserialize, Validate)]
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,
#[serde(default)]
@@ -29,7 +29,7 @@ pub struct CreateNoteRequest {
/// Request to update an existing note (all fields optional)
#[derive(Debug, Deserialize, Validate)]
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
,
pub content: Option,
@@ -68,7 +68,7 @@ impl From for TagResponse {
fn from(tag: Tag) -> Self {
Self {
id: tag.id,
- name: tag.name,
+ name: tag.name.into_inner(), // Convert TagName to String
}
}
}
@@ -91,7 +91,7 @@ impl From for NoteResponse {
fn from(note: Note) -> Self {
Self {
id: note.id,
- title: note.title,
+ title: note.title_str().to_string(), // Convert Option to String
content: note.content,
color: note.color,
is_pinned: note.is_pinned,
@@ -160,7 +160,7 @@ impl From for NoteVersionResponse {
Self {
id: version.id,
note_id: version.note_id,
- title: version.title,
+ title: version.title.unwrap_or_default(), // Convert Option to String
content: version.content,
created_at: version.created_at,
}
diff --git a/notes-api/src/main.rs b/notes-api/src/main.rs
index dde665d..fb1a02f 100644
--- a/notes-api/src/main.rs
+++ b/notes-api/src/main.rs
@@ -18,8 +18,6 @@ mod auth;
mod config;
mod dto;
mod error;
-#[cfg(feature = "smart-features")]
-mod nats_broker;
mod routes;
mod state;
@@ -81,13 +79,17 @@ async fn main() -> anyhow::Result<()> {
.await
.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")]
- let nats_client = {
- tracing::info!("Connecting to NATS: {}", config.broker_url);
- async_nats::connect(&config.broker_url)
+ let message_broker = {
+ use notes_infra::factory::{BrokerProvider, build_message_broker};
+ tracing::info!("Connecting to message broker: {}", config.broker_url);
+ let provider = BrokerProvider::Nats {
+ url: config.broker_url.clone(),
+ };
+ build_message_broker(&provider)
.await
- .map_err(|e| anyhow::anyhow!("NATS connection failed: {}", e))?
+ .map_err(|e| anyhow::anyhow!("Broker connection failed: {}", e))?
};
// Create services
@@ -95,9 +97,11 @@ async fn main() -> anyhow::Result<()> {
// Build NoteService with optional MessageBroker
#[cfg(feature = "smart-features")]
- let note_service = {
- let broker = Arc::new(nats_broker::NatsMessageBroker::new(nats_client.clone()));
- Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()).with_message_broker(broker))
+ let note_service = match message_broker {
+ Some(broker) => Arc::new(
+ 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"))]
let note_service = Arc::new(NoteService::new(note_repo.clone(), tag_repo.clone()));
@@ -115,8 +119,6 @@ async fn main() -> anyhow::Result<()> {
note_service,
tag_service,
user_service,
- #[cfg(feature = "smart-features")]
- nats_client,
config.clone(),
);
@@ -188,7 +190,7 @@ async fn main() -> 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 password_auth::generate_hash;
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();
if user_repo.find_by_id(dev_user_id).await?.is_none() {
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(
dev_user_id,
"dev|local",
- "dev@localhost.com",
+ dev_email,
Some(hash),
chrono::Utc::now(),
);
diff --git a/notes-api/src/nats_broker.rs b/notes-api/src/nats_broker.rs
deleted file mode 100644
index 431bf34..0000000
--- a/notes-api/src/nats_broker.rs
+++ /dev/null
@@ -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(())
- }
-}
diff --git a/notes-api/src/routes/auth.rs b/notes-api/src/routes/auth.rs
index 22ff697..9418805 100644
--- a/notes-api/src/routes/auth.rs
+++ b/notes-api/src/routes/auth.rs
@@ -4,7 +4,7 @@ use axum::{Json, extract::State, http::StatusCode};
use axum_login::AuthSession;
use validator::Validate;
-use notes_domain::User;
+use notes_domain::{Email, User};
use password_auth::generate_hash;
use crate::auth::{AuthBackend, AuthUser, Credentials};
@@ -43,9 +43,12 @@ pub async fn register(
// Hash password
let password_hash = generate_hash(&payload.password);
- // Create use
- // For local registration, we use email as subject
- let user = User::new_local(&payload.email, &password_hash);
+ // Parse email string to Email newtype
+ let email = Email::try_from(payload.email)
+ .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)?;
@@ -108,7 +111,7 @@ pub async fn me(
Ok(Json(crate::dto::UserResponse {
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,
}))
}
diff --git a/notes-api/src/routes/notes.rs b/notes-api/src/routes/notes.rs
index 8479ebe..f6dc189 100644
--- a/notes-api/src/routes/notes.rs
+++ b/notes-api/src/routes/notes.rs
@@ -10,7 +10,10 @@ use uuid::Uuid;
use validator::Validate;
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::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
@@ -71,11 +74,30 @@ pub async fn create_note(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
+ // Parse title into NoteTitle (optional - empty string becomes None)
+ let title: Option = 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 = payload
+ .tags
+ .into_iter()
+ .map(|s| {
+ TagName::try_from(s).map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
+ })
+ .collect::, _>>()?;
+
let domain_req = DomainCreateNote {
user_id,
- title: payload.title,
+ title,
content: payload.content,
- tags: payload.tags,
+ tags,
color: payload.color,
is_pinned: payload.is_pinned,
};
@@ -126,15 +148,40 @@ pub async fn update_note(
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
+ // Parse optional title - Some(string) -> Some(Some(NoteTitle)) or Some(None) for empty
+ let title: Option