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