Skip to main content

max / goingson

6.3 KB · 206 lines History Blame Raw
1 //! SQLite implementation of the ProjectRepository.
2 //!
3 //! Manages projects, which are the top-level organizational unit in GoingsOn.
4 //! Projects can be of various types (Job, SideProject, Company, etc.) and have
5 //! associated tasks, events, and emails.
6
7 use async_trait::async_trait;
8 use sqlx::SqlitePool;
9 use goingson_core::{
10 CoreError, DbValue, NewProject, ParseableEnum, Project, ProjectId, ProjectRepository,
11 ProjectStatus, ProjectType, Result, UpdateProject, UserId,
12 };
13
14 use crate::utils::{format_datetime, format_datetime_now, parse_datetime, parse_uuid};
15
16 /// Database row struct for Project
17 #[derive(Debug, Clone, sqlx::FromRow)]
18 struct ProjectRow {
19 pub id: String,
20 pub name: String,
21 pub description: String,
22 pub project_type: String,
23 pub status: String,
24 pub created_at: String,
25 }
26
27 impl TryFrom<ProjectRow> for Project {
28 type Error = CoreError;
29
30 fn try_from(row: ProjectRow) -> std::result::Result<Self, Self::Error> {
31 Ok(Project {
32 id: parse_uuid(&row.id)?.into(),
33 name: row.name,
34 description: row.description,
35 project_type: ProjectType::from_str_or_default(&row.project_type),
36 status: ProjectStatus::from_str_or_default(&row.status),
37 created_at: parse_datetime(&row.created_at)?,
38 })
39 }
40 }
41
42 /// SQLite-backed implementation of [`ProjectRepository`].
43 ///
44 /// Provides CRUD operations for projects with automatic UUID generation
45 /// and timestamp management.
46 pub struct SqliteProjectRepository {
47 pool: SqlitePool,
48 }
49
50 impl SqliteProjectRepository {
51 /// Creates a new repository instance with the given connection pool.
52 #[tracing::instrument(skip_all)]
53 pub fn new(pool: SqlitePool) -> Self {
54 Self { pool }
55 }
56 }
57
58 #[async_trait]
59 impl ProjectRepository for SqliteProjectRepository {
60 #[tracing::instrument(skip_all)]
61 async fn list_all(&self, user_id: UserId) -> Result<Vec<Project>> {
62 let rows = sqlx::query_as::<_, ProjectRow>(
63 r#"
64 SELECT id, name, description, project_type, status, created_at
65 FROM projects
66 WHERE user_id = ?
67 ORDER BY created_at DESC
68 "#,
69 )
70 .bind(user_id.to_string())
71 .fetch_all(&self.pool)
72 .await
73 .map_err(CoreError::database)?;
74
75 rows.into_iter().map(Project::try_from).collect()
76 }
77
78 #[tracing::instrument(skip_all)]
79 async fn get_by_id(&self, id: ProjectId, user_id: UserId) -> Result<Option<Project>> {
80 let row = sqlx::query_as::<_, ProjectRow>(
81 r#"
82 SELECT id, name, description, project_type, status, created_at
83 FROM projects
84 WHERE id = ? AND user_id = ?
85 "#,
86 )
87 .bind(id.to_string())
88 .bind(user_id.to_string())
89 .fetch_optional(&self.pool)
90 .await
91 .map_err(CoreError::database)?;
92
93 row.map(Project::try_from).transpose()
94 }
95
96 #[tracing::instrument(skip_all)]
97 async fn create(&self, user_id: UserId, project: NewProject) -> Result<Project> {
98 let id = ProjectId::new();
99 let now = format_datetime_now();
100
101 sqlx::query(
102 r#"
103 INSERT INTO projects (id, user_id, name, description, project_type, status, created_at)
104 VALUES (?, ?, ?, ?, ?, ?, ?)
105 "#,
106 )
107 .bind(id.to_string())
108 .bind(user_id.to_string())
109 .bind(&project.name)
110 .bind(&project.description)
111 .bind(project.project_type.db_value())
112 .bind(project.status.db_value())
113 .bind(&now)
114 .execute(&self.pool)
115 .await
116 .map_err(CoreError::database)?;
117
118 // Fetch the created project
119 self.get_by_id(id, user_id)
120 .await?
121 .ok_or_else(|| CoreError::internal("Failed to retrieve created project"))
122 }
123
124 #[tracing::instrument(skip_all)]
125 async fn restore(&self, user_id: UserId, project: &Project) -> Result<()> {
126 sqlx::query(
127 r#"
128 INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at)
129 VALUES (?, ?, ?, ?, ?, ?, ?)
130 "#,
131 )
132 .bind(project.id.to_string())
133 .bind(user_id.to_string())
134 .bind(&project.name)
135 .bind(&project.description)
136 .bind(project.project_type.db_value())
137 .bind(project.status.db_value())
138 .bind(format_datetime(&project.created_at))
139 .execute(&self.pool)
140 .await
141 .map_err(CoreError::database)?;
142 Ok(())
143 }
144
145 #[tracing::instrument(skip_all)]
146 async fn update(
147 &self,
148 id: ProjectId,
149 user_id: UserId,
150 project: UpdateProject,
151 ) -> Result<Option<Project>> {
152 let result = sqlx::query(
153 r#"
154 UPDATE projects
155 SET name = ?, description = ?, project_type = ?, status = ?
156 WHERE id = ? AND user_id = ?
157 "#,
158 )
159 .bind(&project.name)
160 .bind(&project.description)
161 .bind(project.project_type.db_value())
162 .bind(project.status.db_value())
163 .bind(id.to_string())
164 .bind(user_id.to_string())
165 .execute(&self.pool)
166 .await
167 .map_err(CoreError::database)?;
168
169 if result.rows_affected() > 0 {
170 self.get_by_id(id, user_id).await
171 } else {
172 Ok(None)
173 }
174 }
175
176 #[tracing::instrument(skip_all)]
177 async fn delete(&self, id: ProjectId, user_id: UserId) -> Result<bool> {
178 let result = sqlx::query("DELETE FROM projects WHERE id = ? AND user_id = ?")
179 .bind(id.to_string())
180 .bind(user_id.to_string())
181 .execute(&self.pool)
182 .await
183 .map_err(CoreError::database)?;
184
185 Ok(result.rows_affected() > 0)
186 }
187
188 #[tracing::instrument(skip_all)]
189 async fn find_by_name(&self, user_id: UserId, name: &str) -> Result<Option<Project>> {
190 let row = sqlx::query_as::<_, ProjectRow>(
191 r#"
192 SELECT id, name, description, project_type, status, created_at
193 FROM projects
194 WHERE user_id = ? AND LOWER(name) = LOWER(?)
195 "#,
196 )
197 .bind(user_id.to_string())
198 .bind(name)
199 .fetch_optional(&self.pool)
200 .await
201 .map_err(CoreError::database)?;
202
203 row.map(Project::try_from).transpose()
204 }
205 }
206