aven-core 0.1.12

Core library for the Aven local-first task manager
use crate::ids::WorkspaceId;
use anyhow::{Result, bail};
use sqlx::SqliteConnection;

use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
use crate::db::{Database, begin_immediate, field_version, insert_change, set_field_version};
use crate::ids::{TaskId, now};
use crate::refs::get_task_in_workspace;
use crate::task_fields::TaskField;
use crate::types::Task;
use crate::workspaces::Workspace;

impl Database {
    pub async fn add_task_to_epic(
        &self,
        workspace: &Workspace,
        child_id: &TaskId,
        epic_id: &TaskId,
    ) -> Result<EpicLinkOutcome> {
        let mut conn = self.acquire().await?;
        add_task_to_epic(&mut conn, workspace, child_id, epic_id).await
    }

    pub async fn remove_task_from_epic(
        &self,
        workspace: &Workspace,
        child_id: &TaskId,
        epic_id: &TaskId,
    ) -> Result<EpicLinkOutcome> {
        let mut conn = self.acquire().await?;
        remove_task_from_epic(&mut conn, workspace, child_id, epic_id).await
    }
}

pub struct EpicLinkOutcome {
    pub epic: Task,
    pub child: Task,
    pub changed: bool,
}

struct EpicPair {
    epic: Task,
    child: Task,
}

async fn load_epic_pair(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    child_id: &crate::ids::TaskId,
    epic_id: &crate::ids::TaskId,
) -> Result<EpicPair> {
    if child_id == epic_id {
        bail!("error epic-self task_id={child_id}");
    }

    let child = get_task_in_workspace(conn, workspace, child_id).await?;
    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
    if child.project_id != epic.project_id {
        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
    }
    if child.is_epic {
        bail!("error epic-child-is-epic child_task_id={child_id}");
    }

    Ok(EpicPair { epic, child })
}

async fn record_epic_change(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    pair: &EpicPair,
    op_type: &'static str,
) -> Result<()> {
    append_change(
        conn,
        ChangeEntity::Task,
        &pair.child.id,
        Some("epics"),
        op_type,
        ChangePayload::workspace(workspace)
            .set("epic_task_id", pair.epic.id.clone())
            .set("created_at", now()),
    )
    .await?;
    Ok(())
}

async fn mark_task_as_epic(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task: &Task,
) -> Result<()> {
    if task.is_epic {
        return Ok(());
    }
    let field = TaskField::IsEpic.as_str();
    let base = field_version(conn, &task.id, field).await?;
    let ts = now();
    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
        .bind(&ts)
        .bind(&task.workspace_id)
        .bind(&task.id)
        .execute(&mut *conn)
        .await?;
    let change_id = insert_change(
        conn,
        ChangeEntity::Task.as_str(),
        &task.id,
        Some(field),
        op_type::SET_FIELD,
        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
        base.as_deref(),
    )
    .await?;
    set_field_version(conn, &task.id, field, &change_id).await?;
    Ok(())
}

pub async fn add_task_to_epic(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    child_id: &crate::ids::TaskId,
    epic_id: &crate::ids::TaskId,
) -> Result<EpicLinkOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_epic_pair(&mut tx, workspace, child_id, epic_id).await?;
    let ts = now();
    if !pair.epic.is_epic {
        mark_task_as_epic(&mut tx, workspace, &pair.epic).await?;
    }
    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
    )
    .bind(&pair.child.workspace_id)
    .bind(&pair.child.id)
    .fetch_optional(&mut *tx)
    .await?;
    if let Some(existing_epic_id) = existing_epic_id
        && existing_epic_id != pair.epic.id
    {
        bail!(
            "error epic-child-already-linked child_task_id={} epic_task_id={}",
            pair.child.id,
            existing_epic_id
        );
    }
    let changed = sqlx::query(
        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
         VALUES (?, ?, ?, ?)",
    )
    .bind(&pair.child.workspace_id)
    .bind(&pair.epic.id)
    .bind(&pair.child.id)
    .bind(&ts)
    .execute(&mut *tx)
    .await?
    .rows_affected()
        > 0;

    if changed {
        record_epic_change(&mut tx, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
    }

    tx.commit().await?;
    Ok(EpicLinkOutcome {
        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
        child: pair.child,
        changed,
    })
}

pub async fn remove_task_from_epic(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    child_id: &crate::ids::TaskId,
    epic_id: &crate::ids::TaskId,
) -> Result<EpicLinkOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_epic_pair(&mut tx, workspace, child_id, epic_id).await?;
    let changed = sqlx::query(
        "DELETE FROM task_epic_links
         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
    )
    .bind(&pair.child.workspace_id)
    .bind(&pair.epic.id)
    .bind(&pair.child.id)
    .execute(&mut *tx)
    .await?
    .rows_affected()
        > 0;

    if changed {
        record_epic_change(&mut tx, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
    }

    tx.commit().await?;
    Ok(EpicLinkOutcome {
        epic: pair.epic,
        child: pair.child,
        changed,
    })
}

pub async fn task_has_epic_children(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_id: &crate::ids::TaskId,
) -> Result<bool> {
    Ok(sqlx::query_scalar::<_, i64>(
        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
    )
    .bind(workspace_id)
    .bind(task_id)
    .fetch_one(&mut *conn)
    .await?
        > 0)
}