1use crate::ids::{ProjectId, WorkspaceId};
2use crate::recurrence::RecurrenceSeriesId;
3
4use anyhow::{Result, bail};
5use sqlx::{Row, SqliteConnection};
6use tracing::info;
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{Database, begin_immediate};
10use crate::ids::now;
11use crate::projects::{
12 create_project_in_workspace, normalize_key, resolve_existing_project_in_workspace,
13};
14use crate::types::Project;
15use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
16use crate::workspaces::Workspace;
17
18pub struct ProjectOutcome {
19 pub project: Project,
20 pub created: bool,
21 pub change_id: Option<String>,
22}
23
24pub struct ProjectDeleteOutcome {
25 pub project: Project,
26 pub stopped_series_count: usize,
27}
28
29pub struct ProjectRenameOutcome {
30 pub previous: Project,
31 pub project: Project,
32 pub changed: bool,
33}
34
35impl Database {
36 pub async fn create_project(
37 &self,
38 workspace: &Workspace,
39 name: &str,
40 ) -> Result<ProjectOutcome> {
41 let mut conn = self.acquire_writer().await?;
42 let mut tx = begin_immediate(&mut conn).await?;
43 let outcome = match create_project_operation(&mut tx, workspace, name).await {
44 Ok(outcome) => outcome,
45 Err(error) => {
46 tx.rollback().await?;
47 return Err(error);
48 }
49 };
50 tx.commit().await?;
51 Ok(outcome)
52 }
53
54 pub async fn create_project_with_tui_undo(
55 &self,
56 workspace: &Workspace,
57 name: &str,
58 ) -> Result<ProjectOutcome> {
59 let mut conn = self.acquire_writer().await?;
60 let mut tx = begin_immediate(&mut conn).await?;
61 let outcome = create_project_operation(&mut tx, workspace, name).await?;
62 if outcome.created {
63 record_tui_undo(
64 &mut tx,
65 &workspace.id,
66 UndoPayload {
67 commands: vec![UndoCommand::DeleteCreatedProject {
68 project_key: outcome.project.key.clone(),
69 create_change_id: outcome.change_id.clone().unwrap_or_default(),
70 expected_name: outcome.project.name.clone(),
71 expected_prefix: outcome.project.prefix.clone(),
72 }],
73 },
74 )
75 .await?;
76 }
77 tx.commit().await?;
78 Ok(outcome)
79 }
80
81 pub async fn delete_project(
82 &self,
83 workspace: &Workspace,
84 project: &str,
85 ) -> Result<ProjectDeleteOutcome> {
86 let mut conn = self.acquire_writer().await?;
87 delete_project_operation(&mut conn, workspace, project).await
88 }
89
90 pub async fn rename_project(
91 &self,
92 workspace: &Workspace,
93 project: &str,
94 new_name: &str,
95 prefix: Option<&str>,
96 ) -> Result<ProjectRenameOutcome> {
97 let mut conn = self.acquire_writer().await?;
98 rename_project_operation(&mut conn, workspace, project, new_name, prefix).await
99 }
100
101 pub async fn rename_project_with_tui_undo(
102 &self,
103 workspace: &Workspace,
104 project: &str,
105 new_name: &str,
106 prefix: Option<&str>,
107 ) -> Result<ProjectRenameOutcome> {
108 let mut conn = self.acquire_writer().await?;
109 let (outcome, ()) = rename_project_operation_before_commit(
110 &mut conn,
111 workspace,
112 project,
113 new_name,
114 prefix,
115 |_| Ok(()),
116 true,
117 )
118 .await?;
119 Ok(outcome)
120 }
121
122 pub async fn rename_project_before_commit<T, F>(
123 &self,
124 workspace: &Workspace,
125 project: &str,
126 new_name: &str,
127 prefix: Option<&str>,
128 before_commit: F,
129 ) -> Result<(ProjectRenameOutcome, T)>
130 where
131 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
132 {
133 let mut conn = self.acquire_writer().await?;
134 rename_project_operation_before_commit(
135 &mut conn,
136 workspace,
137 project,
138 new_name,
139 prefix,
140 before_commit,
141 false,
142 )
143 .await
144 }
145}
146
147#[derive(Clone, Copy)]
148pub struct ProjectMetadata<'a> {
149 pub key: &'a str,
150 pub name: &'a str,
151 pub prefix: &'a str,
152}
153
154pub(crate) async fn create_project_operation(
155 conn: &mut SqliteConnection,
156 workspace: &Workspace,
157 name: &str,
158) -> Result<ProjectOutcome> {
159 let outcome = create_project_in_workspace(conn, &workspace.id, name).await?;
160 if outcome.created {
161 info!(project_key = %outcome.project.key, "project created");
162 }
163 Ok(ProjectOutcome {
164 project: outcome.project,
165 created: outcome.created,
166 change_id: outcome.change_id,
167 })
168}
169
170pub async fn delete_project_operation(
171 conn: &mut SqliteConnection,
172 workspace: &Workspace,
173 project: &str,
174) -> Result<ProjectDeleteOutcome> {
175 let mut tx = begin_immediate(conn).await?;
176 let project = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
177 let deleted_at = now();
178 let series_ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
179 "SELECT id FROM recurrence_series
180 WHERE workspace_id = ? AND project_id = ? AND deleted = 0 AND state != 'stopped'
181 ORDER BY id",
182 )
183 .bind(&project.workspace_id)
184 .bind(&project.id)
185 .fetch_all(&mut *tx)
186 .await?;
187 for series_id in &series_ids {
188 super::recurrence::stop_recurrence_series_for_project_delete_in_transaction(
189 &mut tx,
190 workspace,
191 series_id,
192 &deleted_at,
193 )
194 .await?;
195 }
196 let task_refs: i64 =
197 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
198 .bind(&project.workspace_id)
199 .bind(&project.id)
200 .fetch_one(&mut *tx)
201 .await?;
202 let series_refs: i64 = sqlx::query_scalar(
203 "SELECT count(*) FROM recurrence_series WHERE workspace_id = ? AND project_id = ?",
204 )
205 .bind(&project.workspace_id)
206 .bind(&project.id)
207 .fetch_one(&mut *tx)
208 .await?;
209 sqlx::query("DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ?")
210 .bind(&project.workspace_id)
211 .bind(&project.id)
212 .execute(&mut *tx)
213 .await?;
214 let deleted = if task_refs > 0 || series_refs > 0 {
215 sqlx::query(
216 "UPDATE projects SET deleted = 1, updated_at = ? WHERE workspace_id = ? AND key = ?",
217 )
218 .bind(&deleted_at)
219 .bind(&project.workspace_id)
220 .bind(&project.key)
221 .execute(&mut *tx)
222 .await?
223 } else {
224 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
225 .bind(&project.workspace_id)
226 .bind(&project.key)
227 .execute(&mut *tx)
228 .await?
229 };
230 if deleted.rows_affected() != 1 {
231 bail!("error project-delete-race project={}", project.key);
232 }
233 append_change(
234 &mut tx,
235 ChangeEntity::Project,
236 project.id.as_str(),
237 None,
238 op_type::PROJECT_DELETE,
239 ChangePayload::workspace(workspace).set("deleted_at", deleted_at),
240 )
241 .await?;
242 tx.commit().await?;
243 info!(stopped_series_count = series_ids.len(), "project deleted");
244 Ok(ProjectDeleteOutcome {
245 project,
246 stopped_series_count: series_ids.len(),
247 })
248}
249
250pub async fn rename_project_operation(
251 conn: &mut SqliteConnection,
252 workspace: &Workspace,
253 project: &str,
254 new_name: &str,
255 prefix: Option<&str>,
256) -> Result<ProjectRenameOutcome> {
257 let (outcome, ()) = rename_project_operation_before_commit(
258 conn,
259 workspace,
260 project,
261 new_name,
262 prefix,
263 |_| Ok(()),
264 false,
265 )
266 .await?;
267 Ok(outcome)
268}
269
270async fn rename_project_operation_before_commit<T, F>(
271 conn: &mut SqliteConnection,
272 workspace: &Workspace,
273 project: &str,
274 new_name: &str,
275 prefix: Option<&str>,
276 before_commit: F,
277 tui_undo: bool,
278) -> Result<(ProjectRenameOutcome, T)>
279where
280 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
281{
282 let mut tx = begin_immediate(conn).await?;
283 let previous = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
284 let new_name = new_name.trim();
285 let key = normalize_key(new_name);
286 if key.is_empty() {
287 bail!("error invalid-project input={new_name:?}");
288 }
289 if let Some(existing) = resolve_project_key(&mut tx, &workspace.id, &key).await?
290 && existing.id != previous.id
291 {
292 bail!("error project-exists project={key}");
293 }
294 let prefix = match prefix {
295 Some(prefix) => {
296 let prefix = normalize_prefix(prefix)?;
297 if project_prefix_exists(&mut tx, &workspace.id, &prefix, Some(&previous.id)).await? {
298 bail!("error project-prefix-exists prefix={prefix}");
299 }
300 prefix
301 }
302 None => {
303 unique_project_prefix_excluding(&mut tx, &workspace.id, &key, Some(&previous.id))
304 .await?
305 }
306 };
307 let changed = previous.key != key || previous.name != new_name || previous.prefix != prefix;
308 if !changed {
309 let outcome = ProjectRenameOutcome {
310 project: previous.clone(),
311 previous,
312 changed: false,
313 };
314 let value = before_commit(&outcome)?;
315 tx.commit().await?;
316 return Ok((outcome, value));
317 }
318 let project = set_project_metadata(
319 &mut tx,
320 workspace,
321 &previous.id,
322 ProjectMetadata {
323 key: &key,
324 name: new_name,
325 prefix: &prefix,
326 },
327 true,
328 )
329 .await?;
330 let outcome = ProjectRenameOutcome {
331 previous,
332 project,
333 changed: true,
334 };
335 let value = before_commit(&outcome)?;
336 if tui_undo {
337 record_tui_undo(
338 &mut tx,
339 &workspace.id,
340 UndoPayload {
341 commands: vec![UndoCommand::SetProjectMetadata {
342 project_id: outcome.project.id.clone(),
343 before_key: outcome.previous.key.clone(),
344 before_name: outcome.previous.name.clone(),
345 before_prefix: outcome.previous.prefix.clone(),
346 after_key: outcome.project.key.clone(),
347 after_name: outcome.project.name.clone(),
348 after_prefix: outcome.project.prefix.clone(),
349 }],
350 },
351 )
352 .await?;
353 }
354 tx.commit().await?;
355 info!(project_id = %outcome.project.id, project_key = %outcome.project.key, "project renamed");
356 Ok((outcome, value))
357}
358
359pub async fn set_project_metadata(
360 conn: &mut SqliteConnection,
361 workspace: &Workspace,
362 project_id: &ProjectId,
363 metadata: ProjectMetadata<'_>,
364 record_change: bool,
365) -> Result<Project> {
366 let ts = now();
367 let updated = sqlx::query(
368 "UPDATE projects SET key = ?, name = ?, prefix = ?, updated_at = ?
369 WHERE workspace_id = ? AND id = ? AND deleted = 0",
370 )
371 .bind(metadata.key)
372 .bind(metadata.name)
373 .bind(metadata.prefix)
374 .bind(&ts)
375 .bind(&workspace.id)
376 .bind(project_id)
377 .execute(&mut *conn)
378 .await?;
379 if updated.rows_affected() != 1 {
380 bail!("error project-metadata-target-missing project_id={project_id}");
381 }
382 if record_change {
383 insert_project_metadata_change(conn, workspace, project_id, metadata, &ts).await?;
384 }
385 Ok(Project {
386 id: project_id.clone(),
387 workspace_id: workspace.id.clone(),
388 key: metadata.key.to_string(),
389 name: metadata.name.to_string(),
390 prefix: metadata.prefix.to_string(),
391 })
392}
393
394pub async fn insert_project_metadata_change(
395 conn: &mut SqliteConnection,
396 workspace: &Workspace,
397 project_id: &ProjectId,
398 metadata: ProjectMetadata<'_>,
399 updated_at: &str,
400) -> Result<String> {
401 append_change(
402 conn,
403 ChangeEntity::Project,
404 project_id.as_str(),
405 None,
406 op_type::SET_PROJECT_METADATA,
407 ChangePayload::workspace(workspace)
408 .set("key", metadata.key)
409 .set("name", metadata.name)
410 .set("prefix", metadata.prefix)
411 .set("updated_at", updated_at),
412 )
413 .await
414}
415
416async fn resolve_project_key(
417 conn: &mut SqliteConnection,
418 workspace_id: &WorkspaceId,
419 key: &str,
420) -> Result<Option<Project>> {
421 let row = sqlx::query(
422 "SELECT id, workspace_id, key, name, prefix
423 FROM projects
424 WHERE workspace_id = ? AND key = ?",
425 )
426 .bind(workspace_id)
427 .bind(key)
428 .fetch_optional(&mut *conn)
429 .await?;
430 Ok(row.map(|row| Project {
431 id: row.get("id"),
432 workspace_id: row.get("workspace_id"),
433 key: row.get("key"),
434 name: row.get("name"),
435 prefix: row.get("prefix"),
436 }))
437}
438
439async fn unique_project_prefix_excluding(
440 conn: &mut SqliteConnection,
441 workspace_id: &WorkspaceId,
442 key: &str,
443 exclude_project_id: Option<&ProjectId>,
444) -> Result<String> {
445 let base = crate::projects::prefix_base(key);
446 let mut candidate = base.clone();
447 let mut n = 2;
448 while project_prefix_exists(conn, workspace_id, &candidate, exclude_project_id).await? {
449 candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
450 n += 1;
451 }
452 Ok(candidate)
453}
454
455async fn project_prefix_exists(
456 conn: &mut SqliteConnection,
457 workspace_id: &WorkspaceId,
458 prefix: &str,
459 exclude_project_id: Option<&ProjectId>,
460) -> Result<bool> {
461 Ok(sqlx::query_scalar::<_, i64>(
462 "SELECT count(*) FROM projects
463 WHERE workspace_id = ? AND prefix = ? AND (? IS NULL OR id != ?)",
464 )
465 .bind(workspace_id)
466 .bind(prefix)
467 .bind(exclude_project_id)
468 .bind(exclude_project_id)
469 .fetch_one(&mut *conn)
470 .await?
471 > 0)
472}
473
474fn normalize_prefix(prefix: &str) -> Result<String> {
475 let prefix = prefix.trim().to_ascii_uppercase();
476 if (2..=crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN).contains(&prefix.len())
477 && prefix.chars().all(|ch| ch.is_ascii_alphanumeric())
478 {
479 Ok(prefix)
480 } else {
481 bail!("error invalid-project-prefix prefix={prefix:?}")
482 }
483}