From b35c8cca57811050536a4fa6c1cb5675453ad463 Mon Sep 17 00:00:00 2001 From: Mikkel Thestrup Date: Tue, 27 Jan 2026 17:07:13 +0100 Subject: feat(infrastructure): implement SQLite persistence layer for calendar domain Add infrastructure layer with SQLite repositories for calendars, events, and recurring events. Implements the repository pattern with proper domain/infrastructure separation. - Add CalendarModel, EventModel, RecurrenceModel, and RecurrenceExceptionModel for database persistence - Implement SqliteCalendarRepository with CRUD operations - Implement SqliteEventRepository with calendar filtering and time range queries - Implement SqliteRecurringEventRepository with exception handling and transactions - Add bidirectional mappers between domain entities and persistence models - Use sqlx query_as for type-safe database queries with FromRow derivation - Support upsert operations for all entities using ON CONFLICT clauses --- src/infrastructure/persistence/mappers.rs | 278 ++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 src/infrastructure/persistence/mappers.rs (limited to 'src/infrastructure/persistence/mappers.rs') diff --git a/src/infrastructure/persistence/mappers.rs b/src/infrastructure/persistence/mappers.rs new file mode 100644 index 0000000..4260038 --- /dev/null +++ b/src/infrastructure/persistence/mappers.rs @@ -0,0 +1,278 @@ +use chrono::{DateTime, Utc}; +use crate::domain::{ + calendar::Calendar, + event::Event, + recurrence::{ + ExceptionModification, + RecurrenceException, + RecurrenceRule, + RecurringEvent, + }, + value_objects::{ + CalendarId, + EventColor, + EventId, + Frequency, + TimeRange, + }, +}; +use super::models::{ + CalendarModel, + EventModel, + RecurrenceModel, + RecurrenceExceptionModel +}; +use std::{collections::HashMap, str::FromStr}; + +pub struct CalendarMapper; + +impl CalendarMapper { + pub fn to_domain(model: CalendarModel) -> Result { + let id = CalendarId::from_str(&model.id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + Ok(Calendar::with_id( + id, + model.name, + model.description, + model.is_archived != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(calendar: &Calendar) -> CalendarModel { + CalendarModel { + id: calendar.id().to_string(), + name: calendar.name().to_string(), + description: calendar.description().clone(), + is_archived: if *calendar.is_archived() { 1 } else { 0 }, + created_at: calendar.created_at().to_rfc3339(), + updated_at: calendar.updated_at().to_rfc3339(), + } + } +} + +pub struct EventMapper; + +impl EventMapper { + pub fn to_domain(model: EventModel) -> Result { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + Ok(Event::with_id( + id, + calendar_id, + model.title, + model.description, + time_range, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &Event) -> EventModel { + EventModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } +} + +pub struct RecurrenceMapper; + +impl RecurrenceMapper { + pub fn to_domain( + model: RecurrenceModel, + exceptions: Vec, + ) -> Result { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let base_time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + let frequency = Frequency::from_str(&model.frequency) + .map_err(|e| e.to_string())?; + + let until = if let Some(until_str) = model.until { + Some(DateTime::parse_from_rfc3339(&until_str) + .map_err(|e| format!("Invalid until: {}", e))? + .with_timezone(&Utc)) + } else { + None + }; + + let recurrence_rule = RecurrenceRule::new( + frequency, + model.interval as u32, + until, + ).map_err(|e| e.to_string())?; + + let exception_map = exceptions + .into_iter() + .map(Self::exception_to_domain) + .collect::, _>>()? + .into_iter() + .map(|ex| (*ex.original_starts_at(), ex)) + .collect::, RecurrenceException>>(); + + Ok(RecurringEvent::with_id( + id, + calendar_id, + model.title, + model.description, + base_time_range, + recurrence_rule, + exception_map, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &RecurringEvent) -> RecurrenceModel { + RecurrenceModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + frequency: event.rule().frequency().to_string(), + interval: *event.rule().interval() as i64, + until: event.rule().until().map(|dt| dt.to_rfc3339()), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } + + fn exception_to_domain( + model: RecurrenceExceptionModel, + ) -> Result { + let original_starts_at = DateTime::parse_from_rfc3339( + &model.original_starts_at + ) + .map_err(|e| format!("Invalid original_starts_at: {}", e))? + .with_timezone(&Utc); + + if model.is_cancelled != 0 { + Ok(RecurrenceException::cancelled(original_starts_at)) + } else if let (Some(new_starts), Some(new_ends)) = + (model.new_starts_at, model.new_ends_at) { + let starts = DateTime::parse_from_rfc3339(&new_starts) + .map_err(|e| format!("Invalid new_starts_at: {}", e))? + .with_timezone(&Utc); + + let ends = DateTime::parse_from_rfc3339(&new_ends) + .map_err(|e| format!("Invalid new_ends_at: {}", e))? + .with_timezone(&Utc); + + let new_time_range = TimeRange::new(starts, ends) + .map_err(|e| e.to_string())?; + + Ok(RecurrenceException::rescheduled( + original_starts_at, + new_time_range, + )) + } else { + Err( + "Exception must be either cancelled or have new time range" + .to_string() + ) + } + } + + pub fn exception_to_model( + exception: &RecurrenceException, + recurrence_id: &EventId, + ) -> RecurrenceExceptionModel { + let (new_starts_at, new_ends_at, is_cancelled) = + match exception.modification() { + ExceptionModification::Cancelled => (None, None, 1), + ExceptionModification::Rescheduled { new_time_range } => ( + Some(new_time_range.starts_at().to_rfc3339()), + Some(new_time_range.ends_at().to_rfc3339()), + 0, + ), + }; + + RecurrenceExceptionModel { + recurrence_id: recurrence_id.to_string(), + original_starts_at: exception.original_starts_at().to_rfc3339(), + new_starts_at, + new_ends_at, + is_cancelled, + } + } +} -- cgit v1.2.3-70-g09d2