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