From 4e78fd83349c95711cdee5acc56f248f81ebd25c Mon Sep 17 00:00:00 2001 From: Mikkel Thestrup Date: Mon, 26 Jan 2026 21:27:59 +0100 Subject: feat(domain): add core domain models for calendar application Implement domain-driven design layer with entities, value objects, and repository traits for calendar management system. Domain Entities: - Calendar: manages calendar lifecycle and archival state - Event: handles single occurrence events with cancellation support - RecurringEvent: supports recurring events with exception handling - RecurrenceException: manages modifications to specific occurrences Value Objects: - TimeRange: enforces valid start/end time constraints - EventColor: type-safe color representation - Frequency: recurrence frequency enumeration (daily/weekly/monthly/yearly) - RecurrenceRule: encapsulates recurrence pattern logic - CalendarId & EventId: essentially just a wrapper Repository Traits: - CalendarRepository: calendar persistence interface - EventRepository: event querying and persistence with overlap detection - RecurrenceRepository: recurring event management Key Design Decisions: - Use Uuid for entity IDs (type safety, performance) - Encapsulate business logic within entities - Immutable value objects with validation - Repository pattern for infrastructure abstraction - Clear separation of concerns between domain and persistence layers All entities include timestamp tracking and follow builder pattern for construction with validation at domain boundaries. --- src/domain/calendar.rs | 76 +++++++++++++++++ src/domain/error.rs | 28 +++++++ src/domain/event.rs | 116 +++++++++++++++++++++++++ src/domain/mod.rs | 7 ++ src/domain/recurrence.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++ src/domain/repository.rs | 45 ++++++++++ src/domain/value_objects.rs | 147 ++++++++++++++++++++++++++++++++ 7 files changed, 619 insertions(+) create mode 100644 src/domain/calendar.rs create mode 100644 src/domain/error.rs create mode 100644 src/domain/event.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/recurrence.rs create mode 100644 src/domain/repository.rs create mode 100755 src/domain/value_objects.rs (limited to 'src') diff --git a/src/domain/calendar.rs b/src/domain/calendar.rs new file mode 100644 index 0000000..21216b5 --- /dev/null +++ b/src/domain/calendar.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, Utc}; +use getset::Getters; + +use crate::domain::value_objects::CalendarId; + +#[derive(Debug, Clone, Getters)] +pub struct Calendar { + #[getset(get = "pub")] + id: CalendarId, + #[getset(get = "pub")] + name: String, + #[getset(get = "pub")] + description: Option, + #[getset(get = "pub")] + is_archived: bool, + #[getset(get = "pub")] + created_at: DateTime, + #[getset(get = "pub")] + updated_at: DateTime, +} + +impl Calendar { + pub fn new(name: String, description: Option) -> Self { + let now = Utc::now(); + Self { + id: CalendarId::new(), + name, + description, + is_archived: false, + created_at: now, + updated_at: now, + } + } + + pub fn with_id( + id: CalendarId, + name: String, + description: Option, + is_archived: bool, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + name, + description, + is_archived, + created_at, + updated_at, + } + } + + pub fn archive(&mut self) { + self.is_archived = true; + self.touch(); + } + + pub fn unarchive(&mut self) { + self.is_archived = false; + self.touch(); + } + + pub fn update_name(&mut self, name: String) { + self.name = name; + self.touch(); + } + + pub fn update_description(&mut self, description: Option) { + self.description = description; + self.touch(); + } + + pub fn touch(&mut self) { + self.updated_at = Utc::now(); + } +} diff --git a/src/domain/error.rs b/src/domain/error.rs new file mode 100644 index 0000000..4602b7e --- /dev/null +++ b/src/domain/error.rs @@ -0,0 +1,28 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DomainError { + #[error("Invalid time range: start time must be before end time")] + InvalidTimeRange, + + #[error("Invalid color value")] + InvalidColor, + + #[error("Invalid frequency")] + InvalidFrequency, + + #[error("Invalid interval: must be greater than 0")] + InvalidInterval, + + #[error("Calendar not found: {0}")] + CalendarNotFound(String), + + #[error("Event not found: {0}")] + EventNotFound(String), + + #[error("Recurrence not found: {0}")] + RecurrenceNotFound(String), + + #[error("Cannot modify archived calendar")] + CalendarArchived, +} diff --git a/src/domain/event.rs b/src/domain/event.rs new file mode 100644 index 0000000..93216a4 --- /dev/null +++ b/src/domain/event.rs @@ -0,0 +1,116 @@ +use chrono::{DateTime, Utc}; +use getset::Getters; + +use crate::domain::value_objects::{CalendarId, EventColor, EventId, TimeRange}; + +#[derive(Debug, Clone, Getters)] +pub struct Event { + #[getset(get = "pub")] + id: EventId, + #[getset(get = "pub")] + calendar_id: CalendarId, + #[getset(get = "pub")] + title: String, + #[getset(get = "pub")] + description: Option, + #[getset(get = "pub")] + time_range: TimeRange, + #[getset(get = "pub")] + color: EventColor, + #[getset(get = "pub")] + is_all_day: bool, + #[getset(get = "pub")] + is_cancelled: bool, + #[getset(get = "pub")] + created_at: DateTime, + #[getset(get = "pub")] + updated_at: DateTime, +} + +impl Event { + pub fn new( + calendar_id: CalendarId, + title: String, + description: Option, + time_range: TimeRange, + color: EventColor, + is_all_day: bool, + ) -> Self { + let now = Utc::now(); + Self { + id: EventId::new(), + calendar_id, + title, + description, + time_range, + color, + is_all_day, + is_cancelled: false, + created_at: now, + updated_at: now, + } + } + + pub fn with_id( + id: EventId, + calendar_id: CalendarId, + title: String, + description: Option, + time_range: TimeRange, + color: EventColor, + is_all_day: bool, + is_cancelled: bool, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + calendar_id, + title, + description, + time_range, + color, + is_all_day, + is_cancelled, + created_at, + updated_at, + } + } + + pub fn cancel(&mut self) { + self.is_cancelled = true; + self.updated_at = Utc::now(); + } + + pub fn restore(&mut self) { + self.is_cancelled = false; + self.updated_at = Utc::now(); + } + + pub fn update_title(&mut self, title: String) { + self.title = title; + self.updated_at = Utc::now(); + } + + pub fn update_description(&mut self, description: Option) { + self.description = description; + self.updated_at = Utc::now(); + } + + pub fn update_time_range(&mut self, time_range: TimeRange) { + self.time_range = time_range; + self.updated_at = Utc::now(); + } + + pub fn update_color(&mut self, color: EventColor) { + self.color = color; + self.updated_at = Utc::now(); + } + + pub fn overlaps_with(&self, other: &Event) -> bool { + !self.is_cancelled + && !other.is_cancelled + && self.calendar_id == other.calendar_id + && self.time_range.overlaps(other.time_range()) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..05226bf --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,7 @@ +pub mod calendar; +pub mod event; +pub mod recurrence; +pub mod value_objects; +pub mod repository; + +pub mod error; diff --git a/src/domain/recurrence.rs b/src/domain/recurrence.rs new file mode 100644 index 0000000..263868e --- /dev/null +++ b/src/domain/recurrence.rs @@ -0,0 +1,200 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use getset::Getters; + +use crate::domain::{ + error::DomainError, + value_objects::{CalendarId, EventColor, EventId, Frequency, TimeRange} +}; + +#[derive(Debug, Clone, Getters)] +pub struct RecurrenceRule { + #[getset(get = "pub")] + frequency: Frequency, + #[getset(get = "pub")] + interval: u32, + #[getset(get = "pub")] + until: Option>, +} + +impl RecurrenceRule { + pub fn new( + frequency: Frequency, + interval: u32, + until: Option>, + ) -> Result { + if interval == 0 { + return Err(DomainError::InvalidInterval); + } + Ok(Self { + frequency, + interval, + until, + }) + } +} + +#[derive(Debug, Clone, Getters)] +pub struct RecurringEvent { + #[getset(get = "pub")] + id: EventId, + #[getset(get = "pub")] + calendar_id: CalendarId, + #[getset(get = "pub")] + title: String, + #[getset(get = "pub")] + description: Option, + #[getset(get = "pub")] + time_range: TimeRange, + #[getset(get = "pub")] + rule: RecurrenceRule, + #[getset(get = "pub")] + exceptions: HashMap, RecurrenceException>, + #[getset(get = "pub")] + color: EventColor, + #[getset(get = "pub")] + is_all_day: bool, + #[getset(get = "pub")] + is_cancelled: bool, + #[getset(get = "pub")] + created_at: DateTime, + #[getset(get = "pub")] + updated_at: DateTime, +} + +impl RecurringEvent { + pub fn new( + calendar_id: CalendarId, + title: String, + description: Option, + time_range: TimeRange, + rule: RecurrenceRule, + color: EventColor, + is_all_day: bool, + ) -> Self { + let now = Utc::now(); + Self { + id: EventId::new(), + calendar_id, + title, + description, + time_range, + rule, + exceptions: HashMap::new(), + color, + is_all_day, + is_cancelled: false, + created_at: now, + updated_at: now, + } + } + + pub fn with_id( + id: EventId, + calendar_id: CalendarId, + title: String, + description: Option, + time_range: TimeRange, + rule: RecurrenceRule, + exceptions: HashMap, RecurrenceException>, + color: EventColor, + is_all_day: bool, + is_cancelled: bool, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + calendar_id, + title, + description, + time_range, + rule, + exceptions, + color, + is_all_day, + is_cancelled, + created_at, + updated_at, + } + } + + pub fn add_exception(&mut self, exception: RecurrenceException) { + self.exceptions.insert(exception.original_starts_at, exception); + self.touch(); + } + + pub fn remove_exception(&mut self, original_starts_at: DateTime) { + self.exceptions.remove(&original_starts_at); + self.touch(); + } + + pub fn cancel_occurrence(&mut self, original_starts_at: DateTime) { + let exception = RecurrenceException::cancelled(original_starts_at); + self.add_exception(exception); + } + + pub fn reschedule_occurrence( + &mut self, + original_starts_at: DateTime, + new_time_range: TimeRange, + ) { + let exception = RecurrenceException::rescheduled( + original_starts_at, + new_time_range + ); + self.add_exception(exception); + } + + pub fn cancel(&mut self) { + self.is_cancelled = true; + self.touch(); + } + + pub fn touch(&mut self) { + self.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone, Getters)] +pub struct RecurrenceException { + #[getset(get = "pub")] + original_starts_at: DateTime, + #[getset(get = "pub")] + modification: ExceptionModification, +} + +#[derive(Debug, Clone)] +pub enum ExceptionModification { + Cancelled, + Rescheduled { new_time_range: TimeRange }, +} + +impl RecurrenceException { + pub fn cancelled(original_starts_at: DateTime) -> Self { + Self { + original_starts_at, + modification: ExceptionModification::Cancelled, + } + } + + pub fn rescheduled( + original_starts_at: DateTime, + new_time_range: TimeRange, + ) -> Self { + Self { + original_starts_at, + modification: ExceptionModification::Rescheduled { new_time_range }, + } + } + + pub fn new_time_range(&self) -> Option<&TimeRange> { + match &self.modification { + ExceptionModification::Rescheduled { + new_time_range, + } => Some(new_time_range), + ExceptionModification::Cancelled => None, + } + } +} diff --git a/src/domain/repository.rs b/src/domain/repository.rs new file mode 100644 index 0000000..00b3974 --- /dev/null +++ b/src/domain/repository.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; +use super::{ + calendar::Calendar, + event::Event, + recurrence::RecurringEvent, + value_objects::{CalendarId, EventId, TimeRange}, +}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum RepositoryError { + #[error("Entity not found")] + NotFound, + + #[error("Database error: {0}")] + DatabaseError(String), + + #[error("Constraint violation: {0}")] + ConstraintViolation(String), +} + +#[async_trait] +pub trait CalendarRepository: Send + Sync { + async fn save(&self, calendar: &Calendar) -> Result<()>; + async fn find_by_id(&self, id: &CalendarId) -> Result>; + async fn find_all_active(&self) -> Result>; + async fn delete(&self, id: &CalendarId) -> Result<()>; +} + +#[async_trait] +pub trait EventRepository: Send + Sync { + async fn save(&self, event: &Event) -> Result<()>; + async fn find_by_id(&self, id: &EventId) -> Result>; + async fn find_by_calendar(&self, calendar_id: &CalendarId) -> Result>; + async fn find_in_range(&self, calendar_id: &CalendarId, range: &TimeRange) -> Result>; + async fn delete(&self, id: &EventId) -> Result<()>; +} + +#[async_trait] +pub trait RecurringEventRepository: Send + Sync { + async fn save(&self, event: &RecurringEvent) -> Result<()>; + async fn find_by_calendar(&self, calendar_id: &CalendarId) -> Result>; + async fn delete(&self, id: &str) -> Result<()>; +} diff --git a/src/domain/value_objects.rs b/src/domain/value_objects.rs new file mode 100755 index 0000000..f38d351 --- /dev/null +++ b/src/domain/value_objects.rs @@ -0,0 +1,147 @@ +use core::fmt; + +use chrono::{DateTime, Utc}; +use getset::{Getters}; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventColor(u8); + +impl From for EventColor { + fn from(value: u8) -> Self { + Self(value) + } +} + +impl From for u8 { + fn from(color: EventColor) -> Self { + color.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Getters)] +pub struct TimeRange { + #[getset(get = "pub")] + starts_at: DateTime, + #[getset(get = "pub")] + ends_at: DateTime, +} + +impl TimeRange { + pub fn new( + starts_at: DateTime, + ends_at: DateTime, + ) -> Result { + if starts_at >= ends_at { + Err(DomainError::InvalidTimeRange) + } else { + Ok(Self { starts_at, ends_at }) + } + } + + pub fn overlaps(&self, other: &Self) -> bool { + self.starts_at < other.ends_at + && other.starts_at < self.ends_at + } + + pub fn duration(&self) -> chrono::Duration { + self.ends_at - self.starts_at + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Frequency { + Daily, + Weekly, + Monthly, + Yearly, +} + +impl fmt::Display for Frequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Frequency::Daily => write!(f, "DAILY"), + Frequency::Weekly => write!(f, "WEEKLY"), + Frequency::Monthly => write!(f, "MONTHLY"), + Frequency::Yearly => write!(f, "YEARLY"), + } + } +} + +impl std::str::FromStr for Frequency { + type Err = DomainError; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "DAILY" => Ok(Frequency::Daily), + "WEEKLY" => Ok(Frequency::Weekly), + "MONTHLY" => Ok(Frequency::Monthly), + "YEARLY" => Ok(Frequency::Yearly), + _ => Err(DomainError::InvalidFrequency), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CalendarId(Uuid); + +impl CalendarId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl std::fmt::Display for CalendarId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::str::FromStr for CalendarId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EventId(Uuid); + +impl EventId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl std::fmt::Display for EventId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::str::FromStr for EventId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} -- cgit v1.2.3-70-g09d2