init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

16
crates/domain/Cargo.toml Normal file
View 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 = []

View 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(())
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(ActivityId);

View 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
}
}

View 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
}
}

View 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;

View 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
}
}

View 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()
}
}

View 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;

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(PhotoId);

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(VoiceMemoId);

View 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
}
}

View 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;

View 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
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(RefreshSessionId);

View 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
}
}

View 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
}
}

View 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;

View 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}"
))),
}
}
}

View 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();
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(MoodEntryId);

View 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),
}

View File

@@ -0,0 +1,3 @@
mod domain_error;
pub use domain_error::DomainError;

View 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,
},
}

View 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
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(EventId);

View 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
View 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;

View 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;

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>,
}

View 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>;
}

View 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};

View 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>;
}

View 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>;
}

View 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>;
}

View File

@@ -0,0 +1,3 @@
mod push_subscription;
pub use push_subscription::{PushSubscription, PushSubscriptionId};

View 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
}
}

View 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()
}
}

View 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;

View 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;
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(ReminderId);

View File

@@ -0,0 +1,3 @@
mod mood_analyzer;
pub use mood_analyzer::MoodAnalyzerService;

View 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)
}
}

View 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()
}

View 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;

View 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}"))
}
}

View 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()
}
}

View 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())
}
}

View 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())
}
}

View 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())
}
}

View 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())
}
}

View 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(())
}
}

View 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())
}
}

View File

@@ -0,0 +1,5 @@
mod factories;
mod fakes;
pub use factories::*;
pub use fakes::{FakePasswordHasher, InMemoryStore};

View 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
}
}

View 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
}
}

View 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;

View 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
}
}

View 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
}
}

View 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();
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(UserId);

View File

@@ -0,0 +1,8 @@
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum UserRole {
Admin,
#[default]
User,
}

View 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
}
}

View 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;

View 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(_))));
}

View 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);
}

View 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(), "");
}

View 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;

View 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(), "");
}

View 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(_))));
}

View File

@@ -0,0 +1,2 @@
#[path = "auth/refresh_session_test.rs"]
mod refresh_session_test;

View 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);
}

View 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;

View 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(), "");
}

View 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(_))));
}

View 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());
}

View 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(_))));
}

View File

@@ -0,0 +1,2 @@
#[path = "reminder/reminder_test.rs"]
mod reminder_test;

View 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());
}

View File

@@ -0,0 +1,2 @@
#[path = "services/mood_analyzer_test.rs"]
mod mood_analyzer_test;

View 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());
}

View 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;

View 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);
}

View 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(_))));
}

View 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");
}

View 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"));
}

View 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(_))));
}