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
85pub(super) async 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}