1use axum::{
10 extract::{Path, Query, State},
11 response::Json,
12};
13use chrono::Datelike;
14use serde::{Deserialize, Serialize};
15
16use super::state::MultiUserMemoryManager;
17use super::types::MemoryEvent;
18use crate::errors::{AppError, ValidationErrorExt};
19use crate::memory::sessions::SessionEvent;
20use crate::memory::todo_formatter;
21use crate::memory::{Experience, ExperienceType};
22use crate::memory::{
23 Project, ProjectId, ProjectStats, ProjectStatus, ProspectiveTask, ProspectiveTaskId,
24 ProspectiveTaskStatus, ProspectiveTrigger, Recurrence, Todo, TodoComment, TodoCommentId,
25 TodoCommentType, TodoPriority, TodoStatus, UserTodoStats,
26};
27use crate::validation;
28
29pub type AppState = std::sync::Arc<MultiUserMemoryManager>;
31
32#[derive(Debug, Deserialize)]
38pub struct CreateReminderRequest {
39 pub user_id: String,
40 pub content: String,
41 pub trigger: ReminderTriggerRequest,
42 #[serde(default)]
43 pub tags: Vec<String>,
44 #[serde(default = "default_reminder_priority")]
45 pub priority: u8,
46}
47
48fn default_reminder_priority() -> u8 {
49 3
50}
51
52#[derive(Debug, Deserialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum ReminderTriggerRequest {
56 Time {
57 at: chrono::DateTime<chrono::Utc>,
58 },
59 Duration {
60 after_seconds: u64,
61 },
62 Context {
63 keywords: Vec<String>,
64 #[serde(default = "default_context_threshold")]
65 threshold: f32,
66 },
67}
68
69fn default_context_threshold() -> f32 {
70 0.7
71}
72
73#[derive(Debug, Serialize)]
75pub struct CreateReminderResponse {
76 pub id: String,
77 pub content: String,
78 pub trigger_type: String,
79 pub due_at: Option<chrono::DateTime<chrono::Utc>>,
80 pub created_at: chrono::DateTime<chrono::Utc>,
81}
82
83#[derive(Debug, Deserialize)]
85pub struct ListRemindersRequest {
86 pub user_id: String,
87 pub status: Option<String>,
88}
89
90#[derive(Debug, Serialize)]
92pub struct ReminderItem {
93 pub id: String,
94 pub content: String,
95 pub trigger_type: String,
96 pub status: String,
97 pub due_at: Option<chrono::DateTime<chrono::Utc>>,
98 pub created_at: chrono::DateTime<chrono::Utc>,
99 pub triggered_at: Option<chrono::DateTime<chrono::Utc>>,
100 pub dismissed_at: Option<chrono::DateTime<chrono::Utc>>,
101 pub priority: u8,
102 pub tags: Vec<String>,
103 pub overdue_seconds: Option<i64>,
104}
105
106#[derive(Debug, Serialize)]
108pub struct ListRemindersResponse {
109 pub reminders: Vec<ReminderItem>,
110 pub count: usize,
111}
112
113#[derive(Debug, Deserialize)]
115pub struct GetDueRemindersRequest {
116 pub user_id: String,
117 #[serde(default = "default_true")]
118 pub mark_triggered: bool,
119}
120
121fn default_true() -> bool {
122 true
123}
124
125#[derive(Debug, Serialize)]
127pub struct DueRemindersResponse {
128 pub reminders: Vec<ReminderItem>,
129 pub count: usize,
130}
131
132#[derive(Debug, Deserialize)]
134pub struct CheckContextRemindersRequest {
135 pub user_id: String,
136 pub context: String,
137 #[serde(default = "default_true")]
138 pub mark_triggered: bool,
139}
140
141#[derive(Debug, Deserialize)]
143pub struct DismissReminderRequest {
144 pub user_id: String,
145}
146
147#[derive(Debug, Serialize)]
149pub struct ReminderActionResponse {
150 pub success: bool,
151 pub message: String,
152}
153
154#[derive(Debug, Deserialize)]
156pub struct DeleteReminderQuery {
157 pub user_id: String,
158}
159
160#[derive(Debug, Deserialize)]
166pub struct CreateTodoRequest {
167 pub user_id: String,
168 pub content: String,
169 #[serde(default)]
170 pub status: Option<String>,
171 #[serde(default)]
172 pub priority: Option<String>,
173 #[serde(default)]
174 pub project: Option<String>,
175 #[serde(default)]
176 pub contexts: Option<Vec<String>>,
177 #[serde(default)]
178 pub due_date: Option<String>,
179 #[serde(default)]
180 pub blocked_on: Option<String>,
181 #[serde(default)]
182 pub parent_id: Option<String>,
183 #[serde(default)]
184 pub tags: Option<Vec<String>>,
185 #[serde(default)]
186 pub notes: Option<String>,
187 #[serde(default)]
188 pub recurrence: Option<String>,
189 #[serde(default)]
190 pub external_id: Option<String>,
191}
192
193#[derive(Debug, Serialize)]
195pub struct TodoResponse {
196 pub success: bool,
197 pub todo: Option<Todo>,
198 pub project: Option<Project>,
199 pub formatted: String,
200}
201
202#[derive(Debug, Serialize)]
204pub struct TodoListResponse {
205 pub success: bool,
206 pub count: usize,
207 pub todos: Vec<Todo>,
208 pub projects: Vec<Project>,
209 pub formatted: String,
210}
211
212#[derive(Debug, Serialize)]
214pub struct TodoCompleteResponse {
215 pub success: bool,
216 pub todo: Option<Todo>,
217 pub next_recurrence: Option<Todo>,
218 pub formatted: String,
219}
220
221#[derive(Debug, Deserialize)]
223pub struct ListTodosRequest {
224 pub user_id: String,
225 #[serde(default)]
226 pub status: Option<Vec<String>>,
227 #[serde(default)]
228 pub project: Option<String>,
229 #[serde(default)]
230 pub context: Option<String>,
231 #[serde(default)]
232 pub include_completed: Option<bool>,
233 #[serde(default)]
234 pub due: Option<String>,
235 #[serde(default)]
236 pub limit: Option<usize>,
237 #[serde(default)]
238 pub offset: Option<usize>,
239 #[serde(default)]
240 pub parent_id: Option<String>,
241 #[serde(default)]
242 pub query: Option<String>,
243 #[serde(default)]
244 pub priority: Option<String>,
245}
246
247#[derive(Debug, Deserialize)]
249pub struct UpdateTodoRequest {
250 pub user_id: String,
251 #[serde(default)]
252 pub content: Option<String>,
253 #[serde(default)]
254 pub status: Option<String>,
255 #[serde(default)]
256 pub priority: Option<String>,
257 #[serde(default)]
258 pub project: Option<String>,
259 #[serde(default)]
260 pub contexts: Option<Vec<String>>,
261 #[serde(default)]
262 pub due_date: Option<String>,
263 #[serde(default)]
264 pub blocked_on: Option<String>,
265 #[serde(default)]
266 pub notes: Option<String>,
267 #[serde(default)]
268 pub tags: Option<Vec<String>>,
269 #[serde(default)]
270 pub sort_order: Option<i32>,
271 #[serde(default)]
272 pub parent_id: Option<String>,
273 #[serde(default)]
274 pub external_id: Option<String>,
275}
276
277#[derive(Debug, Deserialize)]
279pub struct ReorderTodoRequest {
280 pub user_id: String,
281 pub direction: String,
282}
283
284#[derive(Debug, Deserialize)]
286pub struct DueTodosRequest {
287 pub user_id: String,
288 #[serde(default = "default_include_overdue")]
289 pub include_overdue: bool,
290}
291
292fn default_include_overdue() -> bool {
293 true
294}
295
296#[derive(Debug, Deserialize)]
298pub struct TodoQuery {
299 pub user_id: String,
300}
301
302#[derive(Debug, Deserialize)]
304pub struct TodoStatsRequest {
305 pub user_id: String,
306}
307
308#[derive(Debug, Serialize)]
310pub struct TodoStatsResponse {
311 pub success: bool,
312 pub stats: UserTodoStats,
313 pub formatted: String,
314}
315
316#[derive(Debug, Deserialize)]
322pub struct AddCommentRequest {
323 pub user_id: String,
324 pub content: String,
325 #[serde(default)]
326 pub author: Option<String>,
327 #[serde(default)]
328 pub comment_type: Option<String>,
329}
330
331#[derive(Debug, Deserialize)]
333pub struct UpdateCommentRequest {
334 pub user_id: String,
335 pub content: String,
336}
337
338#[derive(Debug, Serialize)]
340pub struct CommentResponse {
341 pub success: bool,
342 pub comment: Option<TodoComment>,
343 pub formatted: String,
344}
345
346#[derive(Debug, Serialize)]
348pub struct CommentListResponse {
349 pub success: bool,
350 pub count: usize,
351 pub comments: Vec<TodoComment>,
352 pub formatted: String,
353}
354
355#[derive(Debug, Deserialize)]
361pub struct CreateProjectRequest {
362 pub user_id: String,
363 pub name: String,
364 #[serde(default)]
365 pub prefix: Option<String>,
366 #[serde(default)]
367 pub description: Option<String>,
368 #[serde(default)]
369 pub color: Option<String>,
370 #[serde(default)]
371 pub parent: Option<String>,
372}
373
374#[derive(Debug, Serialize)]
376pub struct ProjectResponse {
377 pub success: bool,
378 pub project: Option<Project>,
379 pub stats: Option<ProjectStats>,
380 pub formatted: String,
381}
382
383#[derive(Debug, Serialize)]
385pub struct ProjectListResponse {
386 pub success: bool,
387 pub count: usize,
388 pub projects: Vec<(Project, ProjectStats)>,
389 pub formatted: String,
390}
391
392#[derive(Debug, Deserialize)]
394pub struct UpdateProjectRequest {
395 pub user_id: String,
396 #[serde(default)]
397 pub name: Option<String>,
398 #[serde(default)]
399 pub prefix: Option<String>,
400 #[serde(default)]
401 pub description: Option<Option<String>>,
402 #[serde(default)]
403 pub status: Option<ProjectStatus>,
404 #[serde(default)]
405 pub color: Option<Option<String>>,
406}
407
408#[derive(Debug, Deserialize)]
410pub struct DeleteProjectRequest {
411 pub user_id: String,
412 #[serde(default)]
413 pub delete_todos: bool,
414}
415
416#[derive(Debug, Deserialize)]
418pub struct ListProjectsRequest {
419 pub user_id: String,
420}
421
422fn parse_recurrence(s: &str) -> Option<Recurrence> {
428 match s.to_lowercase().as_str() {
429 "daily" => Some(Recurrence::Daily),
430 "weekly" => Some(Recurrence::Weekly {
431 days: vec![1, 2, 3, 4, 5],
432 }),
433 "monthly" => Some(Recurrence::Monthly { day: 1 }),
434 _ => None,
435 }
436}
437
438pub async fn create_reminder(
444 State(state): State<AppState>,
445 Json(req): Json<CreateReminderRequest>,
446) -> Result<Json<CreateReminderResponse>, AppError> {
447 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
448
449 if req.content.trim().is_empty() {
450 return Err(AppError::InvalidInput {
451 field: "content".to_string(),
452 reason: "Reminder content cannot be empty".to_string(),
453 });
454 }
455
456 let trigger = match req.trigger {
457 ReminderTriggerRequest::Time { at } => {
458 validation::validate_reminder_timestamp(&at).map_validation_err("trigger_at")?;
459 ProspectiveTrigger::AtTime { at }
460 }
461 ReminderTriggerRequest::Duration { after_seconds } => {
462 if after_seconds > 5 * 365 * 24 * 3600 {
463 return Err(AppError::InvalidInput {
464 field: "after_seconds".to_string(),
465 reason: "Duration cannot exceed 5 years".to_string(),
466 });
467 }
468 ProspectiveTrigger::AfterDuration {
469 seconds: after_seconds,
470 from: chrono::Utc::now(),
471 }
472 }
473 ReminderTriggerRequest::Context {
474 keywords,
475 threshold,
476 } => {
477 if keywords.is_empty() {
478 return Err(AppError::InvalidInput {
479 field: "keywords".to_string(),
480 reason: "Context trigger requires at least one keyword".to_string(),
481 });
482 }
483 validation::validate_weight("threshold", threshold).map_validation_err("threshold")?;
484 ProspectiveTrigger::OnContext {
485 keywords,
486 threshold,
487 }
488 }
489 };
490
491 let mut task = ProspectiveTask::new(req.user_id.clone(), req.content.clone(), trigger);
492 task.tags = req.tags;
493 task.priority = req.priority.clamp(1, 5);
494
495 if matches!(task.trigger, ProspectiveTrigger::OnContext { .. }) {
497 if let Ok(memory_system) = state.get_user_memory(&req.user_id) {
498 let content_for_embed = task.content.clone();
499 let memory_clone = memory_system.clone();
500 if let Ok(Ok(embedding)) = tokio::task::spawn_blocking(move || {
501 let guard = memory_clone.read();
502 guard.compute_embedding(&content_for_embed)
503 })
504 .await
505 {
506 task.embedding = Some(embedding);
507 }
508 }
509 }
510
511 let trigger_type = match &task.trigger {
512 ProspectiveTrigger::AtTime { .. } => "time",
513 ProspectiveTrigger::AfterDuration { .. } => "duration",
514 ProspectiveTrigger::OnContext { .. } => "context",
515 };
516
517 let due_at = task.trigger.due_at();
518
519 state
520 .prospective_store
521 .store(&task)
522 .map_err(AppError::Internal)?;
523
524 tracing::info!(
525 user_id = %req.user_id,
526 reminder_id = %task.id,
527 trigger_type = trigger_type,
528 "Created prospective memory (reminder)"
529 );
530
531 state.log_event(
532 &req.user_id,
533 "REMINDER_CREATE",
534 &task.id.to_string(),
535 &format!(
536 "Created reminder trigger={}: '{}'",
537 trigger_type,
538 req.content.chars().take(50).collect::<String>()
539 ),
540 );
541
542 Ok(Json(CreateReminderResponse {
543 id: task.id.to_string(),
544 content: task.content,
545 trigger_type: trigger_type.to_string(),
546 due_at,
547 created_at: task.created_at,
548 }))
549}
550
551pub async fn list_reminders(
553 State(state): State<AppState>,
554 Json(req): Json<ListRemindersRequest>,
555) -> Result<Json<ListRemindersResponse>, AppError> {
556 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
557
558 let status_filter = req.status.as_ref().and_then(|s| match s.as_str() {
559 "pending" => Some(ProspectiveTaskStatus::Pending),
560 "triggered" => Some(ProspectiveTaskStatus::Triggered),
561 "dismissed" => Some(ProspectiveTaskStatus::Dismissed),
562 "expired" => Some(ProspectiveTaskStatus::Expired),
563 _ => None,
564 });
565
566 let tasks = state
567 .prospective_store
568 .list_for_user(&req.user_id, status_filter)
569 .map_err(AppError::Internal)?;
570
571 let reminders: Vec<ReminderItem> = tasks
572 .into_iter()
573 .map(|t| {
574 let overdue_seconds = t.overdue_seconds();
575 ReminderItem {
576 id: t.id.to_string(),
577 content: t.content,
578 trigger_type: match &t.trigger {
579 ProspectiveTrigger::AtTime { .. } => "time".to_string(),
580 ProspectiveTrigger::AfterDuration { .. } => "duration".to_string(),
581 ProspectiveTrigger::OnContext { .. } => "context".to_string(),
582 },
583 status: format!("{:?}", t.status).to_lowercase(),
584 due_at: t.trigger.due_at(),
585 created_at: t.created_at,
586 triggered_at: t.triggered_at,
587 dismissed_at: t.dismissed_at,
588 priority: t.priority,
589 tags: t.tags,
590 overdue_seconds,
591 }
592 })
593 .collect();
594
595 let count = reminders.len();
596
597 Ok(Json(ListRemindersResponse { reminders, count }))
598}
599
600pub async fn get_due_reminders(
602 State(state): State<AppState>,
603 Json(req): Json<GetDueRemindersRequest>,
604) -> Result<Json<DueRemindersResponse>, AppError> {
605 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
606
607 let due_tasks = state
608 .prospective_store
609 .get_due_tasks(&req.user_id)
610 .map_err(AppError::Internal)?;
611
612 let tasks_for_response: Vec<ProspectiveTask> = if req.mark_triggered {
615 let mut result = Vec::with_capacity(due_tasks.len());
616 for task in &due_tasks {
617 match state
618 .prospective_store
619 .mark_triggered(&req.user_id, &task.id)
620 {
621 Ok(true) => {
622 match state.prospective_store.get(&req.user_id, &task.id) {
624 Ok(Some(updated)) => result.push(updated),
625 _ => result.push(task.clone()),
626 }
627 }
628 Ok(false) => {
629 tracing::debug!(task_id = %task.id, "Reminder already triggered (concurrent)");
631 }
632 Err(e) => {
633 tracing::warn!(task_id = %task.id, error = %e, "Failed to mark reminder triggered");
634 result.push(task.clone());
635 }
636 }
637 }
638 result
639 } else {
640 due_tasks
641 };
642
643 let reminders: Vec<ReminderItem> = tasks_for_response
644 .into_iter()
645 .map(|t| {
646 let overdue_seconds = t.overdue_seconds();
647 ReminderItem {
648 id: t.id.to_string(),
649 content: t.content,
650 trigger_type: match &t.trigger {
651 ProspectiveTrigger::AtTime { .. } => "time".to_string(),
652 ProspectiveTrigger::AfterDuration { .. } => "duration".to_string(),
653 ProspectiveTrigger::OnContext { .. } => "context".to_string(),
654 },
655 status: format!("{:?}", t.status).to_lowercase(),
656 due_at: t.trigger.due_at(),
657 created_at: t.created_at,
658 triggered_at: t.triggered_at,
659 dismissed_at: t.dismissed_at,
660 priority: t.priority,
661 tags: t.tags,
662 overdue_seconds,
663 }
664 })
665 .collect();
666
667 let count = reminders.len();
668
669 if count > 0 {
670 tracing::debug!(
671 user_id = %req.user_id,
672 count = count,
673 "Returning due reminders"
674 );
675 }
676
677 Ok(Json(DueRemindersResponse { reminders, count }))
678}
679
680pub async fn check_context_reminders(
682 State(state): State<AppState>,
683 Json(req): Json<CheckContextRemindersRequest>,
684) -> Result<Json<DueRemindersResponse>, AppError> {
685 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
686
687 if req.context.trim().is_empty() {
688 return Ok(Json(DueRemindersResponse {
689 reminders: vec![],
690 count: 0,
691 }));
692 }
693
694 let memory_system = state
695 .get_user_memory(&req.user_id)
696 .map_err(AppError::Internal)?;
697
698 let context_for_embed = req.context.clone();
699 let memory_for_embedding = memory_system.clone();
700 let context_embedding: Vec<f32> = tokio::task::spawn_blocking(move || {
701 let memory_guard = memory_for_embedding.read();
702 memory_guard
703 .compute_embedding(&context_for_embed)
704 .unwrap_or_else(|_| vec![0.0; 384])
705 })
706 .await
707 .map_err(|e| AppError::Internal(anyhow::anyhow!("Embedding task panicked: {e}")))?;
708
709 let user_id = req.user_id.clone();
710 let context_for_triggers = req.context.clone();
711 let memory_for_task_embed = memory_system.clone();
712 let prospective = state.prospective_store.clone();
713 let mark_triggered = req.mark_triggered;
714
715 let matched_tasks: Vec<(crate::memory::types::ProspectiveTask, f32)> =
716 tokio::task::spawn_blocking(move || {
717 let embed_fn = |text: &str| -> Option<Vec<f32>> {
718 let memory_guard = memory_for_task_embed.read();
719 memory_guard.compute_embedding(text).ok()
720 };
721
722 prospective
723 .check_context_triggers_semantic(
724 &user_id,
725 &context_for_triggers,
726 &context_embedding,
727 embed_fn,
728 )
729 .unwrap_or_default()
730 })
731 .await
732 .map_err(|e| AppError::Internal(anyhow::anyhow!("Blocking task panicked: {e}")))?;
733
734 let tasks_with_scores: Vec<(ProspectiveTask, f32)> = if mark_triggered {
736 let mut result = Vec::with_capacity(matched_tasks.len());
737 for (task, score) in &matched_tasks {
738 match state
739 .prospective_store
740 .mark_triggered(&req.user_id, &task.id)
741 {
742 Ok(true) => match state.prospective_store.get(&req.user_id, &task.id) {
743 Ok(Some(updated)) => result.push((updated, *score)),
744 _ => result.push((task.clone(), *score)),
745 },
746 Ok(false) => {
747 tracing::debug!(task_id = %task.id, "Context reminder already triggered (concurrent)");
748 }
749 Err(e) => {
750 tracing::warn!(task_id = %task.id, error = %e, "Failed to mark context reminder triggered");
751 result.push((task.clone(), *score));
752 }
753 }
754 }
755 result
756 } else {
757 matched_tasks
758 };
759
760 let reminders: Vec<ReminderItem> = tasks_with_scores
761 .into_iter()
762 .map(|(t, score)| ReminderItem {
763 id: t.id.to_string(),
764 content: t.content,
765 trigger_type: format!("context (score: {:.2})", score),
766 status: format!("{:?}", t.status).to_lowercase(),
767 due_at: None,
768 created_at: t.created_at,
769 triggered_at: t.triggered_at,
770 dismissed_at: t.dismissed_at,
771 priority: t.priority,
772 tags: t.tags,
773 overdue_seconds: None,
774 })
775 .collect();
776
777 let count = reminders.len();
778
779 if count > 0 {
780 tracing::debug!(
781 user_id = %req.user_id,
782 count = count,
783 context_preview = %req.context.chars().take(50).collect::<String>(),
784 "Context-triggered reminders matched"
785 );
786 }
787
788 Ok(Json(DueRemindersResponse { reminders, count }))
789}
790
791pub async fn dismiss_reminder(
793 State(state): State<AppState>,
794 Path(reminder_id): Path<String>,
795 Json(req): Json<DismissReminderRequest>,
796) -> Result<Json<ReminderActionResponse>, AppError> {
797 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
798
799 let task_id = if let Ok(uuid) = uuid::Uuid::parse_str(&reminder_id) {
800 ProspectiveTaskId(uuid)
801 } else {
802 let task = state
803 .prospective_store
804 .find_by_prefix(&req.user_id, &reminder_id)
805 .map_err(AppError::Internal)?
806 .ok_or_else(|| AppError::InvalidInput {
807 field: "reminder_id".to_string(),
808 reason: format!("No reminder found with ID prefix '{}'", reminder_id),
809 })?;
810 task.id
811 };
812
813 let success = state
814 .prospective_store
815 .mark_dismissed(&req.user_id, &task_id)
816 .map_err(AppError::Internal)?;
817
818 if success {
819 tracing::info!(
820 user_id = %req.user_id,
821 reminder_id = %task_id.0,
822 "Dismissed reminder"
823 );
824 }
825
826 Ok(Json(ReminderActionResponse {
827 success,
828 message: if success {
829 "Reminder dismissed".to_string()
830 } else {
831 "Reminder not found".to_string()
832 },
833 }))
834}
835
836pub async fn delete_reminder(
838 State(state): State<AppState>,
839 Path(reminder_id): Path<String>,
840 Query(query): Query<DeleteReminderQuery>,
841) -> Result<Json<ReminderActionResponse>, AppError> {
842 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
843
844 let task_id = if let Ok(uuid) = uuid::Uuid::parse_str(&reminder_id) {
845 ProspectiveTaskId(uuid)
846 } else {
847 let task = state
848 .prospective_store
849 .find_by_prefix(&query.user_id, &reminder_id)
850 .map_err(AppError::Internal)?
851 .ok_or_else(|| AppError::InvalidInput {
852 field: "reminder_id".to_string(),
853 reason: format!("No reminder found with ID prefix '{}'", reminder_id),
854 })?;
855 task.id
856 };
857
858 let success = state
859 .prospective_store
860 .delete(&query.user_id, &task_id)
861 .map_err(AppError::Internal)?;
862
863 if success {
864 tracing::info!(
865 user_id = %query.user_id,
866 reminder_id = %task_id.0,
867 "Deleted reminder"
868 );
869 }
870
871 Ok(Json(ReminderActionResponse {
872 success,
873 message: if success {
874 "Reminder deleted".to_string()
875 } else {
876 "Reminder not found".to_string()
877 },
878 }))
879}
880
881pub async fn create_todo(
887 State(state): State<AppState>,
888 Json(req): Json<CreateTodoRequest>,
889) -> Result<Json<TodoResponse>, AppError> {
890 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
891
892 if req.content.trim().is_empty() {
893 return Err(AppError::InvalidInput {
894 field: "content".to_string(),
895 reason: "Content cannot be empty".to_string(),
896 });
897 }
898
899 let mut todo = Todo::new(req.user_id.clone(), req.content.clone());
900
901 if let Some(ref status_str) = req.status {
902 todo.status = TodoStatus::from_str_loose(status_str).unwrap_or_default();
903 }
904
905 if let Some(ref priority_str) = req.priority {
906 todo.priority = TodoPriority::from_str_loose(priority_str).unwrap_or_default();
907 }
908
909 let mut project_name = None;
910 if let Some(ref proj_name) = req.project {
911 let project = state
912 .todo_store
913 .find_or_create_project(&req.user_id, proj_name)
914 .map_err(AppError::Internal)?;
915 todo.project_id = Some(project.id.clone());
916 project_name = Some(project.name.clone());
917 }
918
919 if let Some(contexts) = req.contexts {
920 todo.contexts = contexts;
921 } else {
922 todo.contexts = todo_formatter::extract_contexts(&req.content);
923 }
924
925 if let Some(ref due_str) = req.due_date {
926 todo.due_date = todo_formatter::parse_due_date(due_str);
927 }
928
929 todo.blocked_on = req.blocked_on;
930
931 if let Some(ref parent_str) = req.parent_id {
932 if let Some(parent) = state
933 .todo_store
934 .find_todo_by_prefix(&req.user_id, parent_str)
935 .map_err(AppError::Internal)?
936 {
937 todo.parent_id = Some(parent.id);
938 if todo.project_id.is_none() {
939 todo.project_id = parent.project_id;
940 if let Some(ref proj_id) = todo.project_id {
941 if let Ok(Some(proj)) = state.todo_store.get_project(&req.user_id, proj_id) {
942 project_name = Some(proj.name.clone());
943 }
944 }
945 }
946 }
947 }
948
949 todo.tags = req.tags.unwrap_or_default();
950 todo.notes = req.notes;
951 todo.external_id = req.external_id;
952
953 if let Some(ref recurrence_str) = req.recurrence {
954 todo.recurrence = parse_recurrence(recurrence_str);
955 }
956
957 let embedding_text = format!(
959 "{} {} {}",
960 todo.content,
961 todo.notes.as_deref().unwrap_or(""),
962 todo.tags.join(" ")
963 );
964
965 if let Ok(memory_system) = state.get_user_memory(&req.user_id) {
966 let memory_clone = memory_system.clone();
967 let embedding_text_clone = embedding_text.clone();
968
969 if let Ok(embedding) = tokio::task::spawn_blocking(move || {
970 let memory_guard = memory_clone.read();
971 memory_guard.compute_embedding(&embedding_text_clone)
972 })
973 .await
974 .map_err(|e| AppError::Internal(anyhow::anyhow!("Embedding task panicked: {e}")))?
975 {
976 todo.embedding = Some(embedding.clone());
977
978 if let Ok(vector_id) =
979 state
980 .todo_store
981 .index_todo_embedding(&req.user_id, &todo.id, &embedding)
982 {
983 let _ = state
984 .todo_store
985 .store_vector_id_mapping(&req.user_id, vector_id, &todo.id);
986 }
987 }
988 }
989
990 let todo = state
991 .todo_store
992 .store_todo(&todo)
993 .map_err(AppError::Internal)?;
994
995 let activity_msg = if let Some(ref proj) = project_name {
996 format!("Created in project '{}'", proj)
997 } else {
998 "Created".to_string()
999 };
1000 let _ = state
1001 .todo_store
1002 .add_activity(&req.user_id, &todo.id, activity_msg);
1003
1004 let memory_content = if let Some(ref proj) = project_name {
1006 format!(
1007 "[{}] Todo created in {}: {}",
1008 todo.short_id(),
1009 proj,
1010 todo.content
1011 )
1012 } else {
1013 format!("[{}] Todo created: {}", todo.short_id(), todo.content)
1014 };
1015
1016 let mut tags = vec![
1017 format!("todo:{}", todo.short_id()),
1018 "todo-created".to_string(),
1019 ];
1020 if let Some(ref proj) = project_name {
1021 tags.push(format!("project:{}", proj));
1022 }
1023
1024 let experience = Experience {
1025 content: memory_content,
1026 experience_type: ExperienceType::Task,
1027 tags,
1028 ..Default::default()
1029 };
1030
1031 if let Ok(memory) = state.get_user_memory(&req.user_id) {
1032 let memory_clone = memory.clone();
1033 let exp_clone = experience.clone();
1034 let state_clone = state.clone();
1035 let user_id = req.user_id.clone();
1036
1037 tokio::spawn(async move {
1038 let memory_result = tokio::task::spawn_blocking(move || {
1039 let memory_guard = memory_clone.read();
1040 memory_guard.remember(exp_clone, None)
1041 })
1042 .await;
1043
1044 if let Ok(Ok(memory_id)) = memory_result {
1045 if let Err(e) =
1046 state_clone.process_experience_into_graph(&user_id, &experience, &memory_id)
1047 {
1048 tracing::debug!(
1049 "Graph processing failed for todo memory {}: {}",
1050 memory_id.0,
1051 e
1052 );
1053 }
1054 tracing::debug!(memory_id = %memory_id.0, "Todo creation stored as memory");
1055 }
1056 });
1057 }
1058
1059 let formatted = todo_formatter::format_todo_created(&todo, project_name.as_deref());
1060
1061 state.emit_event(MemoryEvent {
1062 event_type: "TODO_CREATE".to_string(),
1063 timestamp: chrono::Utc::now(),
1064 user_id: req.user_id.clone(),
1065 memory_id: Some(todo.id.0.to_string()),
1066 content_preview: Some(todo.content.clone()),
1067 memory_type: Some(format!("{:?}", todo.status)),
1068 importance: None,
1069 count: None,
1070 results: None,
1071 });
1072
1073 let session_id = state.session_store.get_or_create_session(&req.user_id);
1074 state.session_store.add_event(
1075 &session_id,
1076 SessionEvent::TodoCreated {
1077 timestamp: chrono::Utc::now(),
1078 todo_id: todo.id.0.to_string(),
1079 content: todo.content.chars().take(100).collect(),
1080 project: project_name.clone(),
1081 },
1082 );
1083
1084 tracing::info!(
1085 user_id = %req.user_id,
1086 todo_id = %todo.id,
1087 seq_num = todo.seq_num,
1088 content = %req.content,
1089 "Created todo"
1090 );
1091
1092 state.log_event(
1093 &req.user_id,
1094 "TODO_CREATE",
1095 &todo.id.0.to_string(),
1096 &format!(
1097 "Created todo [{}] project={}: '{}'",
1098 todo.short_id(),
1099 project_name.as_deref().unwrap_or("none"),
1100 req.content.chars().take(50).collect::<String>()
1101 ),
1102 );
1103
1104 Ok(Json(TodoResponse {
1105 success: true,
1106 todo: Some(todo),
1107 project: None,
1108 formatted,
1109 }))
1110}
1111
1112pub async fn list_todos(
1114 State(state): State<AppState>,
1115 Json(req): Json<ListTodosRequest>,
1116) -> Result<Json<TodoListResponse>, AppError> {
1117 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1118
1119 let status_filter: Option<Vec<TodoStatus>> = req.status.as_ref().map(|statuses| {
1120 statuses
1121 .iter()
1122 .filter_map(|s| TodoStatus::from_str_loose(s))
1123 .collect()
1124 });
1125
1126 let mut todos = if let Some(ref query) = req.query {
1127 if query.trim().is_empty() {
1128 Vec::new()
1129 } else {
1130 let memory_system = state
1131 .get_user_memory(&req.user_id)
1132 .map_err(AppError::Internal)?;
1133
1134 let query_clone = query.clone();
1135 let query_embedding: Vec<f32> = tokio::task::spawn_blocking(move || {
1136 let memory_guard = memory_system.read();
1137 memory_guard
1138 .compute_embedding(&query_clone)
1139 .unwrap_or_default()
1140 })
1141 .await
1142 .map_err(|e| AppError::Internal(anyhow::anyhow!("Embedding failed: {e}")))?;
1143
1144 if query_embedding.is_empty() {
1145 Vec::new()
1146 } else {
1147 let limit = req.limit.unwrap_or(50);
1148 let search_results = state
1149 .todo_store
1150 .search_similar(&req.user_id, &query_embedding, limit * 2)
1151 .map_err(AppError::Internal)?;
1152
1153 search_results
1154 .into_iter()
1155 .map(|(todo, _score)| todo)
1156 .collect()
1157 }
1158 }
1159 } else if let Some(ref statuses) = status_filter {
1160 state
1161 .todo_store
1162 .list_todos_for_user(&req.user_id, Some(statuses))
1163 .map_err(AppError::Internal)?
1164 } else {
1165 let include_completed = req.include_completed.unwrap_or(false);
1166 let all_todos = state
1167 .todo_store
1168 .list_todos_for_user(&req.user_id, None)
1169 .map_err(AppError::Internal)?;
1170
1171 if include_completed {
1172 all_todos
1173 } else {
1174 all_todos
1175 .into_iter()
1176 .filter(|t| t.status != TodoStatus::Done && t.status != TodoStatus::Cancelled)
1177 .collect()
1178 }
1179 };
1180
1181 if req.query.is_some() {
1183 if let Some(ref statuses) = status_filter {
1184 todos.retain(|t| statuses.contains(&t.status));
1185 } else if !req.include_completed.unwrap_or(false) {
1186 todos.retain(|t| t.status != TodoStatus::Done && t.status != TodoStatus::Cancelled);
1187 }
1188 }
1189
1190 if let Some(ref proj_name) = req.project {
1192 if let Some(project) = state
1193 .todo_store
1194 .find_project_by_name(&req.user_id, proj_name)
1195 .map_err(AppError::Internal)?
1196 {
1197 todos.retain(|t| t.project_id.as_ref() == Some(&project.id));
1198 }
1199 }
1200
1201 if let Some(ref ctx) = req.context {
1203 let ctx_lower = ctx.to_lowercase();
1204 todos.retain(|t| t.contexts.iter().any(|c| c.to_lowercase() == ctx_lower));
1205 }
1206
1207 if let Some(ref parent_str) = req.parent_id {
1209 if let Some(parent) = state
1210 .todo_store
1211 .find_todo_by_prefix(&req.user_id, parent_str)
1212 .map_err(AppError::Internal)?
1213 {
1214 todos.retain(|t| t.parent_id.as_ref() == Some(&parent.id));
1215 }
1216 }
1217
1218 if let Some(ref due_filter) = req.due {
1220 let now = chrono::Utc::now();
1221 let end_of_today = now
1222 .date_naive()
1223 .and_hms_opt(23, 59, 59)
1224 .map(|t| t.and_utc())
1225 .unwrap_or(now);
1226 let end_of_week =
1227 now + chrono::Duration::days(7 - now.weekday().num_days_from_monday() as i64);
1228
1229 match due_filter.to_lowercase().as_str() {
1230 "today" => {
1231 todos.retain(|t| {
1232 t.due_date
1233 .as_ref()
1234 .map(|d| *d <= end_of_today || *d < now)
1235 .unwrap_or(false)
1236 });
1237 }
1238 "overdue" => {
1239 todos.retain(|t| t.is_overdue());
1240 }
1241 "this_week" => {
1242 todos.retain(|t| {
1243 t.due_date
1244 .as_ref()
1245 .map(|d| *d <= end_of_week)
1246 .unwrap_or(false)
1247 });
1248 }
1249 _ => {}
1250 }
1251 }
1252
1253 if let Some(ref priority_str) = req.priority {
1255 if let Some(target_priority) =
1256 crate::memory::types::TodoPriority::from_str_loose(priority_str)
1257 {
1258 todos.retain(|t| t.priority == target_priority);
1259 }
1260 }
1261
1262 let total_count = todos.len();
1264 let offset = req.offset.unwrap_or(0);
1265 let limit = req.limit.unwrap_or(100);
1266
1267 if offset > 0 && offset < todos.len() {
1268 todos = todos.into_iter().skip(offset).collect();
1269 } else if offset >= total_count {
1270 todos.clear();
1271 }
1272
1273 if todos.len() > limit {
1274 todos.truncate(limit);
1275 }
1276
1277 let projects = state
1278 .todo_store
1279 .list_projects(&req.user_id)
1280 .map_err(AppError::Internal)?;
1281
1282 let formatted = todo_formatter::format_todo_list_with_total(&todos, &projects, total_count);
1283
1284 Ok(Json(TodoListResponse {
1285 success: true,
1286 count: total_count,
1287 todos,
1288 projects,
1289 formatted,
1290 }))
1291}
1292
1293pub async fn list_due_todos(
1295 State(state): State<AppState>,
1296 Json(req): Json<DueTodosRequest>,
1297) -> Result<Json<TodoListResponse>, AppError> {
1298 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1299
1300 let todos = state
1301 .todo_store
1302 .list_due_todos(&req.user_id, req.include_overdue)
1303 .map_err(AppError::Internal)?;
1304
1305 let projects = state
1306 .todo_store
1307 .list_projects(&req.user_id)
1308 .map_err(AppError::Internal)?;
1309
1310 let formatted = todo_formatter::format_due_todos(&todos);
1311
1312 Ok(Json(TodoListResponse {
1313 success: true,
1314 count: todos.len(),
1315 todos,
1316 projects,
1317 formatted,
1318 }))
1319}
1320
1321pub async fn get_todo(
1323 State(state): State<AppState>,
1324 Path(todo_id): Path<String>,
1325 Query(query): Query<TodoQuery>,
1326) -> Result<Json<TodoResponse>, AppError> {
1327 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
1328
1329 let todo = state
1330 .todo_store
1331 .find_todo_by_prefix(&query.user_id, &todo_id)
1332 .map_err(AppError::Internal)?
1333 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1334
1335 let project_name = if let Some(ref pid) = todo.project_id {
1336 state
1337 .todo_store
1338 .get_project(&query.user_id, pid)
1339 .map_err(AppError::Internal)?
1340 .map(|p| p.name)
1341 } else {
1342 None
1343 };
1344
1345 let formatted = todo_formatter::format_todo_line(&todo, project_name.as_deref(), true);
1346
1347 Ok(Json(TodoResponse {
1348 success: true,
1349 todo: Some(todo),
1350 project: None,
1351 formatted,
1352 }))
1353}
1354
1355pub async fn update_todo(
1357 State(state): State<AppState>,
1358 Path(todo_id): Path<String>,
1359 Json(req): Json<UpdateTodoRequest>,
1360) -> Result<Json<TodoResponse>, AppError> {
1361 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1362
1363 let mut todo = state
1364 .todo_store
1365 .find_todo_by_prefix(&req.user_id, &todo_id)
1366 .map_err(AppError::Internal)?
1367 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1368
1369 if let Some(ref content) = req.content {
1370 todo.content = content.clone();
1371 }
1372 if let Some(ref status_str) = req.status {
1373 if let Some(status) = TodoStatus::from_str_loose(status_str) {
1374 todo.status = status;
1375 }
1376 }
1377 if let Some(ref priority_str) = req.priority {
1378 if let Some(priority) = TodoPriority::from_str_loose(priority_str) {
1379 todo.priority = priority;
1380 }
1381 }
1382 if let Some(ref contexts) = req.contexts {
1383 todo.contexts = contexts.clone();
1384 }
1385 if let Some(ref due_str) = req.due_date {
1386 todo.due_date = todo_formatter::parse_due_date(due_str);
1387 }
1388 if let Some(ref blocked) = req.blocked_on {
1389 todo.blocked_on = Some(blocked.clone());
1390 }
1391 if let Some(ref notes) = req.notes {
1392 todo.notes = Some(notes.clone());
1393 }
1394 if let Some(ref tags) = req.tags {
1395 todo.tags = tags.clone();
1396 }
1397 if let Some(ref external_id) = req.external_id {
1398 todo.external_id = Some(external_id.clone());
1399 }
1400 if let Some(ref parent_id_str) = req.parent_id {
1401 if parent_id_str.is_empty() {
1402 todo.parent_id = None;
1403 } else if let Ok(Some(parent)) = state
1404 .todo_store
1405 .find_todo_by_prefix(&req.user_id, parent_id_str)
1406 {
1407 todo.parent_id = Some(parent.id.clone());
1408 }
1409 }
1410
1411 let mut project_name = None;
1412 if let Some(ref proj_name) = req.project {
1413 let project = state
1414 .todo_store
1415 .find_or_create_project(&req.user_id, proj_name)
1416 .map_err(AppError::Internal)?;
1417 todo.project_id = Some(project.id.clone());
1418 project_name = Some(project.name.clone());
1419 }
1420
1421 todo.updated_at = chrono::Utc::now();
1422
1423 let needs_reindex = req.content.is_some() || req.notes.is_some() || req.tags.is_some();
1428
1429 state
1430 .todo_store
1431 .update_todo(&todo)
1432 .map_err(AppError::Internal)?;
1433
1434 if needs_reindex {
1435 let embedding_text = format!(
1436 "{} {} {}",
1437 todo.content,
1438 todo.notes.as_deref().unwrap_or(""),
1439 todo.tags.join(" ")
1440 );
1441
1442 if let Ok(memory_system) = state.get_user_memory(&req.user_id) {
1443 let memory_clone = memory_system.clone();
1444 let embedding_text_clone = embedding_text.clone();
1445
1446 if let Ok(embedding) = tokio::task::spawn_blocking(move || {
1447 let memory_guard = memory_clone.read();
1448 memory_guard.compute_embedding(&embedding_text_clone)
1449 })
1450 .await
1451 .map_err(|e| AppError::Internal(anyhow::anyhow!("Embedding task panicked: {e}")))?
1452 {
1453 todo.embedding = Some(embedding.clone());
1454
1455 if let Ok(vector_id) =
1456 state
1457 .todo_store
1458 .index_todo_embedding(&req.user_id, &todo.id, &embedding)
1459 {
1460 let _ =
1461 state
1462 .todo_store
1463 .store_vector_id_mapping(&req.user_id, vector_id, &todo.id);
1464 }
1465
1466 let _ = state.todo_store.update_todo(&todo);
1468 }
1469 }
1470 }
1471
1472 let update_description = {
1473 let mut changes = Vec::new();
1474 if req.status.is_some() {
1475 changes.push(format!("status → {:?}", todo.status));
1476 }
1477 if req.priority.is_some() {
1478 changes.push(format!("priority → {:?}", todo.priority));
1479 }
1480 if req.content.is_some() {
1481 changes.push("content updated".to_string());
1482 }
1483 if req.project.is_some() {
1484 changes.push(format!(
1485 "project → {}",
1486 project_name.as_deref().unwrap_or("none")
1487 ));
1488 }
1489 if req.blocked_on.is_some() {
1490 changes.push(format!(
1491 "blocked on: {}",
1492 todo.blocked_on.as_deref().unwrap_or("cleared")
1493 ));
1494 }
1495 changes.join(", ")
1496 };
1497
1498 if !update_description.is_empty() {
1499 let _ = state.todo_store.add_activity(
1500 &req.user_id,
1501 &todo.id,
1502 format!("Updated: {}", update_description),
1503 );
1504 }
1505
1506 if !update_description.is_empty() {
1507 let memory_content = format!(
1508 "[{}] Todo updated ({}): {}",
1509 todo.short_id(),
1510 update_description,
1511 todo.content
1512 );
1513
1514 let mut tags = vec![
1515 format!("todo:{}", todo.short_id()),
1516 "todo-updated".to_string(),
1517 ];
1518 if let Some(ref proj) = project_name {
1519 tags.push(format!("project:{}", proj));
1520 }
1521 if req.status.is_some() {
1522 tags.push(format!("status:{:?}", todo.status).to_lowercase());
1523 }
1524
1525 let experience = Experience {
1526 content: memory_content,
1527 experience_type: ExperienceType::Context,
1528 tags,
1529 ..Default::default()
1530 };
1531
1532 if let Ok(memory) = state.get_user_memory(&req.user_id) {
1533 let memory_clone = memory.clone();
1534 let exp_clone = experience.clone();
1535 let state_clone = state.clone();
1536 let user_id = req.user_id.clone();
1537
1538 tokio::spawn(async move {
1539 let memory_result = tokio::task::spawn_blocking(move || {
1540 let memory_guard = memory_clone.read();
1541 memory_guard.remember(exp_clone, None)
1542 })
1543 .await;
1544
1545 if let Ok(Ok(memory_id)) = memory_result {
1546 if let Err(e) =
1547 state_clone.process_experience_into_graph(&user_id, &experience, &memory_id)
1548 {
1549 tracing::debug!(
1550 "Graph processing failed for todo update memory {}: {}",
1551 memory_id.0,
1552 e
1553 );
1554 }
1555 tracing::debug!(memory_id = %memory_id.0, "Todo update stored as memory");
1556 }
1557 });
1558 }
1559 }
1560
1561 let formatted = todo_formatter::format_todo_updated(&todo, project_name.as_deref());
1562
1563 state.emit_event(MemoryEvent {
1564 event_type: "TODO_UPDATE".to_string(),
1565 timestamp: chrono::Utc::now(),
1566 user_id: req.user_id.clone(),
1567 memory_id: Some(todo.id.0.to_string()),
1568 content_preview: Some(todo.content.clone()),
1569 memory_type: Some(format!("{:?}", todo.status)),
1570 importance: None,
1571 count: None,
1572 results: None,
1573 });
1574
1575 tracing::info!(
1576 user_id = %req.user_id,
1577 todo_id = %todo.id,
1578 "Updated todo"
1579 );
1580
1581 state.log_event(
1582 &req.user_id,
1583 "TODO_UPDATE",
1584 &todo.id.0.to_string(),
1585 &format!(
1586 "Updated todo [{}]: {}",
1587 todo.short_id(),
1588 if update_description.is_empty() {
1589 "no changes"
1590 } else {
1591 &update_description
1592 }
1593 ),
1594 );
1595
1596 Ok(Json(TodoResponse {
1597 success: true,
1598 todo: Some(todo),
1599 project: None,
1600 formatted,
1601 }))
1602}
1603
1604pub async fn complete_todo(
1606 State(state): State<AppState>,
1607 Path(todo_id): Path<String>,
1608 Json(req): Json<TodoQuery>,
1609) -> Result<Json<TodoCompleteResponse>, AppError> {
1610 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1611
1612 let todo = state
1613 .todo_store
1614 .find_todo_by_prefix(&req.user_id, &todo_id)
1615 .map_err(AppError::Internal)?
1616 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1617
1618 let result = state
1619 .todo_store
1620 .complete_todo(&req.user_id, &todo.id)
1621 .map_err(AppError::Internal)?;
1622
1623 if result.is_some() {
1624 let days_taken = (chrono::Utc::now() - todo.created_at).num_hours() as f64 / 24.0;
1625 let activity_msg = format!("Marked complete after {:.1} days", days_taken);
1626 let _ = state
1627 .todo_store
1628 .add_activity(&req.user_id, &todo.id, activity_msg.clone());
1629
1630 let memory_content = format!(
1631 "[{}] Todo completed: {} (took {:.1} days)",
1632 todo.short_id(),
1633 todo.content,
1634 days_taken
1635 );
1636
1637 let mut tags = vec![
1638 format!("todo:{}", todo.short_id()),
1639 "todo-completed".to_string(),
1640 "completion".to_string(),
1641 ];
1642 if let Some(ref project_id) = todo.project_id {
1643 if let Ok(Some(project)) = state.todo_store.get_project(&req.user_id, project_id) {
1644 tags.push(format!("project:{}", project.name));
1645 }
1646 }
1647
1648 let experience = Experience {
1649 content: memory_content,
1650 experience_type: ExperienceType::Task,
1651 tags,
1652 ..Default::default()
1653 };
1654
1655 if let Ok(memory) = state.get_user_memory(&req.user_id) {
1656 let memory_clone = memory.clone();
1657 let exp_clone = experience.clone();
1658 let state_clone = state.clone();
1659 let user_id = req.user_id.clone();
1660
1661 tokio::spawn(async move {
1662 let memory_result = tokio::task::spawn_blocking(move || {
1663 let memory_guard = memory_clone.read();
1664 memory_guard.remember(exp_clone, None)
1665 })
1666 .await;
1667
1668 if let Ok(Ok(memory_id)) = memory_result {
1669 if let Err(e) =
1670 state_clone.process_experience_into_graph(&user_id, &experience, &memory_id)
1671 {
1672 tracing::debug!(
1673 "Graph processing failed for todo completion memory {}: {}",
1674 memory_id.0,
1675 e
1676 );
1677 }
1678 tracing::debug!(memory_id = %memory_id.0, "Todo completion stored as searchable memory");
1679 }
1680 });
1681 }
1682 }
1683
1684 match result {
1685 Some((completed, next)) => {
1686 let formatted = todo_formatter::format_todo_completed(&completed, next.as_ref());
1687
1688 state.emit_event(MemoryEvent {
1689 event_type: "TODO_COMPLETE".to_string(),
1690 timestamp: chrono::Utc::now(),
1691 user_id: req.user_id.clone(),
1692 memory_id: Some(completed.id.0.to_string()),
1693 content_preview: Some(completed.content.clone()),
1694 memory_type: Some("Done".to_string()),
1695 importance: None,
1696 count: None,
1697 results: None,
1698 });
1699
1700 let session_id = state.session_store.get_or_create_session(&req.user_id);
1701 state.session_store.add_event(
1702 &session_id,
1703 SessionEvent::TodoCompleted {
1704 timestamp: chrono::Utc::now(),
1705 todo_id: completed.id.0.to_string(),
1706 },
1707 );
1708
1709 tracing::info!(
1710 user_id = %req.user_id,
1711 todo_id = %completed.id,
1712 has_next = next.is_some(),
1713 "Completed todo"
1714 );
1715
1716 state.log_event(
1717 &req.user_id,
1718 "TODO_COMPLETE",
1719 &completed.id.0.to_string(),
1720 &format!(
1721 "Completed todo [{}]: '{}' (recurrence={})",
1722 completed.short_id(),
1723 completed.content.chars().take(40).collect::<String>(),
1724 next.is_some()
1725 ),
1726 );
1727
1728 Ok(Json(TodoCompleteResponse {
1729 success: true,
1730 todo: Some(completed),
1731 next_recurrence: next,
1732 formatted,
1733 }))
1734 }
1735 None => Err(AppError::TodoNotFound(todo_id)),
1736 }
1737}
1738
1739pub async fn delete_todo(
1741 State(state): State<AppState>,
1742 Path(todo_id): Path<String>,
1743 Query(query): Query<TodoQuery>,
1744) -> Result<Json<TodoResponse>, AppError> {
1745 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
1746
1747 let todo = state
1748 .todo_store
1749 .find_todo_by_prefix(&query.user_id, &todo_id)
1750 .map_err(AppError::Internal)?
1751 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1752
1753 let success = state
1754 .todo_store
1755 .delete_todo(&query.user_id, &todo.id)
1756 .map_err(AppError::Internal)?;
1757
1758 let formatted = if success {
1759 todo_formatter::format_todo_deleted(&todo.short_id())
1760 } else {
1761 "Todo not found".to_string()
1762 };
1763
1764 if success {
1765 state.emit_event(MemoryEvent {
1766 event_type: "TODO_DELETE".to_string(),
1767 timestamp: chrono::Utc::now(),
1768 user_id: query.user_id.clone(),
1769 memory_id: Some(todo.id.0.to_string()),
1770 content_preview: Some(todo.content.clone()),
1771 memory_type: None,
1772 importance: None,
1773 count: None,
1774 results: None,
1775 });
1776
1777 tracing::info!(
1778 user_id = %query.user_id,
1779 todo_id = %todo.id,
1780 "Deleted todo"
1781 );
1782 }
1783
1784 Ok(Json(TodoResponse {
1785 success,
1786 todo: None,
1787 project: None,
1788 formatted,
1789 }))
1790}
1791
1792pub async fn reorder_todo(
1794 State(state): State<AppState>,
1795 Path(todo_id): Path<String>,
1796 Json(req): Json<ReorderTodoRequest>,
1797) -> Result<Json<TodoResponse>, AppError> {
1798 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1799
1800 let todo = state
1801 .todo_store
1802 .find_todo_by_prefix(&req.user_id, &todo_id)
1803 .map_err(AppError::Internal)?
1804 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1805
1806 let result = state
1807 .todo_store
1808 .reorder_todo(&req.user_id, &todo.id, &req.direction)
1809 .map_err(AppError::Internal)?;
1810
1811 match result {
1812 Some(updated) => {
1813 let formatted = format!(
1814 "Moved {} {}",
1815 updated.short_id(),
1816 if req.direction == "up" { "up" } else { "down" }
1817 );
1818
1819 state.emit_event(MemoryEvent {
1820 event_type: "TODO_REORDER".to_string(),
1821 timestamp: chrono::Utc::now(),
1822 user_id: req.user_id.clone(),
1823 memory_id: Some(updated.id.0.to_string()),
1824 content_preview: Some(updated.content.clone()),
1825 memory_type: Some(format!("{:?}", updated.status)),
1826 importance: None,
1827 count: None,
1828 results: None,
1829 });
1830
1831 tracing::debug!(
1832 user_id = %req.user_id,
1833 todo_id = %updated.id,
1834 direction = %req.direction,
1835 "Reordered todo"
1836 );
1837
1838 Ok(Json(TodoResponse {
1839 success: true,
1840 todo: Some(updated),
1841 project: None,
1842 formatted,
1843 }))
1844 }
1845 None => Err(AppError::TodoNotFound(todo_id)),
1846 }
1847}
1848
1849pub async fn list_subtasks(
1851 State(state): State<AppState>,
1852 Path(todo_id): Path<String>,
1853 Query(query): Query<TodoQuery>,
1854) -> Result<Json<TodoListResponse>, AppError> {
1855 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
1856
1857 let parent = state
1858 .todo_store
1859 .find_todo_by_prefix(&query.user_id, &todo_id)
1860 .map_err(AppError::Internal)?
1861 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1862
1863 let subtasks = state
1864 .todo_store
1865 .list_subtasks(&parent.id)
1866 .map_err(AppError::Internal)?;
1867
1868 let projects = state
1869 .todo_store
1870 .list_projects(&query.user_id)
1871 .map_err(AppError::Internal)?;
1872
1873 let formatted = if subtasks.is_empty() {
1874 format!("No subtasks for {}", parent.short_id())
1875 } else {
1876 let mut output = format!(
1877 "🐘━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
1878 ┃ SUBTASKS OF {} ┃\n\
1879 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n",
1880 parent.short_id()
1881 );
1882 output.push_str(&todo_formatter::format_todo_list(&subtasks, &projects));
1883 output
1884 };
1885
1886 tracing::debug!(
1887 user_id = %query.user_id,
1888 parent_id = %parent.id,
1889 count = subtasks.len(),
1890 "Listed subtasks"
1891 );
1892
1893 Ok(Json(TodoListResponse {
1894 success: true,
1895 count: subtasks.len(),
1896 todos: subtasks,
1897 projects,
1898 formatted,
1899 }))
1900}
1901
1902pub async fn get_todo_stats(
1904 State(state): State<AppState>,
1905 Json(req): Json<TodoStatsRequest>,
1906) -> Result<Json<TodoStatsResponse>, AppError> {
1907 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1908
1909 let stats = state
1910 .todo_store
1911 .get_user_stats(&req.user_id)
1912 .map_err(AppError::Internal)?;
1913
1914 let formatted = todo_formatter::format_user_stats(&stats);
1915
1916 Ok(Json(TodoStatsResponse {
1917 success: true,
1918 stats,
1919 formatted,
1920 }))
1921}
1922
1923pub async fn add_todo_comment(
1929 State(state): State<AppState>,
1930 Path(todo_id): Path<String>,
1931 Json(req): Json<AddCommentRequest>,
1932) -> Result<Json<CommentResponse>, AppError> {
1933 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
1934
1935 if req.content.trim().is_empty() {
1936 return Err(AppError::InvalidInput {
1937 field: "content".to_string(),
1938 reason: "Comment content cannot be empty".to_string(),
1939 });
1940 }
1941
1942 let todo = state
1943 .todo_store
1944 .find_todo_by_prefix(&req.user_id, &todo_id)
1945 .map_err(AppError::Internal)?
1946 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1947
1948 let comment_type = req
1949 .comment_type
1950 .as_ref()
1951 .and_then(|ct| match ct.to_lowercase().as_str() {
1952 "comment" => Some(TodoCommentType::Comment),
1953 "progress" => Some(TodoCommentType::Progress),
1954 "resolution" => Some(TodoCommentType::Resolution),
1955 "activity" => Some(TodoCommentType::Activity),
1956 _ => None,
1957 });
1958
1959 let author = req.author.unwrap_or_else(|| req.user_id.clone());
1960
1961 let comment = state
1962 .todo_store
1963 .add_comment(
1964 &req.user_id,
1965 &todo.id,
1966 author.clone(),
1967 req.content.clone(),
1968 comment_type.clone(),
1969 )
1970 .map_err(AppError::Internal)?
1971 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
1972
1973 let experience_type = match comment_type.as_ref().unwrap_or(&TodoCommentType::Comment) {
1974 TodoCommentType::Comment => ExperienceType::Observation,
1975 TodoCommentType::Progress => ExperienceType::Learning,
1976 TodoCommentType::Resolution => ExperienceType::Learning,
1977 TodoCommentType::Activity => ExperienceType::Context,
1978 };
1979
1980 let memory_content = format!(
1981 "[{}] {} ({}): {}",
1982 todo.short_id(),
1983 match comment_type.as_ref().unwrap_or(&TodoCommentType::Comment) {
1984 TodoCommentType::Comment => "Comment",
1985 TodoCommentType::Progress => "Progress",
1986 TodoCommentType::Resolution => "Resolution",
1987 TodoCommentType::Activity => "Activity",
1988 },
1989 todo.content,
1990 req.content
1991 );
1992
1993 let mut tags = vec![
1994 format!("todo:{}", todo.short_id()),
1995 format!("todo-comment:{:?}", comment.comment_type).to_lowercase(),
1996 ];
1997 if let Some(ref project_id) = todo.project_id {
1998 if let Ok(Some(project)) = state.todo_store.get_project(&req.user_id, project_id) {
1999 tags.push(format!("project:{}", project.name));
2000 }
2001 }
2002
2003 let experience = Experience {
2004 content: memory_content,
2005 experience_type,
2006 tags,
2007 ..Default::default()
2008 };
2009
2010 if let Ok(memory) = state.get_user_memory(&req.user_id) {
2011 let memory_clone = memory.clone();
2012 let exp_clone = experience.clone();
2013 let memory_result = tokio::task::spawn_blocking(move || {
2014 let memory_guard = memory_clone.read();
2015 memory_guard.remember(exp_clone, None)
2016 })
2017 .await;
2018
2019 if let Ok(Ok(memory_id)) = memory_result {
2020 if let Err(e) =
2021 state.process_experience_into_graph(&req.user_id, &experience, &memory_id)
2022 {
2023 tracing::debug!(
2024 "Graph processing failed for todo comment memory {}: {}",
2025 memory_id.0,
2026 e
2027 );
2028 }
2029
2030 tracing::debug!(
2031 memory_id = %memory_id.0,
2032 todo_id = %todo.id,
2033 "Todo comment stored as memory"
2034 );
2035 }
2036 }
2037
2038 let formatted = format!(
2039 "✓ Added comment to {}\n\n {} ({}):\n {}",
2040 todo.short_id(),
2041 author,
2042 comment.created_at.format("%Y-%m-%d %H:%M"),
2043 req.content
2044 );
2045
2046 state.emit_event(MemoryEvent {
2047 event_type: "TODO_COMMENT_ADD".to_string(),
2048 timestamp: chrono::Utc::now(),
2049 user_id: req.user_id.clone(),
2050 memory_id: Some(comment.id.0.to_string()),
2051 content_preview: Some(format!(
2052 "[{}] {}",
2053 todo.short_id(),
2054 req.content.chars().take(80).collect::<String>()
2055 )),
2056 memory_type: Some(format!("{:?}", comment.comment_type)),
2057 importance: None,
2058 count: None,
2059 results: None,
2060 });
2061
2062 tracing::debug!(
2063 user_id = %req.user_id,
2064 todo_id = %todo.id,
2065 comment_id = %comment.id.0,
2066 "Added comment to todo"
2067 );
2068
2069 Ok(Json(CommentResponse {
2070 success: true,
2071 comment: Some(comment),
2072 formatted,
2073 }))
2074}
2075
2076pub async fn list_todo_comments(
2078 State(state): State<AppState>,
2079 Path(todo_id): Path<String>,
2080 Query(query): Query<TodoQuery>,
2081) -> Result<Json<CommentListResponse>, AppError> {
2082 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
2083
2084 let todo = state
2085 .todo_store
2086 .find_todo_by_prefix(&query.user_id, &todo_id)
2087 .map_err(AppError::Internal)?
2088 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
2089
2090 let comments = state
2091 .todo_store
2092 .get_comments(&query.user_id, &todo.id)
2093 .map_err(AppError::Internal)?;
2094
2095 let formatted = if comments.is_empty() {
2096 format!("No comments on {}", todo.short_id())
2097 } else {
2098 let mut output = format!(
2099 "📝 Comments on {} ({} total)\n\n",
2100 todo.short_id(),
2101 comments.len()
2102 );
2103 for (i, comment) in comments.iter().enumerate() {
2104 let type_icon = match comment.comment_type {
2105 TodoCommentType::Comment => "💬",
2106 TodoCommentType::Progress => "📊",
2107 TodoCommentType::Resolution => "✅",
2108 TodoCommentType::Activity => "🔄",
2109 };
2110 output.push_str(&format!(
2111 "{}. {} {} ({})\n {}\n\n",
2112 i + 1,
2113 type_icon,
2114 comment.author,
2115 comment.created_at.format("%Y-%m-%d %H:%M"),
2116 comment.content
2117 ));
2118 }
2119 output
2120 };
2121
2122 tracing::debug!(
2123 user_id = %query.user_id,
2124 todo_id = %todo.id,
2125 count = comments.len(),
2126 "Listed todo comments"
2127 );
2128
2129 Ok(Json(CommentListResponse {
2130 success: true,
2131 count: comments.len(),
2132 comments,
2133 formatted,
2134 }))
2135}
2136
2137pub async fn update_todo_comment(
2139 State(state): State<AppState>,
2140 Path((todo_id, comment_id)): Path<(String, String)>,
2141 Json(req): Json<UpdateCommentRequest>,
2142) -> Result<Json<CommentResponse>, AppError> {
2143 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
2144
2145 if req.content.trim().is_empty() {
2146 return Err(AppError::InvalidInput {
2147 field: "content".to_string(),
2148 reason: "Comment content cannot be empty".to_string(),
2149 });
2150 }
2151
2152 let todo = state
2153 .todo_store
2154 .find_todo_by_prefix(&req.user_id, &todo_id)
2155 .map_err(AppError::Internal)?
2156 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
2157
2158 let cid = uuid::Uuid::parse_str(&comment_id).map_err(|_| AppError::InvalidInput {
2159 field: "comment_id".to_string(),
2160 reason: "Invalid comment ID format".to_string(),
2161 })?;
2162 let comment_id_typed = TodoCommentId(cid);
2163
2164 let comment = state
2165 .todo_store
2166 .update_comment(
2167 &req.user_id,
2168 &todo.id,
2169 &comment_id_typed,
2170 req.content.clone(),
2171 )
2172 .map_err(AppError::Internal)?
2173 .ok_or_else(|| AppError::InvalidInput {
2174 field: "comment_id".to_string(),
2175 reason: "Comment not found".to_string(),
2176 })?;
2177
2178 let formatted = format!(
2179 "✓ Updated comment on {}\n\n Updated content:\n {}",
2180 todo.short_id(),
2181 req.content
2182 );
2183
2184 tracing::debug!(
2185 user_id = %req.user_id,
2186 todo_id = %todo.id,
2187 comment_id = %comment_id_typed.0,
2188 "Updated todo comment"
2189 );
2190
2191 Ok(Json(CommentResponse {
2192 success: true,
2193 comment: Some(comment),
2194 formatted,
2195 }))
2196}
2197
2198pub async fn delete_todo_comment(
2200 State(state): State<AppState>,
2201 Path((todo_id, comment_id)): Path<(String, String)>,
2202 Query(query): Query<TodoQuery>,
2203) -> Result<Json<CommentResponse>, AppError> {
2204 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
2205
2206 let todo = state
2207 .todo_store
2208 .find_todo_by_prefix(&query.user_id, &todo_id)
2209 .map_err(AppError::Internal)?
2210 .ok_or_else(|| AppError::TodoNotFound(todo_id.clone()))?;
2211
2212 let cid = uuid::Uuid::parse_str(&comment_id).map_err(|_| AppError::InvalidInput {
2213 field: "comment_id".to_string(),
2214 reason: "Invalid comment ID format".to_string(),
2215 })?;
2216 let comment_id_typed = TodoCommentId(cid);
2217
2218 let success = state
2219 .todo_store
2220 .delete_comment(&query.user_id, &todo.id, &comment_id_typed)
2221 .map_err(AppError::Internal)?;
2222
2223 let formatted = if success {
2224 format!("✓ Deleted comment from {}", todo.short_id())
2225 } else {
2226 "Comment not found".to_string()
2227 };
2228
2229 if success {
2230 state.emit_event(MemoryEvent {
2231 event_type: "TODO_COMMENT_DELETE".to_string(),
2232 timestamp: chrono::Utc::now(),
2233 user_id: query.user_id.clone(),
2234 memory_id: Some(comment_id.to_string()),
2235 content_preview: Some(format!("[{}] comment deleted", todo.short_id())),
2236 memory_type: None,
2237 importance: None,
2238 count: None,
2239 results: None,
2240 });
2241 }
2242
2243 tracing::debug!(
2244 user_id = %query.user_id,
2245 todo_id = %todo.id,
2246 comment_id = %comment_id,
2247 success = success,
2248 "Deleted todo comment"
2249 );
2250
2251 Ok(Json(CommentResponse {
2252 success,
2253 comment: None,
2254 formatted,
2255 }))
2256}
2257
2258pub async fn create_project(
2264 State(state): State<AppState>,
2265 Json(req): Json<CreateProjectRequest>,
2266) -> Result<Json<ProjectResponse>, AppError> {
2267 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
2268
2269 if req.name.trim().is_empty() {
2270 return Err(AppError::InvalidInput {
2271 field: "name".to_string(),
2272 reason: "Project name cannot be empty".to_string(),
2273 });
2274 }
2275
2276 let parent_id = if let Some(ref parent_ref) = req.parent {
2277 if let Ok(uuid) = uuid::Uuid::parse_str(parent_ref) {
2278 let pid = ProjectId(uuid);
2279 state
2280 .todo_store
2281 .get_project(&req.user_id, &pid)
2282 .map_err(AppError::Internal)?
2283 .ok_or_else(|| AppError::ProjectNotFound(parent_ref.clone()))?;
2284 Some(pid)
2285 } else {
2286 let parent = state
2287 .todo_store
2288 .find_project_by_name(&req.user_id, parent_ref)
2289 .map_err(AppError::Internal)?
2290 .ok_or_else(|| AppError::ProjectNotFound(parent_ref.clone()))?;
2291 Some(parent.id)
2292 }
2293 } else {
2294 None
2295 };
2296
2297 let mut project = Project::new(req.user_id.clone(), req.name.clone());
2298 if let Some(ref custom_prefix) = req.prefix {
2299 let clean = custom_prefix.trim().to_uppercase();
2300 if !clean.is_empty() {
2301 project.prefix = Some(clean);
2302 }
2303 }
2304 project.description = req.description;
2305 project.color = req.color;
2306 project.parent_id = parent_id;
2307
2308 state
2309 .todo_store
2310 .store_project(&project)
2311 .map_err(AppError::Internal)?;
2312
2313 let formatted = todo_formatter::format_project_created(&project);
2314
2315 state.emit_event(MemoryEvent {
2316 event_type: "PROJECT_CREATE".to_string(),
2317 timestamp: chrono::Utc::now(),
2318 user_id: req.user_id.clone(),
2319 memory_id: Some(project.id.0.to_string()),
2320 content_preview: Some(project.name.clone()),
2321 memory_type: Some("Project".to_string()),
2322 importance: None,
2323 count: None,
2324 results: None,
2325 });
2326
2327 tracing::info!(
2328 user_id = %req.user_id,
2329 project_id = %project.id.0,
2330 name = %req.name,
2331 parent = ?project.parent_id,
2332 "Created project"
2333 );
2334
2335 Ok(Json(ProjectResponse {
2336 success: true,
2337 project: Some(project),
2338 stats: None,
2339 formatted,
2340 }))
2341}
2342
2343pub async fn list_projects(
2345 State(state): State<AppState>,
2346 Json(req): Json<ListProjectsRequest>,
2347) -> Result<Json<ProjectListResponse>, AppError> {
2348 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
2349
2350 let projects = state
2351 .todo_store
2352 .list_projects(&req.user_id)
2353 .map_err(AppError::Internal)?;
2354
2355 let mut project_stats = Vec::new();
2356 for project in projects {
2357 let stats = state
2358 .todo_store
2359 .get_project_stats(&req.user_id, &project.id)
2360 .map_err(AppError::Internal)?;
2361 project_stats.push((project, stats));
2362 }
2363
2364 let formatted = todo_formatter::format_project_list(&project_stats);
2365
2366 Ok(Json(ProjectListResponse {
2367 success: true,
2368 count: project_stats.len(),
2369 projects: project_stats,
2370 formatted,
2371 }))
2372}
2373
2374pub async fn get_project(
2376 State(state): State<AppState>,
2377 Path(project_id): Path<String>,
2378 Query(query): Query<TodoQuery>,
2379) -> Result<Json<ProjectResponse>, AppError> {
2380 validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
2381
2382 let project = state
2383 .todo_store
2384 .find_project_by_name(&query.user_id, &project_id)
2385 .map_err(AppError::Internal)?
2386 .or_else(|| {
2387 uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
2388 state
2389 .todo_store
2390 .get_project(&query.user_id, &ProjectId(uuid))
2391 .ok()
2392 .flatten()
2393 })
2394 })
2395 .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
2396
2397 let stats = state
2398 .todo_store
2399 .get_project_stats(&query.user_id, &project.id)
2400 .map_err(AppError::Internal)?;
2401
2402 let todos = state
2403 .todo_store
2404 .list_todos_by_project(&query.user_id, &project.id)
2405 .map_err(AppError::Internal)?;
2406
2407 let formatted = todo_formatter::format_project_todos(&project, &todos, &stats);
2408
2409 Ok(Json(ProjectResponse {
2410 success: true,
2411 project: Some(project),
2412 stats: Some(stats),
2413 formatted,
2414 }))
2415}
2416
2417pub async fn update_project(
2419 State(state): State<AppState>,
2420 Path(project_id): Path<String>,
2421 Json(req): Json<UpdateProjectRequest>,
2422) -> Result<Json<ProjectResponse>, AppError> {
2423 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
2424
2425 let project = state
2426 .todo_store
2427 .find_project_by_name(&req.user_id, &project_id)
2428 .map_err(AppError::Internal)?
2429 .or_else(|| {
2430 uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
2431 state
2432 .todo_store
2433 .get_project(&req.user_id, &ProjectId(uuid))
2434 .ok()
2435 .flatten()
2436 })
2437 })
2438 .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
2439
2440 let updated = state
2441 .todo_store
2442 .update_project(
2443 &req.user_id,
2444 &project.id,
2445 req.name,
2446 req.prefix,
2447 req.description,
2448 req.status,
2449 req.color,
2450 )
2451 .map_err(AppError::Internal)?
2452 .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
2453
2454 let formatted = todo_formatter::format_project_updated(&updated);
2455
2456 state.emit_event(MemoryEvent {
2457 event_type: "PROJECT_UPDATE".to_string(),
2458 timestamp: chrono::Utc::now(),
2459 user_id: req.user_id.clone(),
2460 memory_id: Some(updated.id.0.to_string()),
2461 content_preview: Some(updated.name.clone()),
2462 memory_type: Some("Project".to_string()),
2463 importance: None,
2464 count: None,
2465 results: None,
2466 });
2467
2468 tracing::info!(
2469 user_id = %req.user_id,
2470 project_id = %updated.id.0,
2471 status = ?updated.status,
2472 "Updated project"
2473 );
2474
2475 Ok(Json(ProjectResponse {
2476 success: true,
2477 project: Some(updated),
2478 stats: None,
2479 formatted,
2480 }))
2481}
2482
2483pub async fn delete_project(
2485 State(state): State<AppState>,
2486 Path(project_id): Path<String>,
2487 Json(req): Json<DeleteProjectRequest>,
2488) -> Result<Json<ProjectResponse>, AppError> {
2489 validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
2490
2491 let project = state
2492 .todo_store
2493 .find_project_by_name(&req.user_id, &project_id)
2494 .map_err(AppError::Internal)?
2495 .or_else(|| {
2496 uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
2497 state
2498 .todo_store
2499 .get_project(&req.user_id, &ProjectId(uuid))
2500 .ok()
2501 .flatten()
2502 })
2503 })
2504 .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
2505
2506 let todos_count = if req.delete_todos {
2507 state
2508 .todo_store
2509 .list_todos_by_project(&req.user_id, &project.id)
2510 .map_err(AppError::Internal)?
2511 .len()
2512 } else {
2513 0
2514 };
2515
2516 let deleted = state
2517 .todo_store
2518 .delete_project(&req.user_id, &project.id, req.delete_todos)
2519 .map_err(AppError::Internal)?;
2520
2521 if !deleted {
2522 return Err(AppError::ProjectNotFound(project_id));
2523 }
2524
2525 let formatted = todo_formatter::format_project_deleted(&project, todos_count);
2526
2527 state.emit_event(MemoryEvent {
2528 event_type: "PROJECT_DELETE".to_string(),
2529 timestamp: chrono::Utc::now(),
2530 user_id: req.user_id.clone(),
2531 memory_id: Some(project.id.0.to_string()),
2532 content_preview: Some(project.name.clone()),
2533 memory_type: Some("Project".to_string()),
2534 importance: None,
2535 count: Some(todos_count),
2536 results: None,
2537 });
2538
2539 tracing::info!(
2540 user_id = %req.user_id,
2541 project_id = %project.id.0,
2542 delete_todos = %req.delete_todos,
2543 todos_deleted = %todos_count,
2544 "Deleted project"
2545 );
2546
2547 Ok(Json(ProjectResponse {
2548 success: true,
2549 project: Some(project),
2550 stats: None,
2551 formatted,
2552 }))
2553}