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