1use crate::ids::{ProjectId, WorkspaceId};
2use crate::recurrence::RecurrenceSeriesId;
3
4use anyhow::{Result, bail};
5use sqlx::{Row, SqliteConnection};
6use tracing::info;
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{Database, begin_immediate};
10use crate::ids::now;
11use crate::projects::{
12 create_project_in_workspace, ensure_project_prefix_available, normalize_key, normalize_prefix,
13 resolve_existing_project_in_workspace,
14};
15use crate::types::Project;
16use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
17use crate::workspaces::Workspace;
18
19pub struct ProjectOutcome {
20 pub project: Project,
21 pub created: bool,
22 pub change_id: Option<String>,
23}
24
25pub struct ProjectDeleteOutcome {
26 pub project: Project,
27 pub stopped_series_count: usize,
28}
29
30pub struct ProjectRenameOutcome {
31 pub previous: Project,
32 pub project: Project,
33 pub changed: bool,
34}
35
36impl Database {
37 pub async fn create_project(
38 &self,
39 workspace: &Workspace,
40 name: &str,
41 prefix: Option<&str>,
42 ) -> Result<ProjectOutcome> {
43 let mut conn = self.acquire_writer().await?;
44 let mut tx = begin_immediate(&mut conn).await?;
45 let outcome = match create_project_operation(&mut tx, workspace, name, prefix).await {
46 Ok(outcome) => outcome,
47 Err(error) => {
48 tx.rollback().await?;
49 return Err(error);
50 }
51 };
52 tx.commit().await?;
53 Ok(outcome)
54 }
55
56 pub async fn create_project_with_tui_undo(
57 &self,
58 workspace: &Workspace,
59 name: &str,
60 ) -> Result<ProjectOutcome> {
61 let mut conn = self.acquire_writer().await?;
62 let mut tx = begin_immediate(&mut conn).await?;
63 let outcome = create_project_operation(&mut tx, workspace, name, None).await?;
64 if outcome.created {
65 record_tui_undo(
66 &mut tx,
67 &workspace.id,
68 UndoPayload {
69 commands: vec![UndoCommand::DeleteCreatedProject {
70 project_key: outcome.project.key.clone(),
71 create_change_id: outcome.change_id.clone().unwrap_or_default(),
72 expected_name: outcome.project.name.clone(),
73 expected_prefix: outcome.project.prefix.clone(),
74 }],
75 },
76 )
77 .await?;
78 }
79 tx.commit().await?;
80 Ok(outcome)
81 }
82
83 pub async fn delete_project(
84 &self,
85 workspace: &Workspace,
86 project: &str,
87 ) -> Result<ProjectDeleteOutcome> {
88 let mut conn = self.acquire_writer().await?;
89 delete_project_operation(&mut conn, workspace, project).await
90 }
91
92 pub async fn rename_project(
93 &self,
94 workspace: &Workspace,
95 project: &str,
96 new_name: &str,
97 prefix: Option<&str>,
98 ) -> Result<ProjectRenameOutcome> {
99 let mut conn = self.acquire_writer().await?;
100 rename_project_operation(&mut conn, workspace, project, new_name, prefix).await
101 }
102
103 pub async fn rename_project_with_tui_undo(
104 &self,
105 workspace: &Workspace,
106 project: &str,
107 new_name: &str,
108 prefix: Option<&str>,
109 ) -> Result<ProjectRenameOutcome> {
110 let mut conn = self.acquire_writer().await?;
111 let (outcome, ()) = rename_project_operation_before_commit(
112 &mut conn,
113 workspace,
114 project,
115 new_name,
116 prefix,
117 |_| Ok(()),
118 true,
119 )
120 .await?;
121 Ok(outcome)
122 }
123
124 pub async fn rename_project_before_commit<T, F>(
125 &self,
126 workspace: &Workspace,
127 project: &str,
128 new_name: &str,
129 prefix: Option<&str>,
130 before_commit: F,
131 ) -> Result<(ProjectRenameOutcome, T)>
132 where
133 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
134 {
135 let mut conn = self.acquire_writer().await?;
136 rename_project_operation_before_commit(
137 &mut conn,
138 workspace,
139 project,
140 new_name,
141 prefix,
142 before_commit,
143 false,
144 )
145 .await
146 }
147}
148
149#[derive(Clone, Copy)]
150pub struct ProjectMetadata<'a> {
151 pub key: &'a str,
152 pub name: &'a str,
153 pub prefix: &'a str,
154}
155
156pub(crate) async fn create_project_operation(
157 conn: &mut SqliteConnection,
158 workspace: &Workspace,
159 name: &str,
160 prefix: Option<&str>,
161) -> Result<ProjectOutcome> {
162 let outcome = create_project_in_workspace(conn, &workspace.id, name, prefix).await?;
163 if outcome.created {
164 info!(project_key = %outcome.project.key, "project created");
165 }
166 Ok(ProjectOutcome {
167 project: outcome.project,
168 created: outcome.created,
169 change_id: outcome.change_id,
170 })
171}
172
173pub async fn delete_project_operation(
174 conn: &mut SqliteConnection,
175 workspace: &Workspace,
176 project: &str,
177) -> Result<ProjectDeleteOutcome> {
178 let mut tx = begin_immediate(conn).await?;
179 let project = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
180 let deleted_at = now();
181 let series_ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
182 "SELECT id FROM recurrence_series
183 WHERE workspace_id = ? AND project_id = ? AND deleted = 0 AND state != 'stopped'
184 ORDER BY id",
185 )
186 .bind(&project.workspace_id)
187 .bind(&project.id)
188 .fetch_all(&mut *tx)
189 .await?;
190 for series_id in &series_ids {
191 super::recurrence::stop_recurrence_series_for_project_delete_in_transaction(
192 &mut tx,
193 workspace,
194 series_id,
195 &deleted_at,
196 )
197 .await?;
198 }
199 let task_refs: i64 =
200 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
201 .bind(&project.workspace_id)
202 .bind(&project.id)
203 .fetch_one(&mut *tx)
204 .await?;
205 let series_refs: i64 = sqlx::query_scalar(
206 "SELECT count(*) FROM recurrence_series WHERE workspace_id = ? AND project_id = ?",
207 )
208 .bind(&project.workspace_id)
209 .bind(&project.id)
210 .fetch_one(&mut *tx)
211 .await?;
212 sqlx::query("DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ?")
213 .bind(&project.workspace_id)
214 .bind(&project.id)
215 .execute(&mut *tx)
216 .await?;
217 let deleted = if task_refs > 0 || series_refs > 0 {
218 sqlx::query(
219 "UPDATE projects SET deleted = 1, updated_at = ? WHERE workspace_id = ? AND key = ?",
220 )
221 .bind(&deleted_at)
222 .bind(&project.workspace_id)
223 .bind(&project.key)
224 .execute(&mut *tx)
225 .await?
226 } else {
227 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
228 .bind(&project.workspace_id)
229 .bind(&project.key)
230 .execute(&mut *tx)
231 .await?
232 };
233 if deleted.rows_affected() != 1 {
234 bail!("error project-delete-race project={}", project.key);
235 }
236 append_change(
237 &mut tx,
238 ChangeEntity::Project,
239 project.id.as_str(),
240 None,
241 op_type::PROJECT_DELETE,
242 ChangePayload::workspace(workspace).set("deleted_at", deleted_at),
243 )
244 .await?;
245 tx.commit().await?;
246 info!(stopped_series_count = series_ids.len(), "project deleted");
247 Ok(ProjectDeleteOutcome {
248 project,
249 stopped_series_count: series_ids.len(),
250 })
251}
252
253pub async fn rename_project_operation(
254 conn: &mut SqliteConnection,
255 workspace: &Workspace,
256 project: &str,
257 new_name: &str,
258 prefix: Option<&str>,
259) -> Result<ProjectRenameOutcome> {
260 let (outcome, ()) = rename_project_operation_before_commit(
261 conn,
262 workspace,
263 project,
264 new_name,
265 prefix,
266 |_| Ok(()),
267 false,
268 )
269 .await?;
270 Ok(outcome)
271}
272
273async fn rename_project_operation_before_commit<T, F>(
274 conn: &mut SqliteConnection,
275 workspace: &Workspace,
276 project: &str,
277 new_name: &str,
278 prefix: Option<&str>,
279 before_commit: F,
280 tui_undo: bool,
281) -> Result<(ProjectRenameOutcome, T)>
282where
283 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
284{
285 let mut tx = begin_immediate(conn).await?;
286 let previous = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
287 let new_name = new_name.trim();
288 let key = normalize_key(new_name);
289 if key.is_empty() {
290 bail!("error invalid-project input={new_name:?}");
291 }
292 if let Some(existing) = resolve_project_key(&mut tx, &workspace.id, &key).await?
293 && existing.id != previous.id
294 {
295 bail!("error project-exists project={key}");
296 }
297 let prefix = match prefix {
298 Some(prefix) => {
299 let prefix = normalize_prefix(prefix)?;
300 ensure_project_prefix_available(&mut tx, &workspace.id, &prefix, Some(&previous.id))
301 .await?;
302 prefix
303 }
304 None => previous.prefix.clone(),
305 };
306 let changed = previous.key != key || previous.name != new_name || previous.prefix != prefix;
307 if !changed {
308 let outcome = ProjectRenameOutcome {
309 project: previous.clone(),
310 previous,
311 changed: false,
312 };
313 let value = before_commit(&outcome)?;
314 tx.commit().await?;
315 return Ok((outcome, value));
316 }
317 let project = set_project_metadata(
318 &mut tx,
319 workspace,
320 &previous.id,
321 ProjectMetadata {
322 key: &key,
323 name: new_name,
324 prefix: &prefix,
325 },
326 true,
327 )
328 .await?;
329 let outcome = ProjectRenameOutcome {
330 previous,
331 project,
332 changed: true,
333 };
334 let value = before_commit(&outcome)?;
335 if tui_undo {
336 record_tui_undo(
337 &mut tx,
338 &workspace.id,
339 UndoPayload {
340 commands: vec![UndoCommand::SetProjectMetadata {
341 project_id: outcome.project.id.clone(),
342 before_key: outcome.previous.key.clone(),
343 before_name: outcome.previous.name.clone(),
344 before_prefix: outcome.previous.prefix.clone(),
345 after_key: outcome.project.key.clone(),
346 after_name: outcome.project.name.clone(),
347 after_prefix: outcome.project.prefix.clone(),
348 }],
349 },
350 )
351 .await?;
352 }
353 tx.commit().await?;
354 info!(project_id = %outcome.project.id, project_key = %outcome.project.key, "project renamed");
355 Ok((outcome, value))
356}
357
358pub async fn set_project_metadata(
359 conn: &mut SqliteConnection,
360 workspace: &Workspace,
361 project_id: &ProjectId,
362 metadata: ProjectMetadata<'_>,
363 record_change: bool,
364) -> Result<Project> {
365 let ts = now();
366 let updated = sqlx::query(
367 "UPDATE projects SET key = ?, name = ?, prefix = ?, updated_at = ?
368 WHERE workspace_id = ? AND id = ? AND deleted = 0",
369 )
370 .bind(metadata.key)
371 .bind(metadata.name)
372 .bind(metadata.prefix)
373 .bind(&ts)
374 .bind(&workspace.id)
375 .bind(project_id)
376 .execute(&mut *conn)
377 .await?;
378 if updated.rows_affected() != 1 {
379 bail!("error project-metadata-target-missing project_id={project_id}");
380 }
381 if record_change {
382 insert_project_metadata_change(conn, workspace, project_id, metadata, &ts).await?;
383 }
384 Ok(Project {
385 id: project_id.clone(),
386 workspace_id: workspace.id.clone(),
387 key: metadata.key.to_string(),
388 name: metadata.name.to_string(),
389 prefix: metadata.prefix.to_string(),
390 })
391}
392
393pub async fn insert_project_metadata_change(
394 conn: &mut SqliteConnection,
395 workspace: &Workspace,
396 project_id: &ProjectId,
397 metadata: ProjectMetadata<'_>,
398 updated_at: &str,
399) -> Result<String> {
400 append_change(
401 conn,
402 ChangeEntity::Project,
403 project_id.as_str(),
404 None,
405 op_type::SET_PROJECT_METADATA,
406 ChangePayload::workspace(workspace)
407 .set("key", metadata.key)
408 .set("name", metadata.name)
409 .set("prefix", metadata.prefix)
410 .set("updated_at", updated_at),
411 )
412 .await
413}
414
415async fn resolve_project_key(
416 conn: &mut SqliteConnection,
417 workspace_id: &WorkspaceId,
418 key: &str,
419) -> Result<Option<Project>> {
420 let row = sqlx::query(
421 "SELECT id, workspace_id, key, name, prefix
422 FROM projects
423 WHERE workspace_id = ? AND key = ?",
424 )
425 .bind(workspace_id)
426 .bind(key)
427 .fetch_optional(&mut *conn)
428 .await?;
429 Ok(row.map(|row| Project {
430 id: row.get("id"),
431 workspace_id: row.get("workspace_id"),
432 key: row.get("key"),
433 name: row.get("name"),
434 prefix: row.get("prefix"),
435 }))
436}
437
438#[cfg(test)]
439mod tests;