refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
19
crates/domain/src/errors.rs
Normal file
19
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
#[error("validation: {0}")]
|
||||
Validation(String),
|
||||
#[error("repository: {0}")]
|
||||
Repository(String),
|
||||
#[error("infrastructure: {0}")]
|
||||
Infrastructure(String),
|
||||
}
|
||||
|
||||
pub type DomainResult<T> = Result<T, DomainError>;
|
||||
65
crates/domain/src/events.rs
Normal file
65
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use futures::{future::BoxFuture, stream::BoxStream};
|
||||
|
||||
use crate::{errors::DomainError, note::entity::NoteId, user::entity::UserId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DomainEvent {
|
||||
NoteCreated { note_id: NoteId, user_id: UserId },
|
||||
NoteUpdated { note_id: NoteId, user_id: UserId },
|
||||
NoteDeleted { note_id: NoteId, user_id: UserId },
|
||||
}
|
||||
|
||||
type AckFn = Box<dyn FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send>;
|
||||
|
||||
pub struct EventEnvelope {
|
||||
pub event: DomainEvent,
|
||||
ack_fn: AckFn,
|
||||
nack_fn: AckFn,
|
||||
}
|
||||
|
||||
impl EventEnvelope {
|
||||
pub fn new(
|
||||
event: DomainEvent,
|
||||
ack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static,
|
||||
nack_fn: impl FnOnce() -> BoxFuture<'static, Result<(), DomainError>> + Send + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
ack_fn: Box::new(ack_fn),
|
||||
nack_fn: Box::new(nack_fn),
|
||||
}
|
||||
}
|
||||
|
||||
/// Both ack and nack are no-ops. For in-memory and test consumers.
|
||||
pub fn noop(event: DomainEvent) -> Self {
|
||||
Self::new(
|
||||
event,
|
||||
|| Box::pin(async { Ok(()) }),
|
||||
|| Box::pin(async { Ok(()) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn ack(self) -> Result<(), DomainError> {
|
||||
(self.ack_fn)().await
|
||||
}
|
||||
|
||||
/// Signal that processing failed. The transport decides whether to redeliver
|
||||
/// (JetStream: redeliver up to max_deliver times; in-memory: no-op).
|
||||
pub async fn nack(self) -> Result<(), DomainError> {
|
||||
(self.nack_fn)().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait EventPublisher: Send + Sync {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub trait EventConsumer: Send + Sync {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait EventHandler: Send + Sync {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
}
|
||||
6
crates/domain/src/lib.rs
Normal file
6
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod note;
|
||||
pub mod smart;
|
||||
pub mod tag;
|
||||
pub mod user;
|
||||
176
crates/domain/src/note/entity.rs
Normal file
176
crates/domain/src/note/entity.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::value_objects::{NoteColor, NoteTitle};
|
||||
use crate::{tag::entity::Tag, user::entity::UserId};
|
||||
|
||||
pub const MAX_TAGS_PER_NOTE: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct NoteId(Uuid);
|
||||
|
||||
impl NoteId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoteId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NoteId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
pub id: NoteId,
|
||||
pub user_id: UserId,
|
||||
pub title: Option<NoteTitle>,
|
||||
pub content: String,
|
||||
pub color: NoteColor,
|
||||
pub is_pinned: bool,
|
||||
pub is_archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Hydrated by the repository on read. Not managed by Note itself.
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(user_id: UserId, title: Option<NoteTitle>, content: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: NoteId::new(),
|
||||
user_id,
|
||||
title,
|
||||
content: content.into(),
|
||||
color: NoteColor::default(),
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_title(&mut self, title: Option<NoteTitle>) {
|
||||
self.title = title;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, content: impl Into<String>) {
|
||||
self.content = content.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_color(&mut self, color: NoteColor) {
|
||||
self.color = color;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_pinned(&mut self, pinned: bool) {
|
||||
self.is_pinned = pinned;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_archived(&mut self, archived: bool) {
|
||||
self.is_archived = archived;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn can_add_tag(&self) -> bool {
|
||||
self.tags.len() < MAX_TAGS_PER_NOTE
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of a note's content at a point in time.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NoteVersion {
|
||||
pub id: Uuid,
|
||||
pub note_id: NoteId,
|
||||
pub title: Option<String>,
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl NoteVersion {
|
||||
pub fn snapshot(note: &Note) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
note_id: note.id,
|
||||
title: note.title.as_ref().map(|t| t.as_ref().to_string()),
|
||||
content: note.content.clone(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic similarity edge between two notes, produced by smart features.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NoteLink {
|
||||
pub source_id: NoteId,
|
||||
pub target_id: NoteId,
|
||||
/// Cosine similarity score in [0.0, 1.0].
|
||||
pub score: f32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl NoteLink {
|
||||
pub fn new(source_id: NoteId, target_id: NoteId, score: f32) -> Self {
|
||||
Self {
|
||||
source_id,
|
||||
target_id,
|
||||
score,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NoteFilter {
|
||||
pub is_pinned: Option<bool>,
|
||||
pub is_archived: Option<bool>,
|
||||
pub tag_id: Option<crate::tag::entity::TagId>,
|
||||
}
|
||||
|
||||
impl NoteFilter {
|
||||
pub fn pinned(mut self) -> Self {
|
||||
self.is_pinned = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn archived(mut self) -> Self {
|
||||
self.is_archived = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn not_archived(mut self) -> Self {
|
||||
self.is_archived = Some(false);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tag(mut self, tag_id: crate::tag::entity::TagId) -> Self {
|
||||
self.tag_id = Some(tag_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/entity.rs"]
|
||||
mod tests;
|
||||
6
crates/domain/src/note/mod.rs
Normal file
6
crates/domain/src/note/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod entity;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use entity::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteId, NoteLink, NoteVersion};
|
||||
pub use value_objects::{NoteColor, NoteTitle};
|
||||
22
crates/domain/src/note/ports.rs
Normal file
22
crates/domain/src/note/ports.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion};
|
||||
use crate::{errors::DomainResult, user::entity::UserId};
|
||||
|
||||
#[async_trait]
|
||||
pub trait NoteRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>>;
|
||||
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>>;
|
||||
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>>;
|
||||
async fn save(&self, note: &Note) -> DomainResult<()>;
|
||||
async fn delete(&self, id: &NoteId) -> DomainResult<()>;
|
||||
async fn save_version(&self, version: &NoteVersion) -> DomainResult<()>;
|
||||
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LinkRepository: Send + Sync {
|
||||
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()>;
|
||||
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()>;
|
||||
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>>;
|
||||
}
|
||||
47
crates/domain/src/note/tests/entity.rs
Normal file
47
crates/domain/src/note/tests/entity.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::{
|
||||
note::entity::{MAX_TAGS_PER_NOTE, Note, NoteVersion},
|
||||
tag::{entity::Tag, value_objects::TagName},
|
||||
user::entity::UserId,
|
||||
};
|
||||
|
||||
fn uid() -> UserId {
|
||||
UserId::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_note_defaults() {
|
||||
let note = Note::new(uid(), None, "content");
|
||||
assert!(!note.is_pinned);
|
||||
assert!(!note.is_archived);
|
||||
assert_eq!(note.color.as_str(), "DEFAULT");
|
||||
assert!(note.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_pinned_updates_timestamp() {
|
||||
let mut note = Note::new(uid(), None, "content");
|
||||
let before = note.updated_at;
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
note.set_pinned(true);
|
||||
assert!(note.is_pinned);
|
||||
assert!(note.updated_at > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_add_tag_respects_limit() {
|
||||
let user_id = uid();
|
||||
let mut note = Note::new(user_id, None, "content");
|
||||
assert!(note.can_add_tag());
|
||||
note.tags = (0..MAX_TAGS_PER_NOTE)
|
||||
.map(|_| Tag::new(TagName::new("x").unwrap(), user_id))
|
||||
.collect();
|
||||
assert!(!note.can_add_tag());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_version_snapshots_content() {
|
||||
let note = Note::new(uid(), None, "hello");
|
||||
let v = NoteVersion::snapshot(¬e);
|
||||
assert_eq!(v.note_id, note.id);
|
||||
assert_eq!(v.content, "hello");
|
||||
}
|
||||
34
crates/domain/src/note/tests/value_objects.rs
Normal file
34
crates/domain/src/note/tests/value_objects.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_trims_whitespace() {
|
||||
let t = NoteTitle::new(" My Note ").unwrap();
|
||||
assert_eq!(t.as_ref(), "My Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_rejects_too_long() {
|
||||
assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH + 1)).is_err());
|
||||
assert!(NoteTitle::new("a".repeat(MAX_NOTE_TITLE_LENGTH)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_optional_empty_is_none() {
|
||||
assert!(NoteTitle::from_optional(None).unwrap().is_none());
|
||||
assert!(
|
||||
NoteTitle::from_optional(Some(" ".into()))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_uppercases() {
|
||||
let c = NoteColor::new("default");
|
||||
assert_eq!(c.as_str(), "DEFAULT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_default() {
|
||||
assert_eq!(NoteColor::default().as_str(), "DEFAULT");
|
||||
}
|
||||
94
crates/domain/src/note/value_objects.rs
Normal file
94
crates/domain/src/note/value_objects.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub const MAX_NOTE_TITLE_LENGTH: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct NoteTitle(String);
|
||||
|
||||
impl NoteTitle {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let s = value.into();
|
||||
let trimmed = s.trim();
|
||||
if trimmed.len() > MAX_NOTE_TITLE_LENGTH {
|
||||
return Err(DomainError::Validation(format!(
|
||||
"note title exceeds {MAX_NOTE_TITLE_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
|
||||
/// Returns `None` for empty/whitespace input, `Some` otherwise.
|
||||
pub fn from_optional(value: Option<String>) -> Result<Option<Self>, DomainError> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(s) if s.trim().is_empty() => Ok(None),
|
||||
Some(s) => Self::new(s).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
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 = DomainError;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for NoteTitle {
|
||||
type Error = DomainError;
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background color of a note. Stored as an uppercase string (e.g. "DEFAULT", "#FF5733").
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NoteColor(String);
|
||||
|
||||
impl NoteColor {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into().trim().to_uppercase())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoteColor {
|
||||
fn default() -> Self {
|
||||
Self::new("DEFAULT")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NoteColor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/value_objects.rs"]
|
||||
mod tests;
|
||||
1
crates/domain/src/smart/mod.rs
Normal file
1
crates/domain/src/smart/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod ports;
|
||||
15
crates/domain/src/smart/ports.rs
Normal file
15
crates/domain/src/smart/ports.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{errors::DomainResult, note::entity::NoteId};
|
||||
|
||||
#[async_trait]
|
||||
pub trait EmbeddingGenerator: Send + Sync {
|
||||
async fn generate(&self, text: &str) -> DomainResult<Vec<f32>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VectorStore: Send + Sync {
|
||||
async fn upsert(&self, id: &NoteId, vector: &[f32]) -> DomainResult<()>;
|
||||
async fn find_similar(&self, vector: &[f32], limit: usize) -> DomainResult<Vec<(NoteId, f32)>>;
|
||||
async fn delete(&self, id: &NoteId) -> DomainResult<()>;
|
||||
}
|
||||
56
crates/domain/src/tag/entity.rs
Normal file
56
crates/domain/src/tag/entity.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::fmt;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::value_objects::TagName;
|
||||
use crate::user::entity::UserId;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TagId(Uuid);
|
||||
|
||||
impl TagId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TagId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TagId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tag {
|
||||
pub id: TagId,
|
||||
pub name: TagName,
|
||||
pub user_id: UserId,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn new(name: TagName, user_id: UserId) -> Self {
|
||||
Self {
|
||||
id: TagId::new(),
|
||||
name,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_row(id: TagId, name: TagName, user_id: UserId) -> Self {
|
||||
Self { id, name, user_id }
|
||||
}
|
||||
}
|
||||
6
crates/domain/src/tag/mod.rs
Normal file
6
crates/domain/src/tag/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod entity;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use entity::{Tag, TagId};
|
||||
pub use value_objects::TagName;
|
||||
19
crates/domain/src/tag/ports.rs
Normal file
19
crates/domain/src/tag/ports.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{
|
||||
entity::{Tag, TagId},
|
||||
value_objects::TagName,
|
||||
};
|
||||
use crate::{errors::DomainResult, note::entity::NoteId, user::entity::UserId};
|
||||
|
||||
#[async_trait]
|
||||
pub trait TagRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>>;
|
||||
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>>;
|
||||
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>>;
|
||||
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>>;
|
||||
async fn save(&self, tag: &Tag) -> DomainResult<()>;
|
||||
async fn delete(&self, id: &TagId) -> DomainResult<()>;
|
||||
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>;
|
||||
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()>;
|
||||
}
|
||||
19
crates/domain/src/tag/tests/value_objects.rs
Normal file
19
crates/domain/src/tag/tests/value_objects.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalises_to_lowercase_trimmed() {
|
||||
let t = TagName::new(" Important ").unwrap();
|
||||
assert_eq!(t.as_ref(), "important");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert!(TagName::new("").is_err());
|
||||
assert!(TagName::new(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH + 1)).is_err());
|
||||
assert!(TagName::new("a".repeat(MAX_TAG_NAME_LENGTH)).is_ok());
|
||||
}
|
||||
57
crates/domain/src/tag/value_objects.rs
Normal file
57
crates/domain/src/tag/value_objects.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub const MAX_TAG_NAME_LENGTH: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct TagName(String);
|
||||
|
||||
impl TagName {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let s = value.into().trim().to_lowercase();
|
||||
if s.is_empty() {
|
||||
return Err(DomainError::Validation("tag name cannot be empty".into()));
|
||||
}
|
||||
if s.len() > MAX_TAG_NAME_LENGTH {
|
||||
return Err(DomainError::Validation(format!(
|
||||
"tag name exceeds {MAX_TAG_NAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(Self(s))
|
||||
}
|
||||
|
||||
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 = DomainError;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for TagName {
|
||||
type Error = DomainError;
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/value_objects.rs"]
|
||||
mod tests;
|
||||
85
crates/domain/src/user/entity.rs
Normal file
85
crates/domain/src/user/entity.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::value_objects::{Email, PasswordHash};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct UserId(Uuid);
|
||||
|
||||
impl UserId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
/// OIDC subject claim; equals email string for local-auth users.
|
||||
pub subject: String,
|
||||
pub email: Email,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new_oidc(subject: impl Into<String>, email: Email) -> Self {
|
||||
Self {
|
||||
id: UserId::new(),
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_local(email: Email, password_hash: PasswordHash) -> Self {
|
||||
let subject = email.as_ref().to_string();
|
||||
Self {
|
||||
id: UserId::new(),
|
||||
subject,
|
||||
email,
|
||||
password_hash: Some(password_hash),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from storage. Does not validate business rules.
|
||||
pub fn from_row(
|
||||
id: UserId,
|
||||
subject: String,
|
||||
email: Email,
|
||||
password_hash: Option<PasswordHash>,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
subject,
|
||||
email,
|
||||
password_hash,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
6
crates/domain/src/user/mod.rs
Normal file
6
crates/domain/src/user/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod entity;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use entity::{User, UserId};
|
||||
pub use value_objects::{Email, Password, PasswordHash};
|
||||
22
crates/domain/src/user/ports.rs
Normal file
22
crates/domain/src/user/ports.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{
|
||||
entity::{User, UserId},
|
||||
value_objects::{Email, Password, PasswordHash},
|
||||
};
|
||||
use crate::errors::DomainResult;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>>;
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
|
||||
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>>;
|
||||
async fn save(&self, user: &User) -> DomainResult<()>;
|
||||
async fn delete(&self, id: &UserId) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PasswordHasher: Send + Sync {
|
||||
async fn hash(&self, password: &Password) -> DomainResult<PasswordHash>;
|
||||
async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult<bool>;
|
||||
}
|
||||
25
crates/domain/src/user/tests/value_objects.rs
Normal file
25
crates/domain/src/user/tests/value_objects.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_normalises() {
|
||||
let e = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(e.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_rejects_invalid() {
|
||||
assert!(Email::new("not-an-email").is_err());
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_enforces_minimum_length() {
|
||||
assert!(Password::new("short").is_err());
|
||||
assert!(Password::new("longenough").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_hides_in_debug() {
|
||||
let p = Password::new("supersecret").unwrap();
|
||||
assert!(!format!("{p:?}").contains("supersecret"));
|
||||
}
|
||||
101
crates/domain/src/user/value_objects.rs
Normal file
101
crates/domain/src/user/value_objects.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Email(email_address::EmailAddress);
|
||||
|
||||
impl Email {
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
|
||||
let s = value.as_ref().trim().to_lowercase();
|
||||
s.parse::<email_address::EmailAddress>()
|
||||
.map(Self)
|
||||
.map_err(|e| DomainError::Validation(format!("invalid email: {e}")))
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
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 = DomainError;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Email {
|
||||
type Error = DomainError;
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unverified plaintext password. Not stored — only used for auth operations.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
impl Password {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let v = value.into();
|
||||
if v.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(DomainError::Validation(format!(
|
||||
"password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(Self(v))
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Password {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored password hash — opaque to the domain, managed by PasswordHasher port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn new(hash: impl Into<String>) -> Self {
|
||||
Self(hash.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/value_objects.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user