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