aboutsummaryrefslogtreecommitdiff
path: root/src/infrastructure
diff options
context:
space:
mode:
authorMikkel Thestrup <mikkel@mithe.dk>2026-01-27 17:07:13 +0100
committerMikkel Thestrup <mikkel@mithe.dk>2026-01-27 17:08:24 +0100
commitb35c8cca57811050536a4fa6c1cb5675453ad463 (patch)
tree622bce9ef4021701ab845fd88ff0fc86b47905aa /src/infrastructure
parent4e78fd83349c95711cdee5acc56f248f81ebd25c (diff)
downloadkal-b35c8cca57811050536a4fa6c1cb5675453ad463.tar.gz
kal-b35c8cca57811050536a4fa6c1cb5675453ad463.zip
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
Diffstat (limited to 'src/infrastructure')
-rw-r--r--src/infrastructure/mod.rs1
-rw-r--r--src/infrastructure/persistence/calendar_repository.rs119
-rw-r--r--src/infrastructure/persistence/event_repository.rs168
-rw-r--r--src/infrastructure/persistence/mappers.rs278
-rw-r--r--src/infrastructure/persistence/mod.rs7
-rw-r--r--src/infrastructure/persistence/models.rs53
-rw-r--r--src/infrastructure/persistence/recurring_event_repository.rs174
7 files changed, 800 insertions, 0 deletions
diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs
new file mode 100644
index 0000000..c93e4ff
--- /dev/null
+++ b/src/infrastructure/mod.rs
@@ -0,0 +1 @@
+pub mod persistence;
diff --git a/src/infrastructure/persistence/calendar_repository.rs b/src/infrastructure/persistence/calendar_repository.rs
new file mode 100644
index 0000000..d0b758d
--- /dev/null
+++ b/src/infrastructure/persistence/calendar_repository.rs
@@ -0,0 +1,119 @@
+use async_trait::async_trait;
+use sqlx::SqlitePool;
+use crate::{
+ domain::{
+ calendar::Calendar,
+ repository::{CalendarRepository, RepositoryError, Result},
+ value_objects::CalendarId,
+ },
+ infrastructure::persistence::models::CalendarModel
+};
+use super::mappers::CalendarMapper;
+
+pub struct SqliteCalendarRepository {
+ pool: SqlitePool,
+}
+
+impl SqliteCalendarRepository {
+ pub fn new(pool: SqlitePool) -> Self {
+ Self { pool }
+ }
+}
+
+#[async_trait]
+impl CalendarRepository for SqliteCalendarRepository {
+ async fn save(&self, calendar: &Calendar) -> Result<()> {
+ let model = CalendarMapper::to_model(calendar);
+
+ sqlx::query!(
+ r#"
+ INSERT INTO calendars (
+ id, name, description, is_archived,
+ created_at, updated_at
+ )
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ description = excluded.description,
+ is_archived = excluded.is_archived,
+ updated_at = excluded.updated_at
+ "#,
+ model.id,
+ model.name,
+ model.description,
+ model.is_archived,
+ model.created_at,
+ model.updated_at,
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ Ok(())
+ }
+
+ async fn find_by_id(&self, id: &CalendarId) -> Result<Option<Calendar>> {
+ let id_str = id.to_string();
+
+ let model = sqlx::query_as::<_, CalendarModel>(
+ r#"
+ SELECT id, name, description, is_archived, created_at, updated_at
+ FROM calendars
+ WHERE id = ?1
+ "#
+ )
+ .bind(&id_str)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ match model {
+ Some(m) => {
+ let calendar = CalendarMapper::to_domain(m)
+ .map_err(|e| RepositoryError::DatabaseError(e))?;
+ Ok(Some(calendar))
+ }
+ None => Ok(None),
+ }
+ }
+
+ async fn find_all_active(&self) -> Result<Vec<Calendar>> {
+ let models = sqlx::query_as::<_, CalendarModel>(
+ r#"
+ SELECT id, name, description, is_archived, created_at, updated_at
+ FROM calendars
+ WHERE is_archived = 0
+ ORDER BY name
+ "#
+ )
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ models
+ .into_iter()
+ .map(|m| CalendarMapper::to_domain(m)
+ .map_err(|e| RepositoryError::DatabaseError(e)))
+ .collect()
+ }
+
+ async fn delete(&self, id: &CalendarId) -> Result<()> {
+ let id_str = id.to_string();
+
+ let result = sqlx::query!(
+ r#"
+ DELETE FROM calendars WHERE id = ?1
+ "#,
+ id_str,
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ if result.rows_affected() == 0 {
+ return Err(RepositoryError::NotFound);
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/infrastructure/persistence/event_repository.rs b/src/infrastructure/persistence/event_repository.rs
new file mode 100644
index 0000000..cd83e99
--- /dev/null
+++ b/src/infrastructure/persistence/event_repository.rs
@@ -0,0 +1,168 @@
+use async_trait::async_trait;
+use sqlx::SqlitePool;
+use crate::domain::{
+ event::Event,
+ repository::{EventRepository, RepositoryError, Result},
+ value_objects::{CalendarId, EventId, TimeRange},
+};
+use super::{models::EventModel, mappers::EventMapper};
+
+pub struct SqliteEventRepository {
+ pool: SqlitePool,
+}
+
+impl SqliteEventRepository {
+ pub fn new(pool: SqlitePool) -> Self {
+ Self { pool }
+ }
+}
+
+#[async_trait]
+impl EventRepository for SqliteEventRepository {
+ async fn save(&self, event: &Event) -> Result<()> {
+ let model = EventMapper::to_model(event);
+
+ sqlx::query!(
+ r#"
+ INSERT INTO events (
+ id, calendar_id, title, description, starts_at, ends_at,
+ color, is_all_day, is_cancelled, created_at, updated_at
+ )
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
+ ON CONFLICT(id) DO UPDATE SET
+ title = excluded.title,
+ description = excluded.description,
+ starts_at = excluded.starts_at,
+ ends_at = excluded.ends_at,
+ color = excluded.color,
+ is_all_day = excluded.is_all_day,
+ is_cancelled = excluded.is_cancelled,
+ updated_at = excluded.updated_at
+ "#,
+ model.id,
+ model.calendar_id,
+ model.title,
+ model.description,
+ model.starts_at,
+ model.ends_at,
+ model.color,
+ model.is_all_day,
+ model.is_cancelled,
+ model.created_at,
+ model.updated_at,
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ Ok(())
+ }
+
+ async fn find_by_id(&self, id: &EventId) -> Result<Option<Event>> {
+ let id_str = id.to_string();
+
+ let model = sqlx::query_as::<_, EventModel>(
+ r#"
+ SELECT id, calendar_id, title, description, starts_at, ends_at,
+ color, is_all_day, is_cancelled, created_at, updated_at
+ FROM events
+ WHERE id = ?1
+ "#
+ )
+ .bind(&id_str)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ match model {
+ Some(m) => {
+ let event = EventMapper::to_domain(m)
+ .map_err(|e| RepositoryError::DatabaseError(e))?;
+ Ok(Some(event))
+ }
+ None => Ok(None),
+ }
+ }
+
+ async fn find_by_calendar(
+ &self,
+ calendar_id: &CalendarId
+ ) -> Result<Vec<Event>> {
+ let calendar_id_str = calendar_id.to_string();
+
+ let models = sqlx::query_as::<_, EventModel>(
+ r#"
+ SELECT id, calendar_id, title, description, starts_at, ends_at,
+ color, is_all_day, is_cancelled, created_at, updated_at
+ FROM events
+ WHERE calendar_id = ?1
+ ORDER BY starts_at
+ "#
+ )
+ .bind(&calendar_id_str)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ models
+ .into_iter()
+ .map(|m| EventMapper::to_domain(m)
+ .map_err(|e| RepositoryError::DatabaseError(e)))
+ .collect()
+ }
+
+ async fn find_in_range(
+ &self,
+ calendar_id: &CalendarId,
+ range: &TimeRange,
+ ) -> Result<Vec<Event>> {
+ let calendar_id_str = calendar_id.to_string();
+ let range_start = range.starts_at().to_rfc3339();
+ let range_end = range.ends_at().to_rfc3339();
+
+ let models = sqlx::query_as::<_, EventModel>(
+ r#"
+ SELECT id, calendar_id, title, description, starts_at, ends_at,
+ color, is_all_day, is_cancelled, created_at, updated_at
+ FROM events
+ WHERE calendar_id = ?1
+ AND is_cancelled = 0
+ AND starts_at < ?3
+ AND ends_at > ?2
+ ORDER BY starts_at
+ "#
+ )
+ .bind(&calendar_id_str)
+ .bind(&range_start)
+ .bind(&range_end)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ models
+ .into_iter()
+ .map(|m| EventMapper::to_domain(m)
+ .map_err(|e| RepositoryError::DatabaseError(e)))
+ .collect()
+ }
+
+ async fn delete(&self, id: &EventId) -> Result<()> {
+ let id_str = id.to_string();
+
+ let result = sqlx::query!(
+ r#"
+ DELETE FROM events WHERE id = ?1
+ "#,
+ id_str,
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ if result.rows_affected() == 0 {
+ return Err(RepositoryError::NotFound);
+ }
+
+ Ok(())
+ }
+}
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<Calendar, String> {
+ 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<Event, String> {
+ 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<RecurrenceExceptionModel>,
+ ) -> Result<RecurringEvent, String> {
+ 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::<Result<Vec<_>, _>>()?
+ .into_iter()
+ .map(|ex| (*ex.original_starts_at(), ex))
+ .collect::<HashMap<DateTime<Utc>, 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<RecurrenceException, String> {
+ 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,
+ }
+ }
+}
diff --git a/src/infrastructure/persistence/mod.rs b/src/infrastructure/persistence/mod.rs
new file mode 100644
index 0000000..99e0215
--- /dev/null
+++ b/src/infrastructure/persistence/mod.rs
@@ -0,0 +1,7 @@
+pub mod models;
+pub mod mappers;
+pub mod calendar_repository;
+pub mod event_repository;
+pub mod recurring_event_repository;
+
+pub use calendar_repository::SqliteCalendarRepository;
diff --git a/src/infrastructure/persistence/models.rs b/src/infrastructure/persistence/models.rs
new file mode 100644
index 0000000..dc39e94
--- /dev/null
+++ b/src/infrastructure/persistence/models.rs
@@ -0,0 +1,53 @@
+use sqlx::FromRow;
+
+#[derive(Debug, FromRow)]
+pub struct CalendarModel {
+ pub id: String,
+ pub name: String,
+ pub description: Option<String>,
+ pub is_archived: i64,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, FromRow)]
+pub struct EventModel {
+ pub id: String,
+ pub calendar_id: String,
+ pub title: String,
+ pub description: Option<String>,
+ pub starts_at: String,
+ pub ends_at: String,
+ pub color: i64,
+ pub is_all_day: i64,
+ pub is_cancelled: i64,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, FromRow)]
+pub struct RecurrenceModel {
+ pub id: String,
+ pub calendar_id: String,
+ pub title: String,
+ pub description: Option<String>,
+ pub starts_at: String,
+ pub ends_at: String,
+ pub frequency: String,
+ pub interval: i64,
+ pub until: Option<String>,
+ pub color: i64,
+ pub is_all_day: i64,
+ pub is_cancelled: i64,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, FromRow)]
+pub struct RecurrenceExceptionModel {
+ pub recurrence_id: String,
+ pub original_starts_at: String,
+ pub new_starts_at: Option<String>,
+ pub new_ends_at: Option<String>,
+ pub is_cancelled: i64,
+}
diff --git a/src/infrastructure/persistence/recurring_event_repository.rs b/src/infrastructure/persistence/recurring_event_repository.rs
new file mode 100644
index 0000000..af5753a
--- /dev/null
+++ b/src/infrastructure/persistence/recurring_event_repository.rs
@@ -0,0 +1,174 @@
+use async_trait::async_trait;
+use sqlx::SqlitePool;
+use crate::domain::{
+ recurrence::RecurringEvent,
+ repository::{RecurringEventRepository, RepositoryError, Result},
+ value_objects::CalendarId,
+};
+use super::{
+ models::{RecurrenceModel, RecurrenceExceptionModel},
+ mappers::RecurrenceMapper,
+};
+
+pub struct SqliteRecurringEventRepository {
+ pool: SqlitePool,
+}
+
+impl SqliteRecurringEventRepository {
+ pub fn new(pool: SqlitePool) -> Self {
+ Self { pool }
+ }
+}
+
+#[async_trait]
+impl RecurringEventRepository for SqliteRecurringEventRepository {
+ async fn save(&self, event: &RecurringEvent) -> Result<()> {
+ let mut tx = self.pool.begin()
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ let model = RecurrenceMapper::to_model(event);
+
+ sqlx::query!(
+ r#"
+ INSERT INTO recurrences (
+ id, calendar_id, title, description, starts_at, ends_at,
+ frequency, interval, until, color, is_all_day, is_cancelled,
+ created_at, updated_at
+ )
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
+ ON CONFLICT(id) DO UPDATE SET
+ title = excluded.title,
+ description = excluded.description,
+ starts_at = excluded.starts_at,
+ ends_at = excluded.ends_at,
+ frequency = excluded.frequency,
+ interval = excluded.interval,
+ until = excluded.until,
+ color = excluded.color,
+ is_all_day = excluded.is_all_day,
+ is_cancelled = excluded.is_cancelled,
+ updated_at = excluded.updated_at
+ "#,
+ model.id,
+ model.calendar_id,
+ model.title,
+ model.description,
+ model.starts_at,
+ model.ends_at,
+ model.frequency,
+ model.interval,
+ model.until,
+ model.color,
+ model.is_all_day,
+ model.is_cancelled,
+ model.created_at,
+ model.updated_at,
+ )
+ .execute(&mut *tx)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ sqlx::query!(
+ r#"
+ DELETE FROM recurrence_exceptions WHERE recurrence_id = ?1
+ "#,
+ model.id,
+ )
+ .execute(&mut *tx)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ for exception in event.exceptions().values() {
+ let ex_model = RecurrenceMapper::exception_to_model(
+ exception,
+ event.id(),
+ );
+
+ sqlx::query!(
+ r#"
+ INSERT INTO recurrence_exceptions (
+ recurrence_id, original_starts_at, new_starts_at,
+ new_ends_at, is_cancelled
+ )
+ VALUES (?1, ?2, ?3, ?4, ?5)
+ "#,
+ ex_model.recurrence_id,
+ ex_model.original_starts_at,
+ ex_model.new_starts_at,
+ ex_model.new_ends_at,
+ ex_model.is_cancelled,
+ )
+ .execute(&mut *tx)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+ }
+
+ tx.commit()
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ Ok(())
+ }
+
+ async fn find_by_calendar(&self, calendar_id: &CalendarId) -> Result<Vec<RecurringEvent>> {
+ let calendar_id_str = calendar_id.to_string();
+
+ let models = sqlx::query_as::<_, RecurrenceModel>(
+ r#"
+ SELECT id, calendar_id, title, description, starts_at, ends_at,
+ frequency, interval, until, color, is_all_day, is_cancelled,
+ created_at, updated_at
+ FROM recurrences
+ WHERE calendar_id = ?1
+ ORDER BY starts_at
+ "#
+ )
+ .bind(&calendar_id_str)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ let mut result = Vec::new();
+
+ for model in models {
+ let exceptions = sqlx::query_as::<_, RecurrenceExceptionModel>(
+ r#"
+ SELECT id, recurrence_id, original_starts_at, new_starts_at,
+ new_ends_at, is_cancelled
+ FROM recurrence_exceptions
+ WHERE recurrence_id = ?1
+ "#
+ )
+ .bind(&model.id)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ let event = RecurrenceMapper::to_domain(model, exceptions)
+ .map_err(|e| RepositoryError::DatabaseError(e))?;
+
+ result.push(event);
+ }
+
+ Ok(result)
+ }
+
+ async fn delete(&self, id: &str) -> Result<()> {
+ let result = sqlx::query!(
+ r#"
+ DELETE FROM recurrences WHERE id = ?1
+ "#,
+ id,
+ )
+ .execute(&self.pool)
+ .await
+ .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
+
+ if result.rows_affected() == 0 {
+ return Err(RepositoryError::NotFound);
+ }
+
+ Ok(())
+ }
+}