1use crate::ids::WorkspaceId;
2use anyhow::{Result, bail};
3use serde_json::json;
4use sqlx::{Row, SqliteConnection};
5
6use crate::db::{Database, begin_immediate, insert_change};
7use crate::ids::now;
8use crate::projects::normalize_key;
9
10pub const DEFAULT_WORKSPACE_ID: &str = "0000000000000000";
11
12pub fn default_workspace_id() -> WorkspaceId {
13 DEFAULT_WORKSPACE_ID
14 .parse()
15 .expect("valid default workspace ID")
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Workspace {
20 pub id: WorkspaceId,
21 pub key: String,
22 pub name: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WorkspaceOpenSummary {
27 pub workspace: Workspace,
28 pub open_task_count: u32,
29}
30
31impl Default for Workspace {
32 fn default() -> Self {
33 Self {
34 id: DEFAULT_WORKSPACE_ID
35 .parse()
36 .expect("valid default workspace ID"),
37 key: "default".to_string(),
38 name: "default".to_string(),
39 }
40 }
41}
42
43impl Database {
44 pub async fn list_workspaces(&self) -> Result<Vec<Workspace>> {
45 let mut conn = self.acquire_reader().await?;
46 list_workspaces(&mut conn).await
47 }
48
49 pub async fn list_workspace_open_summaries(&self) -> Result<Vec<WorkspaceOpenSummary>> {
50 let mut conn = self.acquire_reader().await?;
51 let rows = sqlx::query(
52 "SELECT w.id, w.key, w.name,
53 COALESCE(SUM(CASE WHEN t.deleted = 0
54 AND t.status NOT IN ('done', 'canceled') THEN 1 ELSE 0 END), 0)
55 AS open_task_count
56 FROM workspaces w
57 LEFT JOIN tasks t ON t.workspace_id = w.id
58 WHERE w.archived = 0
59 GROUP BY w.id, w.key, w.name
60 ORDER BY w.key",
61 )
62 .fetch_all(&mut *conn)
63 .await?;
64 Ok(rows
65 .into_iter()
66 .map(|row| WorkspaceOpenSummary {
67 workspace: Workspace {
68 id: row.get("id"),
69 key: row.get("key"),
70 name: row.get("name"),
71 },
72 open_task_count: u32::try_from(row.get::<i64, _>("open_task_count"))
73 .unwrap_or(u32::MAX),
74 })
75 .collect())
76 }
77
78 pub async fn find_workspace(&self, name_or_key: &str) -> Result<Option<Workspace>> {
79 let mut conn = self.acquire_reader().await?;
80 find_workspace(&mut conn, name_or_key).await
81 }
82
83 pub async fn workspace_for_id(&self, workspace_id: &WorkspaceId) -> Result<Workspace> {
84 let mut conn = self.acquire_reader().await?;
85 workspace_for_id(&mut conn, workspace_id).await
86 }
87
88 pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<Workspace> {
89 self.resolve_required_workspace(name_or_key, "workspace")
90 .await
91 }
92
93 pub async fn resolve_required_workspace(
94 &self,
95 name_or_key: &str,
96 source: &str,
97 ) -> Result<Workspace> {
98 let mut conn = self.acquire_reader().await?;
99 resolve_required_workspace(&mut conn, name_or_key, source).await
100 }
101
102 pub async fn create_workspace(&self, name: &str) -> Result<Workspace> {
103 let mut conn = self.acquire_writer().await?;
104 let mut tx = begin_immediate(&mut conn).await?;
105 let workspace = create_workspace(&mut tx, name).await?;
106 tx.commit().await?;
107 Ok(workspace)
108 }
109
110 pub async fn rename_workspace(&self, workspace_ref: &str, new_name: &str) -> Result<Workspace> {
111 let mut conn = self.acquire_writer().await?;
112 let mut tx = begin_immediate(&mut conn).await?;
113 let workspace = rename_workspace(&mut tx, workspace_ref, new_name).await?;
114 tx.commit().await?;
115 Ok(workspace)
116 }
117}
118
119pub(crate) async fn ensure_default_workspace(conn: &mut SqliteConnection) -> Result<Workspace> {
120 if let Some(row) = sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ?")
121 .bind(DEFAULT_WORKSPACE_ID)
122 .fetch_optional(&mut *conn)
123 .await?
124 {
125 return Ok(Workspace {
126 id: row.get("id"),
127 key: row.get("key"),
128 name: row.get("name"),
129 });
130 }
131 let id: WorkspaceId = DEFAULT_WORKSPACE_ID
132 .parse()
133 .expect("valid default workspace ID");
134 let ts = now();
135 sqlx::query("INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, 'default', 'default', ?, ?)")
136 .bind(&id)
137 .bind(&ts)
138 .bind(&ts)
139 .execute(&mut *conn)
140 .await?;
141 Ok(Workspace {
142 id,
143 key: "default".to_string(),
144 name: "default".to_string(),
145 })
146}
147
148pub(crate) async fn list_workspaces(conn: &mut SqliteConnection) -> Result<Vec<Workspace>> {
149 let rows = sqlx::query("SELECT id, key, name FROM workspaces WHERE archived = 0 ORDER BY key")
150 .fetch_all(&mut *conn)
151 .await?;
152 Ok(rows
153 .into_iter()
154 .map(|row| Workspace {
155 id: row.get("id"),
156 key: row.get("key"),
157 name: row.get("name"),
158 })
159 .collect())
160}
161
162pub(crate) async fn find_workspace(
163 conn: &mut SqliteConnection,
164 name_or_key: &str,
165) -> Result<Option<Workspace>> {
166 let key = normalize_key(name_or_key);
167 let row = sqlx::query(
168 "SELECT id, key, name FROM workspaces
169 WHERE archived = 0 AND (key = ? OR lower(name) = lower(?))",
170 )
171 .bind(key)
172 .bind(name_or_key)
173 .fetch_optional(&mut *conn)
174 .await?;
175 Ok(row.map(workspace_from_row))
176}
177
178pub(crate) async fn workspace_for_id(
179 conn: &mut SqliteConnection,
180 workspace_id: &WorkspaceId,
181) -> Result<Workspace> {
182 let row = sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ? AND archived = 0")
183 .bind(workspace_id)
184 .fetch_optional(&mut *conn)
185 .await?;
186 match row {
187 Some(row) => Ok(workspace_from_row(row)),
188 None => Err(crate::error::CoreError::not_found(format!(
189 "error unknown-workspace-id id={workspace_id}"
190 ))
191 .into()),
192 }
193}
194
195pub(crate) async fn workspace_key_for_id(
196 conn: &mut SqliteConnection,
197 workspace_id: &WorkspaceId,
198) -> Result<String> {
199 Ok(workspace_for_id(conn, workspace_id).await?.key)
200}
201
202fn workspace_from_row(row: sqlx::sqlite::SqliteRow) -> Workspace {
203 Workspace {
204 id: row.get("id"),
205 key: row.get("key"),
206 name: row.get("name"),
207 }
208}
209
210pub(crate) async fn resolve_workspace(
211 conn: &mut SqliteConnection,
212 name_or_key: &str,
213) -> Result<Workspace> {
214 resolve_required_workspace(conn, name_or_key, "workspace").await
215}
216
217pub(crate) async fn resolve_required_workspace(
218 conn: &mut SqliteConnection,
219 name_or_key: &str,
220 source: &str,
221) -> Result<Workspace> {
222 if let Some(workspace) = find_workspace(conn, name_or_key).await? {
223 return Ok(workspace);
224 }
225 bail!(
226 "error unknown-workspace input={} source={} hint=\"create the workspace with aven workspace create\"",
227 name_or_key,
228 source
229 );
230}
231
232pub(crate) async fn create_workspace(conn: &mut SqliteConnection, name: &str) -> Result<Workspace> {
233 let key = normalize_key(name);
234 if key.is_empty() {
235 bail!("error invalid-workspace input={name}");
236 }
237 if find_workspace(conn, &key).await?.is_some() {
238 bail!("error workspace-exists key={key}");
239 }
240 let id = WorkspaceId::new();
241 let ts = now();
242 sqlx::query(
243 "INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
244 )
245 .bind(&id)
246 .bind(name)
247 .bind(&key)
248 .bind(&ts)
249 .bind(&ts)
250 .execute(&mut *conn)
251 .await?;
252 insert_change(
253 conn,
254 "workspace",
255 id.as_str(),
256 None,
257 "create_workspace",
258 json!({ "key": key, "name": name, "created_at": ts }),
259 None,
260 )
261 .await?;
262 Ok(Workspace {
263 id,
264 key,
265 name: name.to_string(),
266 })
267}
268
269pub(crate) async fn rename_workspace(
270 conn: &mut SqliteConnection,
271 workspace_ref: &str,
272 new_name: &str,
273) -> Result<Workspace> {
274 let workspace = resolve_workspace(conn, workspace_ref).await?;
275 let new_key = normalize_key(new_name);
276 if new_key.is_empty() {
277 bail!("error invalid-workspace input={new_name}");
278 }
279 if new_key != workspace.key && find_workspace(conn, &new_key).await?.is_some() {
280 bail!("error workspace-exists key={new_key}");
281 }
282 let ts = now();
283 sqlx::query("UPDATE workspaces SET name = ?, key = ?, updated_at = ? WHERE id = ?")
284 .bind(new_name)
285 .bind(&new_key)
286 .bind(&ts)
287 .bind(&workspace.id)
288 .execute(&mut *conn)
289 .await?;
290 if workspace.name != new_name {
291 insert_change(
292 conn,
293 "workspace",
294 workspace.id.as_str(),
295 Some("name"),
296 "set_workspace_field",
297 json!({ "value": new_name }),
298 None,
299 )
300 .await?;
301 }
302 if workspace.key != new_key {
303 insert_change(
304 conn,
305 "workspace",
306 workspace.id.as_str(),
307 Some("key"),
308 "set_workspace_field",
309 json!({ "value": new_key }),
310 None,
311 )
312 .await?;
313 }
314 Ok(Workspace {
315 id: workspace.id,
316 key: new_key,
317 name: new_name.to_string(),
318 })
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::db::open_db;
325
326 #[tokio::test]
327 async fn fresh_database_has_default_workspace() {
328 let dir = tempfile::tempdir().unwrap();
329 let pool = open_db(&dir.path().join("test.db")).await.unwrap();
330 let mut conn = pool.acquire().await.unwrap();
331 let workspaces = list_workspaces(&mut conn).await.unwrap();
332 assert_eq!(workspaces.len(), 1);
333 assert_eq!(workspaces[0].key, "default");
334 }
335}