16
crates/domain/Cargo.toml
Normal file
16
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
email_address.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[features]
|
||||
test-helpers = []
|
||||
99
crates/domain/src/activity/activity.rs
Normal file
99
crates/domain/src/activity/activity.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{ActivityId, ActivityName, CategoryName};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Activity {
|
||||
id: ActivityId,
|
||||
user_id: UserId,
|
||||
name: ActivityName,
|
||||
category: Option<CategoryName>,
|
||||
archived: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn new(user_id: UserId, name: ActivityName, category: Option<CategoryName>) -> Self {
|
||||
Self {
|
||||
id: ActivityId::generate(),
|
||||
user_id,
|
||||
name,
|
||||
category,
|
||||
archived: false,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: ActivityId,
|
||||
user_id: UserId,
|
||||
name: ActivityName,
|
||||
category: Option<CategoryName>,
|
||||
archived: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
category,
|
||||
archived,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn id(&self) -> &ActivityId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &ActivityName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn category(&self) -> Option<&CategoryName> {
|
||||
self.category.as_ref()
|
||||
}
|
||||
|
||||
pub fn is_archived(&self) -> bool {
|
||||
self.archived
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn rename(&mut self, name: ActivityName) {
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
pub fn set_category(&mut self, category: Option<CategoryName>) {
|
||||
self.category = category;
|
||||
}
|
||||
|
||||
pub fn archive(&mut self) -> Result<(), DomainError> {
|
||||
if self.archived {
|
||||
return Err(DomainError::Conflict("activity is already archived".into()));
|
||||
}
|
||||
self.archived = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unarchive(&mut self) -> Result<(), DomainError> {
|
||||
if !self.archived {
|
||||
return Err(DomainError::Conflict("activity is not archived".into()));
|
||||
}
|
||||
self.archived = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/activity/activity_id.rs
Normal file
1
crates/domain/src/activity/activity_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(ActivityId);
|
||||
26
crates/domain/src/activity/activity_name.rs
Normal file
26
crates/domain/src/activity/activity_name.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ActivityName(String);
|
||||
|
||||
impl ActivityName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = name.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"activity name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActivityName {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
24
crates/domain/src/activity/category_name.rs
Normal file
24
crates/domain/src/activity/category_name.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CategoryName(String);
|
||||
|
||||
impl CategoryName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = name.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"category name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/activity/mod.rs
Normal file
9
crates/domain/src/activity/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod activity;
|
||||
mod activity_id;
|
||||
mod activity_name;
|
||||
mod category_name;
|
||||
|
||||
pub use activity::Activity;
|
||||
pub use activity_id::ActivityId;
|
||||
pub use activity_name::ActivityName;
|
||||
pub use category_name::CategoryName;
|
||||
29
crates/domain/src/attachment/content_type.rs
Normal file
29
crates/domain/src/attachment/content_type.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentType(String);
|
||||
|
||||
impl ContentType {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into().trim().to_lowercase();
|
||||
if value.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"content type cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if !value.contains('/') {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"content type must be in MIME format (e.g. image/jpeg)".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn from_persistence(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
32
crates/domain/src/attachment/media_upload.rs
Normal file
32
crates/domain/src/attachment/media_upload.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::ContentType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaUpload {
|
||||
data: Vec<u8>,
|
||||
content_type: ContentType,
|
||||
}
|
||||
|
||||
impl MediaUpload {
|
||||
pub fn new(data: Vec<u8>, content_type: ContentType) -> Result<Self, DomainError> {
|
||||
if data.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"media data cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { data, content_type })
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
pub fn content_type(&self) -> &ContentType {
|
||||
&self.content_type
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/attachment/mod.rs
Normal file
9
crates/domain/src/attachment/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod content_type;
|
||||
mod media_upload;
|
||||
mod photo_id;
|
||||
mod voice_memo_id;
|
||||
|
||||
pub use content_type::ContentType;
|
||||
pub use media_upload::MediaUpload;
|
||||
pub use photo_id::PhotoId;
|
||||
pub use voice_memo_id::VoiceMemoId;
|
||||
1
crates/domain/src/attachment/photo_id.rs
Normal file
1
crates/domain/src/attachment/photo_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(PhotoId);
|
||||
1
crates/domain/src/attachment/voice_memo_id.rs
Normal file
1
crates/domain/src/attachment/voice_memo_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(VoiceMemoId);
|
||||
21
crates/domain/src/auth/generated_token.rs
Normal file
21
crates/domain/src/auth/generated_token.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedToken {
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl GeneratedToken {
|
||||
pub fn new(token: String, expires_at: DateTime<Utc>) -> Self {
|
||||
Self { token, expires_at }
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn expires_at(&self) -> &DateTime<Utc> {
|
||||
&self.expires_at
|
||||
}
|
||||
}
|
||||
7
crates/domain/src/auth/mod.rs
Normal file
7
crates/domain/src/auth/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod generated_token;
|
||||
mod refresh_session;
|
||||
mod refresh_session_id;
|
||||
|
||||
pub use generated_token::GeneratedToken;
|
||||
pub use refresh_session::RefreshSession;
|
||||
pub use refresh_session_id::RefreshSessionId;
|
||||
68
crates/domain/src/auth/refresh_session.rs
Normal file
68
crates/domain/src/auth/refresh_session.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::RefreshSessionId;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshSession {
|
||||
id: RefreshSessionId,
|
||||
user_id: UserId,
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RefreshSession {
|
||||
pub fn new(user_id: UserId, ttl_seconds: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: RefreshSessionId::generate(),
|
||||
user_id,
|
||||
token: Uuid::new_v4().to_string(),
|
||||
expires_at: now + Duration::seconds(ttl_seconds),
|
||||
created_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: RefreshSessionId,
|
||||
user_id: UserId,
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
user_id,
|
||||
token,
|
||||
expires_at,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RefreshSessionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn expires_at(&self) -> &DateTime<Utc> {
|
||||
&self.expires_at
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/auth/refresh_session_id.rs
Normal file
1
crates/domain/src/auth/refresh_session_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(RefreshSessionId);
|
||||
24
crates/domain/src/entry/content.rs
Normal file
24
crates/domain/src/entry/content.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Content(String);
|
||||
|
||||
impl Content {
|
||||
pub fn new(text: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = text.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput("content cannot be empty".into()));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(text: String) -> Self {
|
||||
Self(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
31
crates/domain/src/entry/date_range.rs
Normal file
31
crates/domain/src/entry/date_range.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DateRange {
|
||||
start: DateTime<FixedOffset>,
|
||||
end: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl DateRange {
|
||||
pub fn new(
|
||||
start: DateTime<FixedOffset>,
|
||||
end: DateTime<FixedOffset>,
|
||||
) -> Result<Self, DomainError> {
|
||||
if start > end {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"date range start must be before or equal to end".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { start, end })
|
||||
}
|
||||
|
||||
pub fn start(&self) -> &DateTime<FixedOffset> {
|
||||
&self.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> &DateTime<FixedOffset> {
|
||||
&self.end
|
||||
}
|
||||
}
|
||||
11
crates/domain/src/entry/mod.rs
Normal file
11
crates/domain/src/entry/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod content;
|
||||
mod date_range;
|
||||
mod mood;
|
||||
mod mood_entry;
|
||||
mod mood_entry_id;
|
||||
|
||||
pub use content::Content;
|
||||
pub use date_range::DateRange;
|
||||
pub use mood::Mood;
|
||||
pub use mood_entry::{MoodEntry, MoodEntryData};
|
||||
pub use mood_entry_id::MoodEntryId;
|
||||
35
crates/domain/src/entry/mood.rs
Normal file
35
crates/domain/src/entry/mood.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub enum Mood {
|
||||
Awful = 1,
|
||||
Bad = 2,
|
||||
Meh = 3,
|
||||
Good = 4,
|
||||
Rad = 5,
|
||||
}
|
||||
|
||||
impl Mood {
|
||||
pub fn value(&self) -> u8 {
|
||||
*self as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for Mood {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
1 => Ok(Self::Awful),
|
||||
2 => Ok(Self::Bad),
|
||||
3 => Ok(Self::Meh),
|
||||
4 => Ok(Self::Good),
|
||||
5 => Ok(Self::Rad),
|
||||
_ => Err(DomainError::InvalidInput(format!(
|
||||
"mood value must be between 1 and 5, got {value}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/domain/src/entry/mood_entry.rs
Normal file
147
crates/domain/src/entry/mood_entry.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
|
||||
use crate::activity::ActivityId;
|
||||
use crate::attachment::{PhotoId, VoiceMemoId};
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{Content, Mood, MoodEntryId};
|
||||
|
||||
pub struct MoodEntryData {
|
||||
pub id: MoodEntryId,
|
||||
pub user_id: UserId,
|
||||
pub mood: Mood,
|
||||
pub logged_at: DateTime<FixedOffset>,
|
||||
pub activities: Vec<ActivityId>,
|
||||
pub content: Option<Content>,
|
||||
pub photos: Vec<PhotoId>,
|
||||
pub voice_memos: Vec<VoiceMemoId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoodEntry {
|
||||
id: MoodEntryId,
|
||||
user_id: UserId,
|
||||
mood: Mood,
|
||||
logged_at: DateTime<FixedOffset>,
|
||||
activities: Vec<ActivityId>,
|
||||
content: Option<Content>,
|
||||
photos: Vec<PhotoId>,
|
||||
voice_memos: Vec<VoiceMemoId>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MoodEntry {
|
||||
pub fn new(user_id: UserId, mood: Mood, logged_at: DateTime<FixedOffset>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: MoodEntryId::generate(),
|
||||
user_id,
|
||||
mood,
|
||||
logged_at,
|
||||
activities: Vec::new(),
|
||||
content: None,
|
||||
photos: Vec::new(),
|
||||
voice_memos: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: MoodEntryData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
user_id: data.user_id,
|
||||
mood: data.mood,
|
||||
logged_at: data.logged_at,
|
||||
activities: data.activities,
|
||||
content: data.content,
|
||||
photos: data.photos,
|
||||
voice_memos: data.voice_memos,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MoodEntry {
|
||||
pub fn id(&self) -> &MoodEntryId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn mood(&self) -> Mood {
|
||||
self.mood
|
||||
}
|
||||
|
||||
pub fn logged_at(&self) -> &DateTime<FixedOffset> {
|
||||
&self.logged_at
|
||||
}
|
||||
|
||||
pub fn activities(&self) -> &[ActivityId] {
|
||||
&self.activities
|
||||
}
|
||||
|
||||
pub fn content(&self) -> Option<&Content> {
|
||||
self.content.as_ref()
|
||||
}
|
||||
|
||||
pub fn photos(&self) -> &[PhotoId] {
|
||||
&self.photos
|
||||
}
|
||||
|
||||
pub fn voice_memos(&self) -> &[VoiceMemoId] {
|
||||
&self.voice_memos
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> {
|
||||
&self.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
impl MoodEntry {
|
||||
pub fn update_mood(&mut self, mood: Mood) {
|
||||
self.mood = mood;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn update_logged_at(&mut self, logged_at: DateTime<FixedOffset>) {
|
||||
self.logged_at = logged_at;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, content: Option<Content>) {
|
||||
self.content = content;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_activities(&mut self, mut activities: Vec<ActivityId>) {
|
||||
activities.sort();
|
||||
activities.dedup();
|
||||
self.activities = activities;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_photos(&mut self, photos: Vec<PhotoId>) {
|
||||
self.photos = photos;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn set_voice_memos(&mut self, voice_memos: Vec<VoiceMemoId>) {
|
||||
self.voice_memos = voice_memos;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/entry/mood_entry_id.rs
Normal file
1
crates/domain/src/entry/mood_entry_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(MoodEntryId);
|
||||
17
crates/domain/src/errors/domain_error.rs
Normal file
17
crates/domain/src/errors/domain_error.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DomainError {
|
||||
#[error("entity not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
3
crates/domain/src/errors/mod.rs
Normal file
3
crates/domain/src/errors/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod domain_error;
|
||||
|
||||
pub use domain_error::DomainError;
|
||||
56
crates/domain/src/events/domain_event.rs
Normal file
56
crates/domain/src/events/domain_event.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::activity::{ActivityId, ActivityName};
|
||||
use crate::entry::MoodEntryId;
|
||||
use crate::reminder::ReminderId;
|
||||
use crate::user::UserId;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DomainEvent {
|
||||
EntryCreated {
|
||||
entry_id: MoodEntryId,
|
||||
user_id: UserId,
|
||||
},
|
||||
EntryUpdated {
|
||||
entry_id: MoodEntryId,
|
||||
user_id: UserId,
|
||||
},
|
||||
EntryDeleted {
|
||||
entry_id: MoodEntryId,
|
||||
user_id: UserId,
|
||||
},
|
||||
|
||||
ActivityCreated {
|
||||
activity_id: ActivityId,
|
||||
user_id: UserId,
|
||||
},
|
||||
ActivityRenamed {
|
||||
activity_id: ActivityId,
|
||||
user_id: UserId,
|
||||
old_name: ActivityName,
|
||||
new_name: ActivityName,
|
||||
},
|
||||
ActivityArchived {
|
||||
activity_id: ActivityId,
|
||||
user_id: UserId,
|
||||
},
|
||||
ActivityUnarchived {
|
||||
activity_id: ActivityId,
|
||||
user_id: UserId,
|
||||
},
|
||||
|
||||
ReminderCreated {
|
||||
reminder_id: ReminderId,
|
||||
user_id: UserId,
|
||||
},
|
||||
ReminderUpdated {
|
||||
reminder_id: ReminderId,
|
||||
user_id: UserId,
|
||||
},
|
||||
ReminderDeleted {
|
||||
reminder_id: ReminderId,
|
||||
user_id: UserId,
|
||||
},
|
||||
|
||||
UserRegistered {
|
||||
user_id: UserId,
|
||||
},
|
||||
}
|
||||
34
crates/domain/src/events/event_envelope.rs
Normal file
34
crates/domain/src/events/event_envelope.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{DomainEvent, EventId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventEnvelope {
|
||||
id: EventId,
|
||||
event: DomainEvent,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl EventEnvelope {
|
||||
pub fn wrap(event: DomainEvent) -> Self {
|
||||
Self {
|
||||
id: EventId::generate(),
|
||||
event,
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEnvelope {
|
||||
pub fn id(&self) -> &EventId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn event(&self) -> &DomainEvent {
|
||||
&self.event
|
||||
}
|
||||
|
||||
pub fn occurred_at(&self) -> &DateTime<Utc> {
|
||||
&self.occurred_at
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/events/event_id.rs
Normal file
1
crates/domain/src/events/event_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(EventId);
|
||||
7
crates/domain/src/events/mod.rs
Normal file
7
crates/domain/src/events/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod domain_event;
|
||||
mod event_envelope;
|
||||
mod event_id;
|
||||
|
||||
pub use domain_event::DomainEvent;
|
||||
pub use event_envelope::EventEnvelope;
|
||||
pub use event_id::EventId;
|
||||
18
crates/domain/src/lib.rs
Normal file
18
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
#![allow(clippy::module_inception)]
|
||||
|
||||
pub mod activity;
|
||||
pub mod attachment;
|
||||
pub mod auth;
|
||||
pub mod entry;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod ports;
|
||||
pub mod push;
|
||||
pub mod reminder;
|
||||
pub mod services;
|
||||
pub mod user;
|
||||
|
||||
mod macros;
|
||||
|
||||
#[cfg(feature = "test-helpers")]
|
||||
pub mod testing;
|
||||
43
crates/domain/src/macros.rs
Normal file
43
crates/domain/src/macros.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
macro_rules! uuid_id {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
|
||||
serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
impl $name {
|
||||
pub fn generate() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::generate()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Uuid> for $name {
|
||||
fn from(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use uuid_id;
|
||||
17
crates/domain/src/ports/activity.rs
Normal file
17
crates/domain/src/ports/activity.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::activity::{Activity, ActivityId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ActivityCommandPort: Send + Sync {
|
||||
async fn save(&self, activity: &Activity) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError>;
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ActivityQueryPort: Send + Sync {
|
||||
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError>;
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError>;
|
||||
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError>;
|
||||
}
|
||||
27
crates/domain/src/ports/auth.rs
Normal file
27
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::auth::{GeneratedToken, RefreshSession};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::{PasswordHash, UserId};
|
||||
|
||||
pub trait PasswordHasherPort: Send + Sync {
|
||||
fn hash(&self, raw_password: &str) -> Result<PasswordHash, DomainError>;
|
||||
fn verify(&self, raw_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait AuthServicePort: Send + Sync {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RefreshSessionCommandPort: Send + Sync {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait RefreshSessionQueryPort: Send + Sync {
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
||||
}
|
||||
16
crates/domain/src/ports/cascade.rs
Normal file
16
crates/domain/src/ports/cascade.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::entry::{DateRange, MoodEntry};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CascadeDeletePort: Send + Sync {
|
||||
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
|
||||
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
|
||||
async fn delete_entries_in_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
}
|
||||
54
crates/domain/src/ports/entry.rs
Normal file
54
crates/domain/src/ports/entry.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MoodEntryCommandPort: Send + Sync {
|
||||
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError>;
|
||||
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError>;
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
|
||||
async fn delete_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<u64, DomainError>;
|
||||
|
||||
async fn replace_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
old_activity_id: &ActivityId,
|
||||
new_activity_id: &ActivityId,
|
||||
) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MoodEntryQueryPort: Send + Sync {
|
||||
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError>;
|
||||
async fn find_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn find_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
}
|
||||
7
crates/domain/src/ports/event.rs
Normal file
7
crates/domain/src/ports/event.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::events::EventEnvelope;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait EventPublisherPort: Send + Sync {
|
||||
async fn publish(&self, envelope: EventEnvelope) -> Result<(), DomainError>;
|
||||
}
|
||||
35
crates/domain/src/ports/import_export.rs
Normal file
35
crates/domain/src/ports/import_export.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::activity::Activity;
|
||||
use crate::entry::MoodEntry;
|
||||
use crate::errors::DomainError;
|
||||
use crate::reminder::Reminder;
|
||||
|
||||
pub struct MediaBlob {
|
||||
pub id: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct UserExport {
|
||||
pub entries: Vec<MoodEntry>,
|
||||
pub activities: Vec<Activity>,
|
||||
pub reminders: Vec<Reminder>,
|
||||
pub photos: Vec<MediaBlob>,
|
||||
pub voice_memos: Vec<MediaBlob>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExportPort: Send + Sync {
|
||||
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ImportSourcePort: Send + Sync {
|
||||
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError>;
|
||||
}
|
||||
|
||||
pub struct ImportedRow {
|
||||
pub mood: u8,
|
||||
pub date: String,
|
||||
pub time: String,
|
||||
pub activities: Vec<String>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
17
crates/domain/src/ports/media.rs
Normal file
17
crates/domain/src/ports/media.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub struct MediaFile {
|
||||
pub data: Vec<u8>,
|
||||
pub content_type: ContentType,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MediaStoragePort: Send + Sync {
|
||||
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError>;
|
||||
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError>;
|
||||
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError>;
|
||||
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError>;
|
||||
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError>;
|
||||
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError>;
|
||||
}
|
||||
23
crates/domain/src/ports/mod.rs
Normal file
23
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
mod activity;
|
||||
mod auth;
|
||||
mod cascade;
|
||||
mod entry;
|
||||
mod event;
|
||||
mod import_export;
|
||||
mod media;
|
||||
mod push;
|
||||
mod reminder;
|
||||
mod user;
|
||||
|
||||
pub use activity::{ActivityCommandPort, ActivityQueryPort};
|
||||
pub use auth::{
|
||||
AuthServicePort, PasswordHasherPort, RefreshSessionCommandPort, RefreshSessionQueryPort,
|
||||
};
|
||||
pub use cascade::CascadeDeletePort;
|
||||
pub use entry::{MoodEntryCommandPort, MoodEntryQueryPort};
|
||||
pub use event::EventPublisherPort;
|
||||
pub use import_export::{ExportPort, ImportSourcePort, ImportedRow, MediaBlob, UserExport};
|
||||
pub use media::{MediaFile, MediaStoragePort};
|
||||
pub use push::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
|
||||
pub use reminder::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort};
|
||||
pub use user::{UserCommandPort, UserQueryPort};
|
||||
21
crates/domain/src/ports/push.rs
Normal file
21
crates/domain/src/ports/push.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::push::{PushSubscription, PushSubscriptionId};
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait PushSubscriptionCommandPort: Send + Sync {
|
||||
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError>;
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError>;
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait PushSubscriptionQueryPort: Send + Sync {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError>;
|
||||
|
||||
async fn find_by_endpoint(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
) -> Result<Option<PushSubscription>, DomainError>;
|
||||
}
|
||||
22
crates/domain/src/ports/reminder.rs
Normal file
22
crates/domain/src/ports/reminder.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::reminder::{Reminder, ReminderId};
|
||||
use crate::user::UserId;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ReminderCommandPort: Send + Sync {
|
||||
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError>;
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ReminderQueryPort: Send + Sync {
|
||||
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError>;
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError>;
|
||||
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ReminderSenderPort: Send + Sync {
|
||||
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
15
crates/domain/src/ports/user.rs
Normal file
15
crates/domain/src/ports/user.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::{Email, User, UserId, Username};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UserCommandPort: Send + Sync {
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UserQueryPort: Send + Sync {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
}
|
||||
3
crates/domain/src/push/mod.rs
Normal file
3
crates/domain/src/push/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod push_subscription;
|
||||
|
||||
pub use push_subscription::{PushSubscription, PushSubscriptionId};
|
||||
71
crates/domain/src/push/push_subscription.rs
Normal file
71
crates/domain/src/push/push_subscription.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::macros::uuid_id;
|
||||
use crate::user::UserId;
|
||||
|
||||
uuid_id!(PushSubscriptionId);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PushSubscription {
|
||||
id: PushSubscriptionId,
|
||||
user_id: UserId,
|
||||
endpoint: String,
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PushSubscription {
|
||||
pub fn new(user_id: UserId, endpoint: String, p256dh: String, auth: String) -> Self {
|
||||
Self {
|
||||
id: PushSubscriptionId::generate(),
|
||||
user_id,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: PushSubscriptionId,
|
||||
user_id: UserId,
|
||||
endpoint: String,
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
user_id,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &PushSubscriptionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn p256dh(&self) -> &str {
|
||||
&self.p256dh
|
||||
}
|
||||
|
||||
pub fn auth(&self) -> &str {
|
||||
&self.auth
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
102
crates/domain/src/reminder/day_schedule.rs
Normal file
102
crates/domain/src/reminder/day_schedule.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use chrono::{NaiveTime, Weekday};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DaySchedule {
|
||||
monday: Option<NaiveTime>,
|
||||
tuesday: Option<NaiveTime>,
|
||||
wednesday: Option<NaiveTime>,
|
||||
thursday: Option<NaiveTime>,
|
||||
friday: Option<NaiveTime>,
|
||||
saturday: Option<NaiveTime>,
|
||||
sunday: Option<NaiveTime>,
|
||||
}
|
||||
|
||||
impl DaySchedule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
monday: None,
|
||||
tuesday: None,
|
||||
wednesday: None,
|
||||
thursday: None,
|
||||
friday: None,
|
||||
saturday: None,
|
||||
sunday: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
monday: Option<NaiveTime>,
|
||||
tuesday: Option<NaiveTime>,
|
||||
wednesday: Option<NaiveTime>,
|
||||
thursday: Option<NaiveTime>,
|
||||
friday: Option<NaiveTime>,
|
||||
saturday: Option<NaiveTime>,
|
||||
sunday: Option<NaiveTime>,
|
||||
) -> Self {
|
||||
Self {
|
||||
monday,
|
||||
tuesday,
|
||||
wednesday,
|
||||
thursday,
|
||||
friday,
|
||||
saturday,
|
||||
sunday,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn every_day_at(time: NaiveTime) -> Self {
|
||||
Self {
|
||||
monday: Some(time),
|
||||
tuesday: Some(time),
|
||||
wednesday: Some(time),
|
||||
thursday: Some(time),
|
||||
friday: Some(time),
|
||||
saturday: Some(time),
|
||||
sunday: Some(time),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaySchedule {
|
||||
pub fn time_for(&self, day: Weekday) -> Option<NaiveTime> {
|
||||
match day {
|
||||
Weekday::Mon => self.monday,
|
||||
Weekday::Tue => self.tuesday,
|
||||
Weekday::Wed => self.wednesday,
|
||||
Weekday::Thu => self.thursday,
|
||||
Weekday::Fri => self.friday,
|
||||
Weekday::Sat => self.saturday,
|
||||
Weekday::Sun => self.sunday,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_any_day_set(&self) -> bool {
|
||||
self.monday.is_some()
|
||||
|| self.tuesday.is_some()
|
||||
|| self.wednesday.is_some()
|
||||
|| self.thursday.is_some()
|
||||
|| self.friday.is_some()
|
||||
|| self.saturday.is_some()
|
||||
|| self.sunday.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl DaySchedule {
|
||||
pub fn set_time(&mut self, day: Weekday, time: Option<NaiveTime>) {
|
||||
match day {
|
||||
Weekday::Mon => self.monday = time,
|
||||
Weekday::Tue => self.tuesday = time,
|
||||
Weekday::Wed => self.wednesday = time,
|
||||
Weekday::Thu => self.thursday = time,
|
||||
Weekday::Fri => self.friday = time,
|
||||
Weekday::Sat => self.saturday = time,
|
||||
Weekday::Sun => self.sunday = time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DaySchedule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
7
crates/domain/src/reminder/mod.rs
Normal file
7
crates/domain/src/reminder/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod day_schedule;
|
||||
mod reminder;
|
||||
mod reminder_id;
|
||||
|
||||
pub use day_schedule::DaySchedule;
|
||||
pub use reminder::Reminder;
|
||||
pub use reminder_id::ReminderId;
|
||||
78
crates/domain/src/reminder/reminder.rs
Normal file
78
crates/domain/src/reminder/reminder.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{DaySchedule, ReminderId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reminder {
|
||||
id: ReminderId,
|
||||
user_id: UserId,
|
||||
schedule: DaySchedule,
|
||||
enabled: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
pub fn new(user_id: UserId, schedule: DaySchedule) -> Self {
|
||||
Self {
|
||||
id: ReminderId::generate(),
|
||||
user_id,
|
||||
schedule,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: ReminderId,
|
||||
user_id: UserId,
|
||||
schedule: DaySchedule,
|
||||
enabled: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
user_id,
|
||||
schedule,
|
||||
enabled,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
pub fn id(&self) -> &ReminderId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn schedule(&self) -> &DaySchedule {
|
||||
&self.schedule
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
pub fn update_schedule(&mut self, schedule: DaySchedule) {
|
||||
self.schedule = schedule;
|
||||
}
|
||||
|
||||
pub fn enable(&mut self) {
|
||||
self.enabled = true;
|
||||
}
|
||||
|
||||
pub fn disable(&mut self) {
|
||||
self.enabled = false;
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/reminder/reminder_id.rs
Normal file
1
crates/domain/src/reminder/reminder_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(ReminderId);
|
||||
3
crates/domain/src/services/mod.rs
Normal file
3
crates/domain/src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod mood_analyzer;
|
||||
|
||||
pub use mood_analyzer::MoodAnalyzerService;
|
||||
95
crates/domain/src/services/mood_analyzer.rs
Normal file
95
crates/domain/src/services/mood_analyzer.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{Mood, MoodEntry};
|
||||
|
||||
pub struct MoodAnalyzerService;
|
||||
|
||||
impl MoodAnalyzerService {
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn average_mood(entries: &[MoodEntry]) -> Option<f64> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sum: u32 = entries.iter().map(|e| e.mood().value() as u32).sum();
|
||||
Some(sum as f64 / entries.len() as f64)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn mood_frequency(entries: &[MoodEntry]) -> Vec<(Mood, usize)> {
|
||||
let mut counts = [0usize; 5];
|
||||
for entry in entries {
|
||||
let index = entry.mood().value() as usize - 1;
|
||||
counts[index] += 1;
|
||||
}
|
||||
|
||||
let moods = [Mood::Awful, Mood::Bad, Mood::Meh, Mood::Good, Mood::Rad];
|
||||
moods
|
||||
.into_iter()
|
||||
.zip(counts)
|
||||
.filter(|(_, count)| *count > 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn current_streak(entries: &[MoodEntry]) -> usize {
|
||||
if entries.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut dates: Vec<_> = entries.iter().map(|e| e.logged_at().date_naive()).collect();
|
||||
dates.sort();
|
||||
dates.dedup();
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
|
||||
let last_date = *dates.last().unwrap();
|
||||
let diff_to_today = (today - last_date).num_days();
|
||||
if diff_to_today > 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut streak = 1;
|
||||
for window in dates.windows(2).rev() {
|
||||
let diff = window[1] - window[0];
|
||||
if diff.num_days() == 1 {
|
||||
streak += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
streak
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
|
||||
pub fn activity_mood_correlation(
|
||||
entries: &[MoodEntry],
|
||||
activity_id: &ActivityId,
|
||||
) -> Option<f64> {
|
||||
let with: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| e.activities().contains(activity_id))
|
||||
.collect();
|
||||
|
||||
let without: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| !e.activities().contains(activity_id))
|
||||
.collect();
|
||||
|
||||
let avg_with = Self::average_mood_from_refs(&with)?;
|
||||
let avg_without = Self::average_mood_from_refs(&without)?;
|
||||
|
||||
Some(avg_with - avg_without)
|
||||
}
|
||||
|
||||
fn average_mood_from_refs(entries: &[&MoodEntry]) -> Option<f64> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sum: u32 = entries.iter().map(|e| e.mood().value() as u32).sum();
|
||||
Some(sum as f64 / entries.len() as f64)
|
||||
}
|
||||
}
|
||||
65
crates/domain/src/testing/factories.rs
Normal file
65
crates/domain/src/testing/factories.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use chrono::{DateTime, FixedOffset, NaiveTime, TimeZone, Utc};
|
||||
|
||||
use crate::activity::{Activity, ActivityName, CategoryName};
|
||||
use crate::attachment::ContentType;
|
||||
use crate::entry::{Content, DateRange, Mood, MoodEntry};
|
||||
use crate::reminder::{DaySchedule, Reminder};
|
||||
use crate::user::{Email, PasswordHash, User, UserId, Username};
|
||||
|
||||
pub fn test_user(name: &str) -> User {
|
||||
User::new(
|
||||
Username::new(name).unwrap(),
|
||||
Email::new(format!("{name}@example.com")).unwrap(),
|
||||
PasswordHash::new(format!("hashed:{name}")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn test_entry(user_id: UserId, mood: Mood) -> MoodEntry {
|
||||
MoodEntry::new(user_id, mood, test_logged_at())
|
||||
}
|
||||
|
||||
pub fn test_entry_days_ago(user_id: UserId, mood: Mood, days_ago: i64) -> MoodEntry {
|
||||
let offset = FixedOffset::east_opt(0).unwrap();
|
||||
let logged_at =
|
||||
offset.from_utc_datetime(&(Utc::now() - chrono::Duration::days(days_ago)).naive_utc());
|
||||
MoodEntry::new(user_id, mood, logged_at)
|
||||
}
|
||||
|
||||
pub fn test_activity(user_id: UserId, name: &str) -> Activity {
|
||||
Activity::new(user_id, ActivityName::new(name).unwrap(), None)
|
||||
}
|
||||
|
||||
pub fn test_activity_with_category(user_id: UserId, name: &str, category: &str) -> Activity {
|
||||
Activity::new(
|
||||
user_id,
|
||||
ActivityName::new(name).unwrap(),
|
||||
Some(CategoryName::new(category).unwrap()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn test_reminder(user_id: UserId) -> Reminder {
|
||||
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
|
||||
Reminder::new(user_id, DaySchedule::every_day_at(time))
|
||||
}
|
||||
|
||||
pub fn test_content(text: &str) -> Content {
|
||||
Content::new(text).unwrap()
|
||||
}
|
||||
|
||||
pub fn test_content_type(mime: &str) -> ContentType {
|
||||
ContentType::new(mime).unwrap()
|
||||
}
|
||||
|
||||
pub fn test_logged_at() -> DateTime<FixedOffset> {
|
||||
FixedOffset::east_opt(3600)
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(2025, 6, 15, 20, 0, 0)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn test_date_range_last_n_days(days: i64) -> DateRange {
|
||||
let offset = FixedOffset::east_opt(0).unwrap();
|
||||
let now = offset.from_utc_datetime(&Utc::now().naive_utc());
|
||||
let start = offset.from_utc_datetime(&(Utc::now() - chrono::Duration::days(days)).naive_utc());
|
||||
DateRange::new(start, now).unwrap()
|
||||
}
|
||||
11
crates/domain/src/testing/fakes/mod.rs
Normal file
11
crates/domain/src/testing/fakes/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod password_hasher;
|
||||
mod store;
|
||||
mod store_activity;
|
||||
mod store_auth;
|
||||
mod store_entry;
|
||||
mod store_infra;
|
||||
mod store_reminder;
|
||||
mod store_user;
|
||||
|
||||
pub use password_hasher::FakePasswordHasher;
|
||||
pub use store::InMemoryStore;
|
||||
14
crates/domain/src/testing/fakes/password_hasher.rs
Normal file
14
crates/domain/src/testing/fakes/password_hasher.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::PasswordHash;
|
||||
|
||||
pub struct FakePasswordHasher;
|
||||
|
||||
impl crate::ports::PasswordHasherPort for FakePasswordHasher {
|
||||
fn hash(&self, raw_password: &str) -> Result<PasswordHash, DomainError> {
|
||||
Ok(PasswordHash::new(format!("hashed:{raw_password}")))
|
||||
}
|
||||
|
||||
fn verify(&self, raw_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(hash.value() == format!("hashed:{raw_password}"))
|
||||
}
|
||||
}
|
||||
66
crates/domain/src/testing/fakes/store.rs
Normal file
66
crates/domain/src/testing/fakes/store.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::activity::{Activity, ActivityId};
|
||||
use crate::auth::RefreshSession;
|
||||
use crate::entry::{MoodEntry, MoodEntryId};
|
||||
use crate::events::EventEnvelope;
|
||||
use crate::push::{PushSubscription, PushSubscriptionId};
|
||||
use crate::reminder::{Reminder, ReminderId};
|
||||
use crate::user::{User, UserId};
|
||||
|
||||
pub struct InMemoryStore {
|
||||
pub(super) entries: RwLock<HashMap<MoodEntryId, MoodEntry>>,
|
||||
pub(super) activities: RwLock<HashMap<ActivityId, Activity>>,
|
||||
pub(super) users: RwLock<HashMap<UserId, User>>,
|
||||
pub(super) reminders: RwLock<HashMap<ReminderId, Reminder>>,
|
||||
pub refresh_sessions: RwLock<Vec<RefreshSession>>,
|
||||
pub(super) push_subscriptions: RwLock<HashMap<PushSubscriptionId, PushSubscription>>,
|
||||
pub(super) events: RwLock<Vec<EventEnvelope>>,
|
||||
pub(super) sent_reminders: RwLock<Vec<UserId>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
activities: RwLock::new(HashMap::new()),
|
||||
users: RwLock::new(HashMap::new()),
|
||||
reminders: RwLock::new(HashMap::new()),
|
||||
refresh_sessions: RwLock::new(Vec::new()),
|
||||
push_subscriptions: RwLock::new(HashMap::new()),
|
||||
events: RwLock::new(Vec::new()),
|
||||
sent_reminders: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn published_events(&self) -> Vec<EventEnvelope> {
|
||||
self.events.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn sent_reminders(&self) -> Vec<UserId> {
|
||||
self.sent_reminders.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn activity_count(&self) -> usize {
|
||||
self.activities.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn reminder_count(&self) -> usize {
|
||||
self.reminders.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn user_count(&self) -> usize {
|
||||
self.users.read().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
58
crates/domain/src/testing/fakes/store_activity.rs
Normal file
58
crates/domain/src/testing/fakes/store_activity.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use crate::activity::{Activity, ActivityId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ActivityCommandPort for InMemoryStore {
|
||||
async fn save(&self, activity: &Activity) -> Result<(), DomainError> {
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(activity.id().clone(), activity.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError> {
|
||||
self.activities.write().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, a| a.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ActivityQueryPort for InMemoryStore {
|
||||
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError> {
|
||||
Ok(self.activities.read().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
Ok(self
|
||||
.activities
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|a| a.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
Ok(self
|
||||
.activities
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|a| a.user_id() == user_id && !a.is_archived())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
72
crates/domain/src/testing/fakes/store_auth.rs
Normal file
72
crates/domain/src/testing/fakes/store_auth.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use crate::auth::{GeneratedToken, RefreshSession};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::AuthServicePort for InMemoryStore {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
let token = format!("fake-jwt-{}", user_id.value());
|
||||
let expires_at = Utc::now() + Duration::hours(1);
|
||||
Ok(GeneratedToken::new(token, expires_at))
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
let uuid_str = token
|
||||
.strip_prefix("fake-jwt-")
|
||||
.ok_or_else(|| DomainError::Unauthorized("invalid token".into()))?;
|
||||
|
||||
let uuid: uuid::Uuid = uuid_str
|
||||
.parse()
|
||||
.map_err(|_| DomainError::Unauthorized("invalid token".into()))?;
|
||||
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::RefreshSessionCommandPort for InMemoryStore {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
self.refresh_sessions.write().unwrap().push(session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.token() != token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let mut sessions = self.refresh_sessions.write().unwrap();
|
||||
let before = sessions.len();
|
||||
sessions.retain(|s| !s.is_expired());
|
||||
Ok((before - sessions.len()) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::RefreshSessionQueryPort for InMemoryStore {
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
Ok(self
|
||||
.refresh_sessions
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.token() == token)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
160
crates/domain/src/testing/fakes/store_entry.rs
Normal file
160
crates/domain/src/testing/fakes/store_entry.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::MoodEntryCommandPort for InMemoryStore {
|
||||
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(entry.id().clone(), entry.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
|
||||
let mut store = self.entries.write().unwrap();
|
||||
for entry in entries {
|
||||
store.insert(entry.id().clone(), entry.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError> {
|
||||
self.entries.write().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, e| e.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<u64, DomainError> {
|
||||
let mut store = self.entries.write().unwrap();
|
||||
let before = store.len();
|
||||
store.retain(|_, e| {
|
||||
e.user_id() != user_id || e.logged_at() < range.start() || e.logged_at() > range.end()
|
||||
});
|
||||
Ok((before - store.len()) as u64)
|
||||
}
|
||||
|
||||
async fn replace_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
old_activity_id: &ActivityId,
|
||||
new_activity_id: &ActivityId,
|
||||
) -> Result<u64, DomainError> {
|
||||
let mut store = self.entries.write().unwrap();
|
||||
let mut count = 0u64;
|
||||
for entry in store.values_mut() {
|
||||
if entry.user_id() != user_id {
|
||||
continue;
|
||||
}
|
||||
let activities: Vec<_> = entry.activities().to_vec();
|
||||
if activities.contains(old_activity_id) {
|
||||
let replaced: Vec<_> = activities
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
if &a == old_activity_id {
|
||||
new_activity_id.clone()
|
||||
} else {
|
||||
a
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
entry.set_activities(replaced);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::MoodEntryQueryPort for InMemoryStore {
|
||||
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError> {
|
||||
Ok(self.entries.read().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let mut entries: Vec<_> = self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let offset = offset.unwrap_or(0) as usize;
|
||||
let limit = limit.unwrap_or(i64::MAX) as usize;
|
||||
entries = entries.into_iter().skip(offset).take(limit).collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn find_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.user_id() == user_id
|
||||
&& e.logged_at() >= range.start()
|
||||
&& e.logged_at() <= range.end()
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.user_id() == user_id && e.mood() == mood)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.user_id() == user_id && e.activities().contains(activity_id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
191
crates/domain/src/testing/fakes/store_infra.rs
Normal file
191
crates/domain/src/testing/fakes/store_infra.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use crate::attachment::{MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::entry::{DateRange, MoodEntry};
|
||||
use crate::errors::DomainError;
|
||||
use crate::events::EventEnvelope;
|
||||
use crate::ports::ImportedRow;
|
||||
use crate::push::{PushSubscription, PushSubscriptionId};
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::EventPublisherPort for InMemoryStore {
|
||||
async fn publish(&self, envelope: EventEnvelope) -> Result<(), DomainError> {
|
||||
self.events.write().unwrap().push(envelope);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::MediaStoragePort for InMemoryStore {
|
||||
async fn store_photo(&self, _upload: MediaUpload) -> Result<PhotoId, DomainError> {
|
||||
Ok(PhotoId::generate())
|
||||
}
|
||||
|
||||
async fn store_voice_memo(&self, _upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
|
||||
Ok(VoiceMemoId::generate())
|
||||
}
|
||||
|
||||
async fn get_photo(
|
||||
&self,
|
||||
_id: &PhotoId,
|
||||
) -> Result<Option<crate::ports::MediaFile>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_voice_memo(
|
||||
&self,
|
||||
_id: &VoiceMemoId,
|
||||
) -> Result<Option<crate::ports::MediaFile>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn delete_photo(&self, _id: &PhotoId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_voice_memo(&self, _id: &VoiceMemoId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::CascadeDeletePort for InMemoryStore {
|
||||
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, e| e.user_id() != user_id);
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, a| a.user_id() != user_id);
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, r| r.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, e| e.user_id() != user_id);
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, a| a.user_id() != user_id);
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, r| r.user_id() != user_id);
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.user_id() != user_id);
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.user_id() != user_id);
|
||||
self.users.write().unwrap().remove(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_entries_in_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let mut entries = self.entries.write().unwrap();
|
||||
let mut removed = Vec::new();
|
||||
entries.retain(|_, e| {
|
||||
if e.user_id() == user_id
|
||||
&& e.logged_at() >= range.start()
|
||||
&& e.logged_at() <= range.end()
|
||||
{
|
||||
removed.push(e.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::PushSubscriptionCommandPort for InMemoryStore {
|
||||
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError> {
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(subscription.id().clone(), subscription.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError> {
|
||||
self.push_subscriptions.write().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.endpoint() != endpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::PushSubscriptionQueryPort for InMemoryStore {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError> {
|
||||
Ok(self
|
||||
.push_subscriptions
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|s| s.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_endpoint(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
) -> Result<Option<PushSubscription>, DomainError> {
|
||||
Ok(self
|
||||
.push_subscriptions
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|s| s.endpoint() == endpoint)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ExportPort for InMemoryStore {
|
||||
async fn export_user_data(
|
||||
&self,
|
||||
_data: &crate::ports::UserExport,
|
||||
) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(b"exported".to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ImportSourcePort for InMemoryStore {
|
||||
async fn read_entries(&self, _data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
66
crates/domain/src/testing/fakes/store_reminder.rs
Normal file
66
crates/domain/src/testing/fakes/store_reminder.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::reminder::{Reminder, ReminderId};
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ReminderCommandPort for InMemoryStore {
|
||||
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(reminder.id().clone(), reminder.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError> {
|
||||
self.reminders.write().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, r| r.user_id() != user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ReminderQueryPort for InMemoryStore {
|
||||
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError> {
|
||||
Ok(self.reminders.read().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError> {
|
||||
Ok(self
|
||||
.reminders
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|r| r.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError> {
|
||||
Ok(self
|
||||
.reminders
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|r| r.is_enabled())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::ReminderSenderPort for InMemoryStore {
|
||||
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.sent_reminders.write().unwrap().push(user_id.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
47
crates/domain/src/testing/fakes/store_user.rs
Normal file
47
crates/domain/src/testing/fakes/store_user.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::{Email, User, UserId, Username};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::UserCommandPort for InMemoryStore {
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.users
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(user.id().clone(), user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
self.users.write().unwrap().remove(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::UserQueryPort for InMemoryStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
Ok(self.users.read().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
Ok(self
|
||||
.users
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.username() == username)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
Ok(self
|
||||
.users
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.email() == email)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
5
crates/domain/src/testing/mod.rs
Normal file
5
crates/domain/src/testing/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod factories;
|
||||
mod fakes;
|
||||
|
||||
pub use factories::*;
|
||||
pub use fakes::{FakePasswordHasher, InMemoryStore};
|
||||
31
crates/domain/src/user/display_name.rs
Normal file
31
crates/domain/src/user/display_name.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const MAX_LENGTH: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DisplayName(String);
|
||||
|
||||
impl DisplayName {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = name.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"display name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if trimmed.len() > MAX_LENGTH {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"display name must be at most {MAX_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(name: String) -> Self {
|
||||
Self(name)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
25
crates/domain/src/user/email.rs
Normal file
25
crates/domain/src/user/email.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
pub fn new(email: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let email = email.into().trim().to_lowercase();
|
||||
|
||||
email_address::EmailAddress::parse_with_options(&email, email_address::Options::default())
|
||||
.map_err(|_| DomainError::InvalidInput(format!("invalid email address: {email}")))?;
|
||||
|
||||
Ok(Self(email))
|
||||
}
|
||||
|
||||
pub fn from_persistence(email: String) -> Self {
|
||||
Self(email)
|
||||
}
|
||||
}
|
||||
|
||||
impl Email {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
17
crates/domain/src/user/mod.rs
Normal file
17
crates/domain/src/user/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod display_name;
|
||||
mod email;
|
||||
mod password_hash;
|
||||
mod timezone;
|
||||
mod user;
|
||||
mod user_id;
|
||||
mod user_role;
|
||||
mod username;
|
||||
|
||||
pub use display_name::DisplayName;
|
||||
pub use email::Email;
|
||||
pub use password_hash::PasswordHash;
|
||||
pub use timezone::Timezone;
|
||||
pub use user::{User, UserData};
|
||||
pub use user_id::UserId;
|
||||
pub use user_role::UserRole;
|
||||
pub use username::Username;
|
||||
14
crates/domain/src/user/password_hash.rs
Normal file
14
crates/domain/src/user/password_hash.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn new(hash: String) -> Self {
|
||||
Self(hash)
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
27
crates/domain/src/user/timezone.rs
Normal file
27
crates/domain/src/user/timezone.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Timezone(String);
|
||||
|
||||
impl Timezone {
|
||||
pub fn new(tz: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let trimmed = tz.into().trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidInput("timezone cannot be empty".into()));
|
||||
}
|
||||
if !trimmed.contains('/') {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"timezone must be in IANA format (e.g. Europe/Warsaw)".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn from_persistence(tz: String) -> Self {
|
||||
Self(tz)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
127
crates/domain/src/user/user.rs
Normal file
127
crates/domain/src/user/user.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{DisplayName, Email, PasswordHash, Timezone, UserId, UserRole, Username};
|
||||
|
||||
pub struct UserData {
|
||||
pub id: UserId,
|
||||
pub username: Username,
|
||||
pub email: Email,
|
||||
pub password_hash: PasswordHash,
|
||||
pub display_name: Option<DisplayName>,
|
||||
pub timezone: Option<Timezone>,
|
||||
pub role: UserRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
id: UserId,
|
||||
username: Username,
|
||||
email: Email,
|
||||
password_hash: PasswordHash,
|
||||
display_name: Option<DisplayName>,
|
||||
timezone: Option<Timezone>,
|
||||
role: UserRole,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(username: Username, email: Email, password_hash: PasswordHash) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
display_name: None,
|
||||
timezone: None,
|
||||
role: UserRole::default(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(data: UserData) -> Self {
|
||||
Self {
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
password_hash: data.password_hash,
|
||||
display_name: data.display_name,
|
||||
timezone: data.timezone,
|
||||
role: data.role,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn id(&self) -> &UserId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &Username {
|
||||
&self.username
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &Email {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password_hash(&self) -> &PasswordHash {
|
||||
&self.password_hash
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> Option<&DisplayName> {
|
||||
self.display_name.as_ref()
|
||||
}
|
||||
|
||||
pub fn timezone(&self) -> Option<&Timezone> {
|
||||
self.timezone.as_ref()
|
||||
}
|
||||
|
||||
pub fn role(&self) -> UserRole {
|
||||
self.role
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> &DateTime<Utc> {
|
||||
&self.updated_at
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == UserRole::Admin
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn update_display_name(&mut self, display_name: Option<DisplayName>) {
|
||||
self.display_name = display_name;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn update_timezone(&mut self, timezone: Option<Timezone>) {
|
||||
self.timezone = timezone;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn update_password(&mut self, password_hash: PasswordHash) {
|
||||
self.password_hash = password_hash;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn promote_to_admin(&mut self) {
|
||||
self.role = UserRole::Admin;
|
||||
self.touch();
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
1
crates/domain/src/user/user_id.rs
Normal file
1
crates/domain/src/user/user_id.rs
Normal file
@@ -0,0 +1 @@
|
||||
crate::macros::uuid_id!(UserId);
|
||||
8
crates/domain/src/user/user_role.rs
Normal file
8
crates/domain/src/user/user_role.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub enum UserRole {
|
||||
Admin,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
41
crates/domain/src/user/username.rs
Normal file
41
crates/domain/src/user/username.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
const MIN_LENGTH: usize = 3;
|
||||
const MAX_LENGTH: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl Username {
|
||||
pub fn new(username: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let username = username.into().trim().to_string();
|
||||
|
||||
if username.len() < MIN_LENGTH || username.len() > MAX_LENGTH {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"username must be between {MIN_LENGTH} and {MAX_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let is_valid = username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
|
||||
if !is_valid {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"username may only contain alphanumeric characters, underscores, and dots".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self(username))
|
||||
}
|
||||
|
||||
pub fn from_persistence(username: String) -> Self {
|
||||
Self(username)
|
||||
}
|
||||
}
|
||||
|
||||
impl Username {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
8
crates/domain/tests/activity.rs
Normal file
8
crates/domain/tests/activity.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[path = "activity/activity_name_test.rs"]
|
||||
mod activity_name_test;
|
||||
|
||||
#[path = "activity/activity_test.rs"]
|
||||
mod activity_test;
|
||||
|
||||
#[path = "activity/category_name_test.rs"]
|
||||
mod category_name_test;
|
||||
26
crates/domain/tests/activity/activity_name_test.rs
Normal file
26
crates/domain/tests/activity/activity_name_test.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use domain::activity::ActivityName;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn valid_name_is_created() {
|
||||
let name = ActivityName::new("exercise").unwrap();
|
||||
assert_eq!(name.value(), "exercise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_trims_whitespace() {
|
||||
let name = ActivityName::new(" friends ").unwrap();
|
||||
assert_eq!(name.value(), "friends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_is_rejected() {
|
||||
let result = ActivityName::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_name_is_rejected() {
|
||||
let result = ActivityName::new(" ");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
99
crates/domain/tests/activity/activity_test.rs
Normal file
99
crates/domain/tests/activity/activity_test.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use domain::activity::{Activity, ActivityName, CategoryName};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[test]
|
||||
fn new_activity_is_not_archived() {
|
||||
let category = CategoryName::new("hobbies").unwrap();
|
||||
let activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
Some(category.clone()),
|
||||
);
|
||||
|
||||
assert!(!activity.is_archived());
|
||||
assert_eq!(activity.name().value(), "gaming");
|
||||
assert_eq!(activity.category().map(|c| c.value()), Some("hobbies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_sets_archived_flag() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
activity.archive().unwrap();
|
||||
|
||||
assert!(activity.is_archived());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archiving_already_archived_activity_fails() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
activity.archive().unwrap();
|
||||
let result = activity.archive();
|
||||
|
||||
assert!(matches!(result, Err(DomainError::Conflict(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_clears_archived_flag() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
activity.archive().unwrap();
|
||||
activity.unarchive().unwrap();
|
||||
|
||||
assert!(!activity.is_archived());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchiving_active_activity_fails() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
let result = activity.unarchive();
|
||||
|
||||
assert!(matches!(result, Err(DomainError::Conflict(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_updates_name() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
activity.rename(ActivityName::new("video games").unwrap());
|
||||
|
||||
assert_eq!(activity.name().value(), "video games");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_category_updates_category() {
|
||||
let mut activity = Activity::new(
|
||||
UserId::generate(),
|
||||
ActivityName::new("gaming").unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
activity.set_category(Some(CategoryName::new("hobbies").unwrap()));
|
||||
assert_eq!(activity.category().map(|c| c.value()), Some("hobbies"));
|
||||
|
||||
activity.set_category(None);
|
||||
assert_eq!(activity.category(), None);
|
||||
}
|
||||
32
crates/domain/tests/activity/category_name_test.rs
Normal file
32
crates/domain/tests/activity/category_name_test.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use domain::activity::CategoryName;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn valid_category_name_is_created() {
|
||||
let name = CategoryName::new("hobbies").unwrap();
|
||||
assert_eq!(name.value(), "hobbies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_name_trims_whitespace() {
|
||||
let name = CategoryName::new(" social ").unwrap();
|
||||
assert_eq!(name.value(), "social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_category_name_is_rejected() {
|
||||
let result = CategoryName::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_category_name_is_rejected() {
|
||||
let result = CategoryName::new(" ");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_bypasses_validation() {
|
||||
let name = CategoryName::from_persistence(String::new());
|
||||
assert_eq!(name.value(), "");
|
||||
}
|
||||
5
crates/domain/tests/attachment.rs
Normal file
5
crates/domain/tests/attachment.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[path = "attachment/content_type_test.rs"]
|
||||
mod content_type_test;
|
||||
|
||||
#[path = "attachment/media_upload_test.rs"]
|
||||
mod media_upload_test;
|
||||
38
crates/domain/tests/attachment/content_type_test.rs
Normal file
38
crates/domain/tests/attachment/content_type_test.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use domain::attachment::ContentType;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn valid_content_type_is_created() {
|
||||
let ct = ContentType::new("image/jpeg").unwrap();
|
||||
assert_eq!(ct.value(), "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_is_lowercased() {
|
||||
let ct = ContentType::new("Image/JPEG").unwrap();
|
||||
assert_eq!(ct.value(), "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_trims_whitespace() {
|
||||
let ct = ContentType::new(" audio/webm ").unwrap();
|
||||
assert_eq!(ct.value(), "audio/webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_type_is_rejected() {
|
||||
let result = ContentType::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_without_slash_is_rejected() {
|
||||
let result = ContentType::new("jpeg");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_bypasses_validation() {
|
||||
let ct = ContentType::from_persistence(String::new());
|
||||
assert_eq!(ct.value(), "");
|
||||
}
|
||||
19
crates/domain/tests/attachment/media_upload_test.rs
Normal file
19
crates/domain/tests/attachment/media_upload_test.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use domain::attachment::{ContentType, MediaUpload};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn valid_upload_is_created() {
|
||||
let ct = ContentType::new("image/png").unwrap();
|
||||
let upload = MediaUpload::new(vec![1, 2, 3], ct).unwrap();
|
||||
|
||||
assert_eq!(upload.data(), &[1, 2, 3]);
|
||||
assert_eq!(upload.content_type().value(), "image/png");
|
||||
assert_eq!(upload.size(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_data_is_rejected() {
|
||||
let ct = ContentType::new("image/png").unwrap();
|
||||
let result = MediaUpload::new(vec![], ct);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
2
crates/domain/tests/auth.rs
Normal file
2
crates/domain/tests/auth.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "auth/refresh_session_test.rs"]
|
||||
mod refresh_session_test;
|
||||
37
crates/domain/tests/auth/refresh_session_test.rs
Normal file
37
crates/domain/tests/auth/refresh_session_test.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use domain::auth::{RefreshSession, RefreshSessionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[test]
|
||||
fn new_session_is_not_expired() {
|
||||
let session = RefreshSession::new(UserId::generate(), 3600);
|
||||
assert!(!session.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_with_past_expiry_is_expired() {
|
||||
let session = RefreshSession::from_persistence(
|
||||
RefreshSessionId::generate(),
|
||||
UserId::generate(),
|
||||
"token".into(),
|
||||
Utc::now() - Duration::seconds(1),
|
||||
Utc::now() - Duration::seconds(3600),
|
||||
);
|
||||
assert!(session.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_has_unique_token() {
|
||||
let user_id = UserId::generate();
|
||||
let a = RefreshSession::new(user_id.clone(), 3600);
|
||||
let b = RefreshSession::new(user_id, 3600);
|
||||
assert_ne!(a.token(), b.token());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stores_user_id() {
|
||||
let user_id = UserId::generate();
|
||||
let session = RefreshSession::new(user_id.clone(), 3600);
|
||||
assert_eq!(*session.user_id(), user_id);
|
||||
}
|
||||
11
crates/domain/tests/entry.rs
Normal file
11
crates/domain/tests/entry.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[path = "entry/mood_test.rs"]
|
||||
mod mood_test;
|
||||
|
||||
#[path = "entry/content_test.rs"]
|
||||
mod content_test;
|
||||
|
||||
#[path = "entry/mood_entry_test.rs"]
|
||||
mod mood_entry_test;
|
||||
|
||||
#[path = "entry/date_range_test.rs"]
|
||||
mod date_range_test;
|
||||
32
crates/domain/tests/entry/content_test.rs
Normal file
32
crates/domain/tests/entry/content_test.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use domain::entry::Content;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn valid_content_is_created() {
|
||||
let content = Content::new("went for a walk").unwrap();
|
||||
assert_eq!(content.value(), "went for a walk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_trims_whitespace() {
|
||||
let content = Content::new(" hello world ").unwrap();
|
||||
assert_eq!(content.value(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_rejected() {
|
||||
let result = Content::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_content_is_rejected() {
|
||||
let result = Content::new(" ");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_bypasses_validation() {
|
||||
let content = Content::from_persistence(String::new());
|
||||
assert_eq!(content.value(), "");
|
||||
}
|
||||
36
crates/domain/tests/entry/date_range_test.rs
Normal file
36
crates/domain/tests/entry/date_range_test.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
|
||||
use domain::entry::DateRange;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
fn utc_dt(year: i32, month: u32, day: u32) -> chrono::DateTime<FixedOffset> {
|
||||
FixedOffset::east_opt(0)
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(year, month, day, 0, 0, 0)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_range_is_created() {
|
||||
let start = utc_dt(2025, 1, 1);
|
||||
let end = utc_dt(2025, 1, 31);
|
||||
let range = DateRange::new(start, end).unwrap();
|
||||
|
||||
assert_eq!(*range.start(), start);
|
||||
assert_eq!(*range.end(), end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_equal_to_end_is_valid() {
|
||||
let dt = utc_dt(2025, 6, 15);
|
||||
let range = DateRange::new(dt, dt);
|
||||
assert!(range.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_after_end_is_rejected() {
|
||||
let start = utc_dt(2025, 3, 15);
|
||||
let end = utc_dt(2025, 3, 1);
|
||||
let result = DateRange::new(start, end);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
136
crates/domain/tests/entry/mood_entry_test.rs
Normal file
136
crates/domain/tests/entry/mood_entry_test.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use chrono::{FixedOffset, TimeZone, Utc};
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData};
|
||||
use domain::user::UserId;
|
||||
|
||||
fn sample_logged_at() -> chrono::DateTime<FixedOffset> {
|
||||
FixedOffset::east_opt(3600)
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(2025, 3, 15, 20, 0, 0)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_entry_has_required_fields() {
|
||||
let user_id = UserId::generate();
|
||||
let entry = MoodEntry::new(user_id.clone(), Mood::Good, sample_logged_at());
|
||||
|
||||
assert_eq!(*entry.user_id(), user_id);
|
||||
assert_eq!(entry.mood(), Mood::Good);
|
||||
assert!(entry.activities().is_empty());
|
||||
assert!(entry.content().is_none());
|
||||
assert!(entry.photos().is_empty());
|
||||
assert!(entry.voice_memos().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_changes_value_and_touches_updated_at() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
|
||||
let before = *entry.updated_at();
|
||||
|
||||
entry.update_mood(Mood::Rad);
|
||||
|
||||
assert_eq!(entry.mood(), Mood::Rad);
|
||||
assert!(*entry.updated_at() >= before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_activities_deduplicates_and_sorts() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Meh, sample_logged_at());
|
||||
let a = ActivityId::generate();
|
||||
let b = ActivityId::generate();
|
||||
|
||||
entry.set_activities(vec![b.clone(), a.clone(), b.clone()]);
|
||||
|
||||
assert_eq!(entry.activities().len(), 2);
|
||||
let ids = entry.activities();
|
||||
assert!(ids[0] <= ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_content_updates_and_clears() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
|
||||
|
||||
let content = Content::new("great day").unwrap();
|
||||
entry.set_content(Some(content));
|
||||
assert!(entry.content().is_some());
|
||||
|
||||
entry.set_content(None);
|
||||
assert!(entry.content().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_reconstructs_all_fields() {
|
||||
let id = domain::entry::MoodEntryId::generate();
|
||||
let user_id = UserId::generate();
|
||||
let activity_id = ActivityId::generate();
|
||||
let photo_id = PhotoId::generate();
|
||||
let voice_memo_id = VoiceMemoId::generate();
|
||||
let content = Content::new("test").unwrap();
|
||||
let now = Utc::now();
|
||||
|
||||
let entry = MoodEntry::from_persistence(MoodEntryData {
|
||||
id: id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
mood: Mood::Bad,
|
||||
logged_at: sample_logged_at(),
|
||||
activities: vec![activity_id.clone()],
|
||||
content: Some(content),
|
||||
photos: vec![photo_id.clone()],
|
||||
voice_memos: vec![voice_memo_id.clone()],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
assert_eq!(*entry.id(), id);
|
||||
assert_eq!(*entry.user_id(), user_id);
|
||||
assert_eq!(entry.mood(), Mood::Bad);
|
||||
assert_eq!(entry.activities().len(), 1);
|
||||
assert!(entry.content().is_some());
|
||||
assert_eq!(entry.photos().len(), 1);
|
||||
assert_eq!(entry.voice_memos().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_logged_at_changes_timestamp() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
|
||||
|
||||
let new_time = FixedOffset::east_opt(3600)
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(2025, 6, 1, 12, 0, 0)
|
||||
.unwrap();
|
||||
entry.update_logged_at(new_time);
|
||||
|
||||
assert_eq!(*entry.logged_at(), new_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_photos_replaces_list() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
|
||||
|
||||
let p1 = PhotoId::generate();
|
||||
let p2 = PhotoId::generate();
|
||||
entry.set_photos(vec![p1.clone(), p2.clone()]);
|
||||
assert_eq!(entry.photos().len(), 2);
|
||||
|
||||
entry.set_photos(vec![p1.clone()]);
|
||||
assert_eq!(entry.photos().len(), 1);
|
||||
|
||||
entry.set_photos(vec![]);
|
||||
assert!(entry.photos().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_voice_memos_replaces_list() {
|
||||
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
|
||||
|
||||
let m1 = VoiceMemoId::generate();
|
||||
let m2 = VoiceMemoId::generate();
|
||||
entry.set_voice_memos(vec![m1.clone(), m2.clone()]);
|
||||
assert_eq!(entry.voice_memos().len(), 2);
|
||||
|
||||
entry.set_voice_memos(vec![]);
|
||||
assert!(entry.voice_memos().is_empty());
|
||||
}
|
||||
40
crates/domain/tests/entry/mood_test.rs
Normal file
40
crates/domain/tests/entry/mood_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use domain::entry::Mood;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[test]
|
||||
fn mood_values_map_to_expected_scale() {
|
||||
assert_eq!(Mood::Awful.value(), 1);
|
||||
assert_eq!(Mood::Bad.value(), 2);
|
||||
assert_eq!(Mood::Meh.value(), 3);
|
||||
assert_eq!(Mood::Good.value(), 4);
|
||||
assert_eq!(Mood::Rad.value(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_ordering_reflects_scale() {
|
||||
assert!(Mood::Awful < Mood::Bad);
|
||||
assert!(Mood::Bad < Mood::Meh);
|
||||
assert!(Mood::Meh < Mood::Good);
|
||||
assert!(Mood::Good < Mood::Rad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_values_convert_to_mood() {
|
||||
for value in 1..=5u8 {
|
||||
let mood = Mood::try_from(value);
|
||||
assert!(mood.is_ok());
|
||||
assert_eq!(mood.unwrap().value(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_is_invalid_mood_value() {
|
||||
let result = Mood::try_from(0u8);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_is_invalid_mood_value() {
|
||||
let result = Mood::try_from(6u8);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
2
crates/domain/tests/reminder.rs
Normal file
2
crates/domain/tests/reminder.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "reminder/reminder_test.rs"]
|
||||
mod reminder_test;
|
||||
82
crates/domain/tests/reminder/reminder_test.rs
Normal file
82
crates/domain/tests/reminder/reminder_test.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use chrono::{NaiveTime, Weekday};
|
||||
|
||||
use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[test]
|
||||
fn new_reminder_is_enabled() {
|
||||
let schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
|
||||
let reminder = Reminder::new(UserId::generate(), schedule);
|
||||
|
||||
assert!(reminder.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_and_enable_toggle_state() {
|
||||
let schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
|
||||
let mut reminder = Reminder::new(UserId::generate(), schedule);
|
||||
|
||||
reminder.disable();
|
||||
assert!(!reminder.is_enabled());
|
||||
|
||||
reminder.enable();
|
||||
assert!(reminder.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_day_at_sets_all_days() {
|
||||
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
|
||||
let schedule = DaySchedule::every_day_at(time);
|
||||
|
||||
for day in [
|
||||
Weekday::Mon,
|
||||
Weekday::Tue,
|
||||
Weekday::Wed,
|
||||
Weekday::Thu,
|
||||
Weekday::Fri,
|
||||
Weekday::Sat,
|
||||
Weekday::Sun,
|
||||
] {
|
||||
assert_eq!(schedule.time_for(day), Some(time));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_schedule_has_no_days_set() {
|
||||
let schedule = DaySchedule::new();
|
||||
assert!(!schedule.has_any_day_set());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_time_for_specific_day() {
|
||||
let mut schedule = DaySchedule::new();
|
||||
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
|
||||
|
||||
schedule.set_time(Weekday::Mon, Some(time));
|
||||
|
||||
assert_eq!(schedule.time_for(Weekday::Mon), Some(time));
|
||||
assert_eq!(schedule.time_for(Weekday::Tue), None);
|
||||
assert!(schedule.has_any_day_set());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_schedule_replaces_entire_schedule() {
|
||||
let old = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
|
||||
let new = DaySchedule::every_day_at(NaiveTime::from_hms_opt(9, 0, 0).unwrap());
|
||||
let mut reminder = Reminder::new(UserId::generate(), old);
|
||||
|
||||
reminder.update_schedule(new);
|
||||
|
||||
let expected = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
||||
assert_eq!(reminder.schedule().time_for(Weekday::Mon), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_set_day_removes_it() {
|
||||
let mut schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
|
||||
assert!(schedule.time_for(Weekday::Mon).is_some());
|
||||
|
||||
schedule.set_time(Weekday::Mon, None);
|
||||
assert!(schedule.time_for(Weekday::Mon).is_none());
|
||||
assert!(schedule.has_any_day_set());
|
||||
}
|
||||
2
crates/domain/tests/services.rs
Normal file
2
crates/domain/tests/services.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "services/mood_analyzer_test.rs"]
|
||||
mod mood_analyzer_test;
|
||||
137
crates/domain/tests/services/mood_analyzer_test.rs
Normal file
137
crates/domain/tests/services/mood_analyzer_test.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use chrono::{Duration, FixedOffset, TimeZone};
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{Mood, MoodEntry};
|
||||
use domain::services::MoodAnalyzerService;
|
||||
use domain::user::UserId;
|
||||
|
||||
fn entry_with_mood(mood: Mood, days_ago: i64) -> MoodEntry {
|
||||
let offset = FixedOffset::east_opt(0).unwrap();
|
||||
let logged_at =
|
||||
offset.from_utc_datetime(&(chrono::Utc::now() - Duration::days(days_ago)).naive_utc());
|
||||
MoodEntry::new(UserId::generate(), mood, logged_at)
|
||||
}
|
||||
|
||||
fn entry_with_mood_and_activity(mood: Mood, activity: &ActivityId) -> MoodEntry {
|
||||
let offset = FixedOffset::east_opt(0).unwrap();
|
||||
let logged_at = offset.from_utc_datetime(&chrono::Utc::now().naive_utc());
|
||||
let mut entry = MoodEntry::new(UserId::generate(), mood, logged_at);
|
||||
entry.set_activities(vec![activity.clone()]);
|
||||
entry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_mood_of_empty_slice_is_none() {
|
||||
assert!(MoodAnalyzerService::average_mood(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_mood_of_single_entry() {
|
||||
let entries = vec![entry_with_mood(Mood::Good, 0)];
|
||||
let avg = MoodAnalyzerService::average_mood(&entries).unwrap();
|
||||
assert!((avg - 4.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_mood_across_multiple_entries() {
|
||||
let entries = vec![
|
||||
entry_with_mood(Mood::Awful, 0),
|
||||
entry_with_mood(Mood::Rad, 0),
|
||||
];
|
||||
let avg = MoodAnalyzerService::average_mood(&entries).unwrap();
|
||||
assert!((avg - 3.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_frequency_counts_each_mood() {
|
||||
let entries = vec![
|
||||
entry_with_mood(Mood::Good, 0),
|
||||
entry_with_mood(Mood::Good, 1),
|
||||
entry_with_mood(Mood::Meh, 2),
|
||||
];
|
||||
|
||||
let freq = MoodAnalyzerService::mood_frequency(&entries);
|
||||
|
||||
let good_count = freq.iter().find(|(m, _)| *m == Mood::Good).map(|(_, c)| *c);
|
||||
let meh_count = freq.iter().find(|(m, _)| *m == Mood::Meh).map(|(_, c)| *c);
|
||||
|
||||
assert_eq!(good_count, Some(2));
|
||||
assert_eq!(meh_count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_counts_consecutive_days() {
|
||||
let entries = vec![
|
||||
entry_with_mood(Mood::Good, 0),
|
||||
entry_with_mood(Mood::Good, 1),
|
||||
entry_with_mood(Mood::Good, 2),
|
||||
];
|
||||
|
||||
assert_eq!(MoodAnalyzerService::current_streak(&entries), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_breaks_on_gap() {
|
||||
let entries = vec![
|
||||
entry_with_mood(Mood::Good, 0),
|
||||
entry_with_mood(Mood::Good, 1),
|
||||
entry_with_mood(Mood::Good, 3),
|
||||
];
|
||||
|
||||
assert_eq!(MoodAnalyzerService::current_streak(&entries), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_of_empty_entries_is_zero() {
|
||||
assert_eq!(MoodAnalyzerService::current_streak(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_correlation_positive_when_mood_higher_with_activity() {
|
||||
let activity = ActivityId::generate();
|
||||
let entries = vec![
|
||||
entry_with_mood_and_activity(Mood::Rad, &activity),
|
||||
entry_with_mood_and_activity(Mood::Good, &activity),
|
||||
entry_with_mood(Mood::Meh, 0),
|
||||
entry_with_mood(Mood::Bad, 1),
|
||||
];
|
||||
|
||||
let correlation = MoodAnalyzerService::activity_mood_correlation(&entries, &activity).unwrap();
|
||||
assert!(correlation > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_correlation_is_none_when_never_used() {
|
||||
let activity = ActivityId::generate();
|
||||
let entries = vec![entry_with_mood(Mood::Good, 0)];
|
||||
|
||||
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_is_zero_when_last_entry_is_old() {
|
||||
let entries = vec![
|
||||
entry_with_mood(Mood::Good, 3),
|
||||
entry_with_mood(Mood::Good, 4),
|
||||
];
|
||||
assert_eq!(MoodAnalyzerService::current_streak(&entries), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_frequency_on_empty_is_empty() {
|
||||
let freq = MoodAnalyzerService::mood_frequency(&[]);
|
||||
assert!(freq.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_correlation_is_none_when_all_entries_have_activity() {
|
||||
let activity = ActivityId::generate();
|
||||
let entries = vec![
|
||||
entry_with_mood_and_activity(Mood::Good, &activity),
|
||||
entry_with_mood_and_activity(Mood::Rad, &activity),
|
||||
];
|
||||
|
||||
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
14
crates/domain/tests/user.rs
Normal file
14
crates/domain/tests/user.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#[path = "user/username_test.rs"]
|
||||
mod username_test;
|
||||
|
||||
#[path = "user/email_test.rs"]
|
||||
mod email_test;
|
||||
|
||||
#[path = "user/user_test.rs"]
|
||||
mod user_test;
|
||||
|
||||
#[path = "user/display_name_test.rs"]
|
||||
mod display_name_test;
|
||||
|
||||
#[path = "user/timezone_test.rs"]
|
||||
mod timezone_test;
|
||||
40
crates/domain/tests/user/display_name_test.rs
Normal file
40
crates/domain/tests/user/display_name_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::DisplayName;
|
||||
|
||||
#[test]
|
||||
fn valid_display_name_is_created() {
|
||||
let name = DisplayName::new("Alice").unwrap();
|
||||
assert_eq!(name.value(), "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_trims_whitespace() {
|
||||
let name = DisplayName::new(" Bob ").unwrap();
|
||||
assert_eq!(name.value(), "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_display_name_is_rejected() {
|
||||
let result = DisplayName::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_display_name_is_rejected() {
|
||||
let result = DisplayName::new(" ");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_over_100_chars_is_rejected() {
|
||||
let long = "a".repeat(101);
|
||||
let result = DisplayName::new(long);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_at_100_chars_is_valid() {
|
||||
let exact = "a".repeat(100);
|
||||
let name = DisplayName::new(exact).unwrap();
|
||||
assert_eq!(name.value().len(), 100);
|
||||
}
|
||||
32
crates/domain/tests/user/email_test.rs
Normal file
32
crates/domain/tests/user/email_test.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::Email;
|
||||
|
||||
#[test]
|
||||
fn valid_email_is_created() {
|
||||
let email = Email::new("user@example.com").unwrap();
|
||||
assert_eq!(email.value(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_is_lowercased() {
|
||||
let email = Email::new("User@Example.COM").unwrap();
|
||||
assert_eq!(email.value(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_trims_whitespace() {
|
||||
let email = Email::new(" user@example.com ").unwrap();
|
||||
assert_eq!(email.value(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_without_at_is_rejected() {
|
||||
let result = Email::new("not-an-email");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_email_is_rejected() {
|
||||
let result = Email::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
32
crates/domain/tests/user/timezone_test.rs
Normal file
32
crates/domain/tests/user/timezone_test.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::Timezone;
|
||||
|
||||
#[test]
|
||||
fn valid_timezone_is_created() {
|
||||
let tz = Timezone::new("Europe/Warsaw").unwrap();
|
||||
assert_eq!(tz.value(), "Europe/Warsaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timezone_trims_whitespace() {
|
||||
let tz = Timezone::new(" America/New_York ").unwrap();
|
||||
assert_eq!(tz.value(), "America/New_York");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_timezone_is_rejected() {
|
||||
let result = Timezone::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timezone_without_slash_is_rejected() {
|
||||
let result = Timezone::new("UTC");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_bypasses_validation() {
|
||||
let tz = Timezone::from_persistence("UTC".into());
|
||||
assert_eq!(tz.value(), "UTC");
|
||||
}
|
||||
59
crates/domain/tests/user/user_test.rs
Normal file
59
crates/domain/tests/user/user_test.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use domain::user::{DisplayName, Email, PasswordHash, Timezone, User, UserRole, Username};
|
||||
|
||||
#[test]
|
||||
fn new_user_has_default_role() {
|
||||
let user = User::new(
|
||||
Username::new("gabriel").unwrap(),
|
||||
Email::new("gabriel@example.com").unwrap(),
|
||||
PasswordHash::new("hashed".into()),
|
||||
);
|
||||
|
||||
assert_eq!(user.role(), UserRole::User);
|
||||
assert!(!user.is_admin());
|
||||
assert!(user.display_name().is_none());
|
||||
assert!(user.timezone().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_to_admin_changes_role() {
|
||||
let mut user = User::new(
|
||||
Username::new("gabriel").unwrap(),
|
||||
Email::new("gabriel@example.com").unwrap(),
|
||||
PasswordHash::new("hashed".into()),
|
||||
);
|
||||
|
||||
user.promote_to_admin();
|
||||
|
||||
assert_eq!(user.role(), UserRole::Admin);
|
||||
assert!(user.is_admin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_display_name_touches_updated_at() {
|
||||
let mut user = User::new(
|
||||
Username::new("gabriel").unwrap(),
|
||||
Email::new("gabriel@example.com").unwrap(),
|
||||
PasswordHash::new("hashed".into()),
|
||||
);
|
||||
let before = *user.updated_at();
|
||||
|
||||
let name = DisplayName::new("Gabriel K").unwrap();
|
||||
user.update_display_name(Some(name));
|
||||
|
||||
assert_eq!(user.display_name().map(|d| d.value()), Some("Gabriel K"));
|
||||
assert!(*user.updated_at() >= before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_timezone_sets_value() {
|
||||
let mut user = User::new(
|
||||
Username::new("gabriel").unwrap(),
|
||||
Email::new("gabriel@example.com").unwrap(),
|
||||
PasswordHash::new("hashed".into()),
|
||||
);
|
||||
|
||||
let tz = Timezone::new("Europe/Warsaw").unwrap();
|
||||
user.update_timezone(Some(tz));
|
||||
|
||||
assert_eq!(user.timezone().map(|t| t.value()), Some("Europe/Warsaw"));
|
||||
}
|
||||
45
crates/domain/tests/user/username_test.rs
Normal file
45
crates/domain/tests/user/username_test.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::Username;
|
||||
|
||||
#[test]
|
||||
fn valid_username_is_created() {
|
||||
let username = Username::new("gabriel").unwrap();
|
||||
assert_eq!(username.value(), "gabriel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_with_dots_and_underscores_is_valid() {
|
||||
let username = Username::new("gabriel.k_99").unwrap();
|
||||
assert_eq!(username.value(), "gabriel.k_99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_trims_whitespace() {
|
||||
let username = Username::new(" gabriel ").unwrap();
|
||||
assert_eq!(username.value(), "gabriel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_username_is_rejected() {
|
||||
let result = Username::new("");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_longer_than_32_chars_is_rejected() {
|
||||
let long = "a".repeat(33);
|
||||
let result = Username::new(long);
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_with_special_chars_is_rejected() {
|
||||
let result = Username::new("gabriel@home");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_with_spaces_is_rejected() {
|
||||
let result = Username::new("gabriel k");
|
||||
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
|
||||
}
|
||||
Reference in New Issue
Block a user