use crate::error::CoreError;
use crate::ids::WorkspaceId;
use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
use anyhow::Result;
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::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
use crate::workspaces::Workspace;
impl Database {
pub async fn add_task_to_epic(
&self,
workspace: &Workspace,
child_id: &TaskId,
epic_id: &TaskId,
) -> Result<EpicLinkOutcome> {
self.add_task_to_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
.await
}
pub async fn add_task_to_epic_with_undo(
&self,
workspace: &Workspace,
child_id: &TaskId,
epic_id: &TaskId,
undo: UndoContext,
) -> Result<EpicLinkOutcome> {
let mut conn = self.acquire_writer().await?;
let mut tx = begin_immediate(&mut conn).await?;
let outcome =
add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
if outcome.changed
&& let UndoContext::Tui = undo
{
let mut commands = vec![UndoCommand::AddEpicChild {
epic_id: outcome.epic.id.clone(),
child_id: outcome.child.id.clone(),
}];
if outcome.promoted {
commands.push(UndoCommand::SetTaskField {
task_id: outcome.epic.id.clone(),
field: TaskField::IsEpic.as_str().to_string(),
before: "0".to_string(),
after: "1".to_string(),
queue_activity_before: None,
queue_activity_after: None,
});
}
record_tui_undo(&mut tx, &workspace.id, UndoPayload { commands }).await?;
}
tx.commit().await?;
Ok(outcome)
}
pub async fn remove_task_from_epic(
&self,
workspace: &Workspace,
child_id: &TaskId,
epic_id: &TaskId,
) -> Result<EpicLinkOutcome> {
self.remove_task_from_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
.await
}
pub async fn remove_task_from_epic_with_undo(
&self,
workspace: &Workspace,
child_id: &TaskId,
epic_id: &TaskId,
undo: UndoContext,
) -> Result<EpicLinkOutcome> {
let mut conn = self.acquire_writer().await?;
let mut tx = begin_immediate(&mut conn).await?;
let outcome =
remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
if outcome.changed
&& let UndoContext::Tui = undo
{
record_tui_undo(
&mut tx,
&workspace.id,
UndoPayload {
commands: vec![UndoCommand::RemoveEpicChild {
epic_id: outcome.epic.id.clone(),
child_id: outcome.child.id.clone(),
}],
},
)
.await?;
}
tx.commit().await?;
Ok(outcome)
}
}
pub struct EpicLinkOutcome {
pub epic: Task,
pub child: Task,
pub changed: bool,
pub promoted: 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 {
return Err(CoreError::validation(format!("error epic-self task_id={child_id}")).into());
}
let child = get_task_in_workspace(conn, workspace, child_id).await?;
let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
Ok(EpicPair { epic, child })
}
async fn insert_epic_link_if_absent(
conn: &mut SqliteConnection,
pair: &EpicPair,
created_at: &str,
) -> Result<bool> {
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 *conn)
.await?;
if let Some(existing_epic_id) = existing_epic_id
&& existing_epic_id != pair.epic.id
{
return Err(CoreError::validation(format!(
"error epic-child-already-linked child_task_id={} epic_task_id={}",
pair.child.id, existing_epic_id
))
.into());
}
Ok(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(created_at)
.execute(&mut *conn)
.await?
.rows_affected()
> 0)
}
async fn record_epic_change(
conn: &mut SqliteConnection,
workspace: &Workspace,
pair: &EpicPair,
op_type: &'static str,
at: &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", at),
)
.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(())
}
#[cfg(any(test, feature = "test-support"))]
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 outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
tx.commit().await?;
Ok(outcome)
}
pub(crate) async fn add_task_to_epic_in_transaction(
conn: &mut SqliteConnection,
workspace: &Workspace,
child_id: &crate::ids::TaskId,
epic_id: &crate::ids::TaskId,
) -> Result<EpicLinkOutcome> {
let ts = now();
crate::operations::route_recurrence_task_mutation(
conn,
workspace,
child_id,
RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
&ts,
)
.await?;
crate::operations::route_recurrence_task_mutation(
conn,
workspace,
epic_id,
RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
&ts,
)
.await?;
let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
if pair.child.project_id != pair.epic.project_id {
return Err(CoreError::validation(format!(
"error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}"
))
.into());
}
if pair.child.is_epic {
return Err(CoreError::validation(format!(
"error epic-child-is-epic child_task_id={child_id}"
))
.into());
}
if pair.child.deleted {
return Err(CoreError::validation(format!(
"error epic-child-deleted child_task_id={child_id}"
))
.into());
}
if pair.epic.deleted {
return Err(CoreError::validation(format!(
"error epic-parent-deleted epic_task_id={epic_id}"
))
.into());
}
crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
.await?;
let promoted = !pair.epic.is_epic;
if promoted {
mark_task_as_epic(conn, workspace, &pair.epic).await?;
}
let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
if changed {
record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD, &ts).await?;
}
Ok(EpicLinkOutcome {
epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
child: pair.child,
changed,
promoted,
})
}
pub(crate) async fn restore_task_to_epic_in_transaction(
conn: &mut SqliteConnection,
workspace: &Workspace,
child_id: &crate::ids::TaskId,
epic_id: &crate::ids::TaskId,
) -> Result<EpicLinkOutcome> {
let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
.await?;
let ts = now();
let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
if changed {
record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD, &ts).await?;
}
Ok(EpicLinkOutcome {
epic: pair.epic,
child: pair.child,
changed,
promoted: false,
})
}
pub(crate) async fn remove_task_from_epic_in_transaction(
conn: &mut SqliteConnection,
workspace: &Workspace,
child_id: &crate::ids::TaskId,
epic_id: &crate::ids::TaskId,
) -> Result<EpicLinkOutcome> {
let mutation_at = now();
crate::operations::route_recurrence_task_mutation(
conn,
workspace,
child_id,
RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
&mutation_at,
)
.await?;
crate::operations::route_recurrence_task_mutation(
conn,
workspace,
epic_id,
RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
&mutation_at,
)
.await?;
let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_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 *conn)
.await?
.rows_affected()
> 0;
if changed {
record_epic_change(
conn,
workspace,
&pair,
op_type::EPIC_LINK_REMOVE,
&mutation_at,
)
.await?;
}
Ok(EpicLinkOutcome {
epic: pair.epic,
child: pair.child,
changed,
promoted: false,
})
}
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)
}
pub(crate) async fn require_consumer_epic(
conn: &mut SqliteConnection,
workspace: &Workspace,
epic_id: &TaskId,
) -> Result<()> {
let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
if epic.deleted || !epic.is_epic {
return Err(CoreError::validation("epic is unavailable".to_string()).into());
}
Ok(())
}
impl Database {
pub(crate) async fn set_epic_child(
&self,
workspace: &Workspace,
epic_id: &TaskId,
child_id: &TaskId,
linked: bool,
) -> Result<bool> {
let mut conn = self.acquire_writer().await?;
let mut tx = begin_immediate(&mut conn).await?;
require_consumer_epic(&mut tx, workspace, epic_id).await?;
let outcome = if linked {
add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
} else {
remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
};
tx.commit().await?;
Ok(outcome.changed)
}
}