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