Skip to main content

shodh_memory/handlers/
todos.rs

1//! Todo, Reminder, and Project Handlers
2//!
3//! GTD-style task management with:
4//! - Prospective memory (reminders with time/duration/context triggers)
5//! - Todo CRUD with semantic search
6//! - Project hierarchy with nested projects
7//! - Todo comments and activity tracking
8
9use 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
29/// Application state type alias
30pub type AppState = std::sync::Arc<MultiUserMemoryManager>;
31
32// =============================================================================
33// REMINDER REQUEST/RESPONSE TYPES
34// =============================================================================
35
36/// Request to create a new reminder
37#[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/// Trigger configuration for reminder creation
53#[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/// Response for reminder creation
74#[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/// Request to list reminders
84#[derive(Debug, Deserialize)]
85pub struct ListRemindersRequest {
86    pub user_id: String,
87    pub status: Option<String>,
88}
89
90/// Individual reminder in list response
91#[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/// Response for listing reminders
107#[derive(Debug, Serialize)]
108pub struct ListRemindersResponse {
109    pub reminders: Vec<ReminderItem>,
110    pub count: usize,
111}
112
113/// Request to get due reminders
114#[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/// Response for due reminders
126#[derive(Debug, Serialize)]
127pub struct DueRemindersResponse {
128    pub reminders: Vec<ReminderItem>,
129    pub count: usize,
130}
131
132/// Request to check context-triggered reminders
133#[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/// Request to dismiss a reminder
142#[derive(Debug, Deserialize)]
143pub struct DismissReminderRequest {
144    pub user_id: String,
145}
146
147/// Response for dismiss/delete operations
148#[derive(Debug, Serialize)]
149pub struct ReminderActionResponse {
150    pub success: bool,
151    pub message: String,
152}
153
154/// Query for delete reminder
155#[derive(Debug, Deserialize)]
156pub struct DeleteReminderQuery {
157    pub user_id: String,
158}
159
160// =============================================================================
161// TODO REQUEST/RESPONSE TYPES
162// =============================================================================
163
164/// Request to create a new todo
165#[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/// Response for todo operations
194#[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/// Response for todo list operations
203#[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/// Response for todo complete with potential next recurrence
213#[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/// Request to list todos with filters
222#[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/// Request to update a todo
248#[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/// Request to reorder a todo
278#[derive(Debug, Deserialize)]
279pub struct ReorderTodoRequest {
280    pub user_id: String,
281    pub direction: String,
282}
283
284/// Request to get due todos
285#[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/// Query params for single todo operations
297#[derive(Debug, Deserialize)]
298pub struct TodoQuery {
299    pub user_id: String,
300}
301
302/// Request for todo stats
303#[derive(Debug, Deserialize)]
304pub struct TodoStatsRequest {
305    pub user_id: String,
306}
307
308/// Response for todo stats
309#[derive(Debug, Serialize)]
310pub struct TodoStatsResponse {
311    pub success: bool,
312    pub stats: UserTodoStats,
313    pub formatted: String,
314}
315
316// =============================================================================
317// COMMENT REQUEST/RESPONSE TYPES
318// =============================================================================
319
320/// Request to add a comment to a todo
321#[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/// Request to update a comment
332#[derive(Debug, Deserialize)]
333pub struct UpdateCommentRequest {
334    pub user_id: String,
335    pub content: String,
336}
337
338/// Response for comment operations
339#[derive(Debug, Serialize)]
340pub struct CommentResponse {
341    pub success: bool,
342    pub comment: Option<TodoComment>,
343    pub formatted: String,
344}
345
346/// Response for listing comments
347#[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// =============================================================================
356// PROJECT REQUEST/RESPONSE TYPES
357// =============================================================================
358
359/// Request to create a project
360#[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/// Response for project operations
375#[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/// Response for project list
384#[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/// Request to update a project
393#[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/// Request to delete a project
409#[derive(Debug, Deserialize)]
410pub struct DeleteProjectRequest {
411    pub user_id: String,
412    #[serde(default)]
413    pub delete_todos: bool,
414}
415
416/// Request to list projects
417#[derive(Debug, Deserialize)]
418pub struct ListProjectsRequest {
419    pub user_id: String,
420}
421
422// =============================================================================
423// HELPER FUNCTIONS
424// =============================================================================
425
426/// Parse recurrence string to Recurrence enum
427fn 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
438// =============================================================================
439// REMINDER HANDLERS
440// =============================================================================
441
442/// Create a new reminder (prospective memory)
443pub 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    // Cache embedding at creation time for context triggers (avoids recomputation on every check)
496    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
551/// List reminders for a user
552pub 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
600/// Get due time-based reminders
601pub 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    // Mark triggered and re-read actual state from DB (fixes C1: timestamp mismatch,
613    // C2: stale snapshot, C3: silent error swallowing)
614    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                    // Re-read to get the actual DB state with correct triggered_at timestamp
623                    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                    // Already triggered by concurrent call (race) — skip
630                    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
680/// Check for context-triggered reminders
681pub 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    // Mark triggered and re-read actual state (same C1+C2+C3 fixes as get_due_reminders)
735    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
791/// Dismiss (acknowledge) a triggered reminder
792pub 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
836/// Delete (cancel) a reminder
837pub 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
881// =============================================================================
882// TODO HANDLERS
883// =============================================================================
884
885/// POST /api/todos - Create a new todo
886pub 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    // Compute embedding for semantic search
958    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    // Create memory from todo
1005    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
1112/// POST /api/todos/list - List todos with filters
1113pub 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    // Apply status filter for semantic search results
1182    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    // Filter by project
1191    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    // Filter by context
1202    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    // Filter by parent_id
1208    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    // Filter by due date
1219    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    // Filter by priority
1254    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    // Apply pagination
1263    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
1293/// POST /api/todos/due - List due/overdue todos
1294pub 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
1321/// GET /api/todos/{todo_id} - Get a single todo
1322pub 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
1355/// POST /api/todos/{todo_id}/update - Update a todo
1356pub 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    // Re-compute embedding if needed.
1424    // IMPORTANT: call update_todo() FIRST so remove_todo_indices() cleans up
1425    // the OLD vector mapping. Then add the new embedding afterwards, so the
1426    // new vector/mapping aren't immediately deleted by remove_todo_indices().
1427    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                // Persist the embedding field on the todo
1467                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
1604/// POST /api/todos/{todo_id}/complete - Mark todo as complete
1605pub 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
1739/// DELETE /api/todos/{todo_id} - Delete a todo
1740pub 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
1792/// POST /api/todos/{todo_id}/reorder - Move todo up/down
1793pub 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
1849/// GET /api/todos/{todo_id}/subtasks - List subtasks of a parent todo
1850pub 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
1902/// POST /api/todos/stats - Get todo statistics
1903pub 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
1923// =============================================================================
1924// COMMENT HANDLERS
1925// =============================================================================
1926
1927/// POST /api/todos/{todo_id}/comments - Add a comment to a todo
1928pub 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
2076/// GET /api/todos/{todo_id}/comments - List comments for a todo
2077pub 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
2137/// POST /api/todos/{todo_id}/comments/{comment_id}/update - Update a comment
2138pub 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
2198/// DELETE /api/todos/{todo_id}/comments/{comment_id} - Delete a comment
2199pub 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
2258// =============================================================================
2259// PROJECT HANDLERS
2260// =============================================================================
2261
2262/// POST /api/projects - Create a new project
2263pub 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
2343/// POST /api/projects/list - List projects
2344pub 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
2374/// GET /api/projects/{project_id} - Get a project with stats
2375pub 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
2417/// POST /api/projects/{project_id}/update - Update a project
2418pub 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
2483/// DELETE /api/projects/{project_id} - Delete a project
2484pub 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}