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 match TaskField::parse(field) {
147 Some(TaskField::Project) => display_project_conflict_value(conn, workspace_id, value).await,
148 Some(TaskField::IsEpic) => Ok(match value {
149 "1" => "on".to_string(),
150 "0" => "off".to_string(),
151 other => other.to_string(),
152 }),
153 _ => Ok(value.to_string()),
154 }
155}
156
157async fn display_project_conflict_value(
158 conn: &mut SqliteConnection,
159 workspace_id: &WorkspaceId,
160 value: &str,
161) -> Result<String> {
162 if let Some((key, prefix)) = sqlx::query_as::<_, (String, String)>(
163 "SELECT key, prefix FROM projects WHERE workspace_id = ? AND id = ?",
164 )
165 .bind(workspace_id)
166 .bind(value)
167 .fetch_optional(&mut *conn)
168 .await?
169 {
170 return Ok(format!("{key} prefix={prefix}"));
171 }
172 Ok(value.to_string())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[tokio::test]
180 async fn task_detail_loads_enriched_fields_in_stable_order() {
181 let (_temp, mut conn) = crate::test_support::test_conn().await;
182 let workspace = crate::workspaces::Workspace::default();
183 let workspace_id = workspace.id.clone();
184 sqlx::query(
185 "INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
186 VALUES ('00000000000000D1', 'detail', 'Detail Project', 'DTL', 't', 't')",
187 )
188 .execute(&mut *conn)
189 .await
190 .unwrap();
191 for (id, title) in [
192 ("D3TA100000000001", "detailed task"),
193 ("D3TA100000000002", "blocker task"),
194 ] {
195 sqlx::query(
196 "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
197 VALUES (?, ?, 'description', '00000000000000D1', 'todo', 'high', 't', 't', 't')",
198 )
199 .bind(id)
200 .bind(title)
201 .execute(&mut *conn)
202 .await
203 .unwrap();
204 }
205 sqlx::query(
206 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
207 VALUES (?, 'note-b', 'D3TA100000000001', 'second', '002', 'change-b'),
208 (?, 'note-a', 'D3TA100000000001', 'first', '001', 'change-a')",
209 )
210 .bind(&workspace_id)
211 .bind(&workspace_id)
212 .execute(&mut *conn)
213 .await
214 .unwrap();
215 sqlx::query(
216 "INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
217 VALUES (?, 'D3TA100000000001', 'D3TA100000000002', '003')",
218 )
219 .bind(&workspace_id)
220 .execute(&mut *conn)
221 .await
222 .unwrap();
223 for (field, resolved) in [("title", 0), ("priority", 1)] {
224 sqlx::query(
225 "INSERT INTO conflicts(workspace_id, task_id, field, local_value, remote_value,
226 remote_change_id, variant_a, variant_b, created_at, resolved)
227 VALUES (?, 'D3TA100000000001', ?, 'local', 'remote', ?, 'a', 'b', '004', ?)",
228 )
229 .bind(&workspace_id)
230 .bind(field)
231 .bind(format!("remote-{field}"))
232 .bind(resolved)
233 .execute(&mut *conn)
234 .await
235 .unwrap();
236 }
237
238 let task =
239 crate::refs::resolve_task_ref_in_workspace(&mut conn, &workspace, "D3TA100000000001")
240 .await
241 .unwrap();
242 let detail = task_detail(&mut conn, &task).await.unwrap();
243
244 assert_eq!(detail.item.task.title, "detailed task");
245 assert_eq!(detail.project_name, "Detail Project");
246 assert_eq!(
247 detail
248 .notes
249 .iter()
250 .map(|note| (note.id.as_str(), note.body.as_str()))
251 .collect::<Vec<_>>(),
252 [("note-a", "first"), ("note-b", "second")]
253 );
254 assert_eq!(detail.dependencies.depends_on.len(), 1);
255 assert_eq!(detail.dependencies.depends_on[0].task.title, "blocker task");
256 assert_eq!(detail.conflicts.len(), 1);
257 assert_eq!(detail.conflicts[0].field, "title");
258 }
259}