Skip to main content

aven_core/operations/
projects.rs

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;