Skip to main content

max / goingson

7.1 KB · 223 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 list_all(&self, user_id: UserId) -> Result<Vec<Milestone>> {
93 let rows = 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 user_id = ?
98 ORDER BY created_at ASC
99 "#,
100 )
101 .bind(user_id.to_string())
102 .fetch_all(&self.pool)
103 .await
104 .map_err(CoreError::database)?;
105
106 rows.into_iter().map(Milestone::try_from).collect()
107 }
108
109 #[tracing::instrument(skip_all)]
110 async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<Milestone>> {
111 let row = sqlx::query_as::<_, MilestoneRow>(
112 r#"
113 SELECT id, user_id, project_id, name, description, position, target_date, status, created_at
114 FROM milestones
115 WHERE id = ? AND user_id = ?
116 "#,
117 )
118 .bind(id.to_string())
119 .bind(user_id.to_string())
120 .fetch_optional(&self.pool)
121 .await
122 .map_err(CoreError::database)?;
123
124 row.map(Milestone::try_from).transpose()
125 }
126
127 #[tracing::instrument(skip_all)]
128 async fn create(&self, user_id: UserId, milestone: NewMilestone) -> Result<Milestone> {
129 let id = MilestoneId::new();
130 let now = format_datetime_now();
131 let target_date_str = milestone.target_date.map(|d| d.format("%Y-%m-%d").to_string());
132
133 sqlx::query(
134 r#"
135 INSERT INTO milestones (id, user_id, project_id, name, description, position, target_date, status, created_at)
136 VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?)
137 "#,
138 )
139 .bind(id.to_string())
140 .bind(user_id.to_string())
141 .bind(milestone.project_id.to_string())
142 .bind(&milestone.name)
143 .bind(&milestone.description)
144 .bind(milestone.position)
145 .bind(&target_date_str)
146 .bind(&now)
147 .execute(&self.pool)
148 .await
149 .map_err(CoreError::database)?;
150
151 self.get_by_id(id, user_id)
152 .await?
153 .ok_or_else(|| CoreError::internal("Failed to retrieve created milestone"))
154 }
155
156 #[tracing::instrument(skip_all)]
157 async fn update(
158 &self,
159 id: MilestoneId,
160 user_id: UserId,
161 name: &str,
162 description: &str,
163 target_date: Option<NaiveDate>,
164 status: &MilestoneStatus,
165 ) -> Result<Option<Milestone>> {
166 let target_date_str = target_date.map(|d| d.format("%Y-%m-%d").to_string());
167
168 let result = sqlx::query(
169 r#"
170 UPDATE milestones
171 SET name = ?, description = ?, target_date = ?, status = ?
172 WHERE id = ? AND user_id = ?
173 "#,
174 )
175 .bind(name)
176 .bind(description)
177 .bind(&target_date_str)
178 .bind(status.db_value())
179 .bind(id.to_string())
180 .bind(user_id.to_string())
181 .execute(&self.pool)
182 .await
183 .map_err(CoreError::database)?;
184
185 if result.rows_affected() > 0 {
186 self.get_by_id(id, user_id).await
187 } else {
188 Ok(None)
189 }
190 }
191
192 #[tracing::instrument(skip_all)]
193 async fn delete(&self, id: MilestoneId, user_id: UserId) -> Result<bool> {
194 let result = sqlx::query("DELETE FROM milestones WHERE id = ? AND user_id = ?")
195 .bind(id.to_string())
196 .bind(user_id.to_string())
197 .execute(&self.pool)
198 .await
199 .map_err(CoreError::database)?;
200
201 Ok(result.rows_affected() > 0)
202 }
203
204 #[tracing::instrument(skip_all)]
205 async fn reorder(&self, project_id: ProjectId, user_id: UserId, milestone_ids: &[MilestoneId]) -> Result<()> {
206 let mut tx = self.pool.begin().await.map_err(CoreError::database)?;
207 for (i, id) in milestone_ids.iter().enumerate() {
208 sqlx::query(
209 "UPDATE milestones SET position = ? WHERE id = ? AND user_id = ? AND project_id = ?"
210 )
211 .bind(i as i32)
212 .bind(id.to_string())
213 .bind(user_id.to_string())
214 .bind(project_id.to_string())
215 .execute(&mut *tx)
216 .await
217 .map_err(CoreError::database)?;
218 }
219 tx.commit().await.map_err(CoreError::database)?;
220 Ok(())
221 }
222 }
223