Skip to main content

max / goingson

6.5 KB · 205 lines History Blame Raw
1 //! SQLite implementation of the MilestoneRepository.
2 //!
3 //! Manages milestones within projects, providing:
4 //! - CRUD operations
5 //! - Ordering/reordering within a project
6 //! - Status tracking (open/completed)
7
8 use async_trait::async_trait;
9 use chrono::NaiveDate;
10 use sqlx::SqlitePool;
11 use goingson_core::{
12 CoreError, DbValue, Milestone, MilestoneId, MilestoneRepository, MilestoneStatus,
13 NewMilestone, ParseableEnum, ProjectId, Result, UserId,
14 };
15
16 use crate::utils::{format_datetime_now, parse_datetime, parse_uuid};
17
18 /// Database row struct for Milestone.
19 #[derive(Debug, Clone, sqlx::FromRow)]
20 struct MilestoneRow {
21 pub id: String,
22 pub user_id: String,
23 pub project_id: String,
24 pub name: String,
25 pub description: String,
26 pub position: i32,
27 pub target_date: Option<String>,
28 pub status: String,
29 pub created_at: String,
30 }
31
32 impl TryFrom<MilestoneRow> for Milestone {
33 type Error = CoreError;
34
35 fn try_from(row: MilestoneRow) -> std::result::Result<Self, Self::Error> {
36 let target_date = row.target_date
37 .as_deref()
38 .filter(|s| !s.is_empty())
39 .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
40 .transpose()
41 .map_err(|_| CoreError::parse("Invalid milestone target_date"))?;
42
43 Ok(Milestone {
44 id: parse_uuid(&row.id)?.into(),
45 user_id: parse_uuid(&row.user_id)?.into(),
46 project_id: parse_uuid(&row.project_id)?.into(),
47 name: row.name,
48 description: row.description,
49 position: row.position,
50 target_date,
51 status: MilestoneStatus::from_str_or_default(&row.status),
52 created_at: parse_datetime(&row.created_at)?,
53 })
54 }
55 }
56
57 /// SQLite-backed implementation of [`MilestoneRepository`].
58 pub struct SqliteMilestoneRepository {
59 pool: SqlitePool,
60 }
61
62 impl SqliteMilestoneRepository {
63 /// Creates a new repository instance with the given connection pool.
64 #[tracing::instrument(skip_all)]
65 pub fn new(pool: SqlitePool) -> Self {
66 Self { pool }
67 }
68 }
69
70 #[async_trait]
71 impl MilestoneRepository for SqliteMilestoneRepository {
72 #[tracing::instrument(skip_all)]
73 async fn list_by_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Milestone>> {
74 let rows = sqlx::query_as::<_, MilestoneRow>(
75 r#"
76 SELECT id, user_id, project_id, name, description, position, target_date, status, created_at
77 FROM milestones
78 WHERE project_id = ? AND user_id = ?
79 ORDER BY position ASC, created_at ASC
80 "#,
81 )
82 .bind(project_id.to_string())
83 .bind(user_id.to_string())
84 .fetch_all(&self.pool)
85 .await
86 .map_err(CoreError::database)?;
87
88 rows.into_iter().map(Milestone::try_from).collect()
89 }
90
91 #[tracing::instrument(skip_all)]
92 async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<Milestone>> {
93 let row = sqlx::query_as::<_, MilestoneRow>(
94 r#"
95 SELECT id, user_id, project_id, name, description, position, target_date, status, created_at
96 FROM milestones
97 WHERE id = ? AND user_id = ?
98 "#,
99 )
100 .bind(id.to_string())
101 .bind(user_id.to_string())
102 .fetch_optional(&self.pool)
103 .await
104 .map_err(CoreError::database)?;
105
106 row.map(Milestone::try_from).transpose()
107 }
108
109 #[tracing::instrument(skip_all)]
110 async fn create(&self, user_id: UserId, milestone: NewMilestone) -> Result<Milestone> {
111 let id = MilestoneId::new();
112 let now = format_datetime_now();
113 let target_date_str = milestone.target_date.map(|d| d.format("%Y-%m-%d").to_string());
114
115 sqlx::query(
116 r#"
117 INSERT INTO milestones (id, user_id, project_id, name, description, position, target_date, status, created_at)
118 VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?)
119 "#,
120 )
121 .bind(id.to_string())
122 .bind(user_id.to_string())
123 .bind(milestone.project_id.to_string())
124 .bind(&milestone.name)
125 .bind(&milestone.description)
126 .bind(milestone.position)
127 .bind(&target_date_str)
128 .bind(&now)
129 .execute(&self.pool)
130 .await
131 .map_err(CoreError::database)?;
132
133 self.get_by_id(id, user_id)
134 .await?
135 .ok_or_else(|| CoreError::internal("Failed to retrieve created milestone"))
136 }
137
138 #[tracing::instrument(skip_all)]
139 async fn update(
140 &self,
141 id: MilestoneId,
142 user_id: UserId,
143 name: &str,
144 description: &str,
145 target_date: Option<NaiveDate>,
146 status: &MilestoneStatus,
147 ) -> Result<Option<Milestone>> {
148 let target_date_str = target_date.map(|d| d.format("%Y-%m-%d").to_string());
149
150 let result = sqlx::query(
151 r#"
152 UPDATE milestones
153 SET name = ?, description = ?, target_date = ?, status = ?
154 WHERE id = ? AND user_id = ?
155 "#,
156 )
157 .bind(name)
158 .bind(description)
159 .bind(&target_date_str)
160 .bind(status.db_value())
161 .bind(id.to_string())
162 .bind(user_id.to_string())
163 .execute(&self.pool)
164 .await
165 .map_err(CoreError::database)?;
166
167 if result.rows_affected() > 0 {
168 self.get_by_id(id, user_id).await
169 } else {
170 Ok(None)
171 }
172 }
173
174 #[tracing::instrument(skip_all)]
175 async fn delete(&self, id: MilestoneId, user_id: UserId) -> Result<bool> {
176 let result = sqlx::query("DELETE FROM milestones WHERE id = ? AND user_id = ?")
177 .bind(id.to_string())
178 .bind(user_id.to_string())
179 .execute(&self.pool)
180 .await
181 .map_err(CoreError::database)?;
182
183 Ok(result.rows_affected() > 0)
184 }
185
186 #[tracing::instrument(skip_all)]
187 async fn reorder(&self, project_id: ProjectId, user_id: UserId, milestone_ids: &[MilestoneId]) -> Result<()> {
188 let mut tx = self.pool.begin().await.map_err(CoreError::database)?;
189 for (i, id) in milestone_ids.iter().enumerate() {
190 sqlx::query(
191 "UPDATE milestones SET position = ? WHERE id = ? AND user_id = ? AND project_id = ?"
192 )
193 .bind(i as i32)
194 .bind(id.to_string())
195 .bind(user_id.to_string())
196 .bind(project_id.to_string())
197 .execute(&mut *tx)
198 .await
199 .map_err(CoreError::database)?;
200 }
201 tx.commit().await.map_err(CoreError::database)?;
202 Ok(())
203 }
204 }
205