Skip to main content

aven_core/query/
details.rs

1use crate::ids::WorkspaceId;
2use anyhow::Result;
3use sqlx::{Row, SqliteConnection};
4
5use crate::refs::DisplayRefContext;
6use crate::task_fields::TaskField;
7use crate::types::Task;
8
9use super::{
10    SortDirection, TaskDependencySummary, TaskFilters, TaskIdFilter, TaskListItem, TaskNote,
11    TaskQueryMode, TaskRelatedLink, TaskSort, list_task_items_with_display_refs,
12    task_dependency_summary_with_display_refs,
13};
14
15#[derive(Debug)]
16pub struct TaskDetail {
17    pub item: TaskListItem,
18    pub project_name: String,
19    pub dependencies: TaskDependencySummary,
20    pub related: Vec<TaskRelatedLink>,
21    pub notes: Vec<TaskNote>,
22    pub conflicts: Vec<TaskDetailConflict>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TaskDetailConflict {
27    pub field: String,
28    pub variant_a: String,
29    pub local_value: String,
30    pub variant_b: String,
31    pub remote_value: String,
32}
33
34pub async fn task_detail(conn: &mut SqliteConnection, task: &Task) -> Result<TaskDetail> {
35    let display_refs = DisplayRefContext::for_workspace(conn, &task.workspace_id).await?;
36    task_detail_with_display_refs(conn, task, &display_refs).await
37}
38
39pub async fn task_detail_with_display_refs(
40    conn: &mut SqliteConnection,
41    task: &Task,
42    display_refs: &DisplayRefContext,
43) -> Result<TaskDetail> {
44    let item = list_task_items_with_display_refs(
45        conn,
46        &task.workspace_id,
47        TaskFilters {
48            task_ids: TaskIdFilter::Only(vec![task.id.clone()]),
49            expand_recurring: true,
50            ..TaskFilters::default().include_deleted(task.deleted)
51        },
52        TaskQueryMode::Flat,
53        TaskSort::Updated,
54        SortDirection::Desc,
55        display_refs,
56    )
57    .await?
58    .into_iter()
59    .next()
60    .expect("task must exist after resolve");
61    let project_name = sqlx::query_scalar::<_, String>(
62        "SELECT name FROM projects WHERE workspace_id = ? AND id = ?",
63    )
64    .bind(&task.workspace_id)
65    .bind(&task.project_id)
66    .fetch_one(&mut *conn)
67    .await?;
68    let dependencies =
69        task_dependency_summary_with_display_refs(conn, &task.workspace_id, &task.id, display_refs)
70            .await?;
71    let related = item.related.clone();
72    let notes = item.notes.iter().rev().cloned().collect();
73    let conflicts = task_detail_conflicts(conn, &task.workspace_id, &task.id).await?;
74
75    Ok(TaskDetail {
76        item,
77        project_name,
78        dependencies,
79        related,
80        notes,
81        conflicts,
82    })
83}
84
85async fn task_detail_conflicts(
86    conn: &mut SqliteConnection,
87    workspace_id: &WorkspaceId,
88    task_id: &crate::ids::TaskId,
89) -> Result<Vec<TaskDetailConflict>> {
90    let rows = sqlx::query(
91        "SELECT field, variant_a, local_value, variant_b, remote_value
92         FROM conflicts
93         WHERE workspace_id = ? AND task_id = ? AND resolved = 0
94         ORDER BY field, id",
95    )
96    .bind(workspace_id)
97    .bind(task_id)
98    .fetch_all(&mut *conn)
99    .await?;
100
101    Ok(rows
102        .into_iter()
103        .map(|row| TaskDetailConflict {
104            field: row.get("field"),
105            variant_a: row.get("variant_a"),
106            local_value: row.get("local_value"),
107            variant_b: row.get("variant_b"),
108            remote_value: row.get("remote_value"),
109        })
110        .collect())
111}
112
113pub async fn conflict_display_value(
114    conn: &mut SqliteConnection,
115    workspace_id: &WorkspaceId,
116    field: &str,
117    value: &str,
118) -> Result<String> {
119    if field.starts_with("metadata:") {
120        return Ok(crate::metadata::decode_metadata_conflict_value(value)?
121            .unwrap_or_else(|| "<absent>".to_string()));
122    }
123    match TaskField::parse(field) {
124        Some(TaskField::Project) => display_project_conflict_value(conn, workspace_id, value).await,
125        Some(TaskField::IsEpic) => Ok(match value {
126            "1" => "on".to_string(),
127            "0" => "off".to_string(),
128            other => other.to_string(),
129        }),
130        _ => Ok(value.to_string()),
131    }
132}
133
134async fn display_project_conflict_value(
135    conn: &mut SqliteConnection,
136    workspace_id: &WorkspaceId,
137    value: &str,
138) -> Result<String> {
139    if let Some((key, prefix)) = sqlx::query_as::<_, (String, String)>(
140        "SELECT key, prefix FROM projects WHERE workspace_id = ? AND id = ?",
141    )
142    .bind(workspace_id)
143    .bind(value)
144    .fetch_optional(&mut *conn)
145    .await?
146    {
147        return Ok(format!("{key} prefix={prefix}"));
148    }
149    Ok(value.to_string())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[tokio::test]
157    async fn task_detail_loads_enriched_fields_in_stable_order() {
158        let (_temp, mut conn) = crate::test_support::test_conn().await;
159        let workspace = crate::workspaces::Workspace::default();
160        let workspace_id = workspace.id.clone();
161        sqlx::query(
162            "INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
163             VALUES ('00000000000000D1', 'detail', 'Detail Project', 'DTL', 't', 't')",
164        )
165        .execute(&mut *conn)
166        .await
167        .unwrap();
168        for (id, title) in [
169            ("D3TA100000000001", "detailed task"),
170            ("D3TA100000000002", "blocker task"),
171        ] {
172            sqlx::query(
173                "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
174                 VALUES (?, ?, 'description', '00000000000000D1', 'todo', 'high', 't', 't', 't')",
175            )
176            .bind(id)
177            .bind(title)
178            .execute(&mut *conn)
179            .await
180            .unwrap();
181        }
182        sqlx::query(
183            "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
184             VALUES (?, 'note-b', 'D3TA100000000001', 'second', '001', 'change-b'),
185                    (?, 'note-a', 'D3TA100000000001', 'first', '001', 'change-a')",
186        )
187        .bind(&workspace_id)
188        .bind(&workspace_id)
189        .execute(&mut *conn)
190        .await
191        .unwrap();
192        sqlx::query(
193            "INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
194             VALUES (?, 'D3TA100000000001', 'D3TA100000000002', '003')",
195        )
196        .bind(&workspace_id)
197        .execute(&mut *conn)
198        .await
199        .unwrap();
200        for (field, resolved) in [("title", 0), ("priority", 1)] {
201            sqlx::query(
202                "INSERT INTO conflicts(workspace_id, task_id, field, local_value, remote_value,
203                 remote_change_id, variant_a, variant_b, created_at, resolved)
204                 VALUES (?, 'D3TA100000000001', ?, 'local', 'remote', ?, 'a', 'b', '004', ?)",
205            )
206            .bind(&workspace_id)
207            .bind(field)
208            .bind(format!("remote-{field}"))
209            .bind(resolved)
210            .execute(&mut *conn)
211            .await
212            .unwrap();
213        }
214
215        let task =
216            crate::refs::resolve_task_ref_in_workspace(&mut conn, &workspace, "D3TA100000000001")
217                .await
218                .unwrap();
219        let detail = task_detail(&mut conn, &task).await.unwrap();
220
221        assert_eq!(detail.item.task.title, "detailed task");
222        assert_eq!(detail.project_name, "Detail Project");
223        assert_eq!(
224            detail
225                .notes
226                .iter()
227                .map(|note| (note.id.as_str(), note.body.as_str()))
228                .collect::<Vec<_>>(),
229            [("note-a", "first"), ("note-b", "second")]
230        );
231        assert_eq!(detail.dependencies.depends_on.len(), 1);
232        assert_eq!(detail.dependencies.depends_on[0].task.title, "blocker task");
233        assert_eq!(detail.conflicts.len(), 1);
234        assert_eq!(detail.conflicts[0].field, "title");
235    }
236}