using System.Text.RegularExpressions; using Nexus.Api.Data; using Nexus.Api.DTOs; using Nexus.Api.Models; using Nexus.Api.Repositories; namespace Nexus.Api.Services; public sealed class TaskService( ITaskRepository taskRepo, IActivityRepository activityRepo) : ITaskService { public async Task> GetAllAsync(CancellationToken ct = default) => await taskRepo.GetAllAsync(ct); public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => await taskRepo.GetByIdAsync(id, ct); public async Task> GetPendingApprovalAsync(CancellationToken ct = default) => await taskRepo.GetPendingApprovalAsync(ct); public async Task CreateAsync(CreateTaskRequest request, CancellationToken ct = default) { var task = new WorkTask { Title = request.Title.Trim(), Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), ProjectId = request.ProjectId }; await taskRepo.AddAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct); return task; } public async Task ApproveAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) return new TaskOperationResult(TaskOperationOutcome.InvalidState, task); task.State = TaskStateHelper.ToStateString(TaskState.Done); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task RejectAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) return new TaskOperationResult(TaskOperationOutcome.InvalidState, task); task.State = TaskStateHelper.ToStateString(TaskState.Backlog); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task UpdateStateAsync(Guid id, string state, CancellationToken ct = default) { var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase)); if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); task.State = canonical; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!string.IsNullOrWhiteSpace(request.Title)) task.Title = request.Title.Trim(); if (!string.IsNullOrWhiteSpace(request.Priority)) task.Priority = request.Priority.Trim(); if (request.ProjectId.HasValue) task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!TaskStateHelper.IsDoneOrBacklog(task.State)) return new TaskOperationResult(TaskOperationOutcome.InvalidState, task); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct); await taskRepo.DeleteAsync(task, ct); return new TaskOperationResult(TaskOperationOutcome.Success); } // ── Dashboard-facing operations ── public async Task> GetOpenAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(t => t.CreatedAt) .ToList(); } public async Task CreateDashboardTaskAsync( string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default) { var task = new WorkTask { Title = title.Trim(), Detail = detail?.Trim(), Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(), Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), AssignedTo = ValidateAssignedTo(assignedTo) }; await taskRepo.AddAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct); return task; } public async Task UpdateDashboardTaskAsync( Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim(); if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(); if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim(); if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim(); if (assignedTo is not null) task.AssignedTo = ValidateAssignedTo(assignedTo); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default) { if (!TaskStateHelper.IsValidState(status)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); task.State = canonical; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); task.State = "Done"; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task CyclePriorityAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); task.Priority = task.Priority.ToLowerInvariant() switch { "high" => "Medium", "medium" => "Low", "low" => "High", _ => "Medium" }; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } // ── Board operations ── public async Task GetBoardAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); var offen = new List(); var inProgress = new List(); var review = new List(); var blocked = new List(); var done = new List(); foreach (var task in all) { var dto = MapToDto(task); switch (task.State.ToLowerInvariant()) { case "backlog": offen.Add(dto); break; case "in progress": inProgress.Add(dto); break; case "review": review.Add(dto); break; case "blocked": blocked.Add(dto); break; case "done": done.Add(dto); break; default: offen.Add(dto); break; } } return new TaskBoardResponse(offen, inProgress, review, blocked, done); } public async Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default) { // Resolve canonical state: accept board group keys or canonical strings var canonical = TaskStateHelper.AllStates .FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase)); if (canonical is null) { // Try mapping from board group key canonical = TaskStateHelper.BoardGroupToState(newState); } if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); task.State = canonical; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}" }, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default) { var todoPath = "/mnt/workspace-iris/TODO.md"; if (!File.Exists(todoPath)) return 0; var content = await File.ReadAllTextAsync(todoPath, ct); var lines = content.Split('\n'); var imported = 0; string? currentPriority = null; // Parse sections and extract tasks with assignee info // Pattern: ### N. Title 👤 Person var taskPattern = new Regex(@"^###\s+\d+\.\s+(.+?)(?:\s+👤\s+(.+?))?$", RegexOptions.Compiled); foreach (var line in lines) { var trimmed = line.Trim(); // Detect priority section if (trimmed.StartsWith("## ")) { var sectionLower = trimmed.ToLowerInvariant(); if (sectionLower.Contains("high")) currentPriority = "High"; else if (sectionLower.Contains("medium")) currentPriority = "Medium"; else if (sectionLower.Contains("low")) currentPriority = "Low"; else currentPriority = "Medium"; continue; } var match = taskPattern.Match(trimmed); if (!match.Success) continue; var title = match.Groups[1].Value.Trim(); var assigneeRaw = match.Groups[2].Success ? match.Groups[2].Value.Trim() : null; // Resolve assignee: "Iris" → "iris", "Bao" → "bao", "Iris+Bao" → "iris" string? assignedTo = null; if (!string.IsNullOrWhiteSpace(assigneeRaw)) { var lower = assigneeRaw.ToLowerInvariant(); if (lower.Contains("iris")) assignedTo = "iris"; else if (lower.Contains("bao")) assignedTo = "bao"; } var task = new WorkTask { Title = title, Source = "iris", Priority = currentPriority ?? "Medium", AssignedTo = assignedTo }; await taskRepo.AddAsync(task, ct); imported++; } if (deleteAfterImport) { File.Delete(todoPath); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md and deleted the file" }, ct); } else { await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md" }, ct); } return imported; } private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt); /// /// Validates AssignedTo — only "bao", "iris", or null are accepted. /// Returns null for invalid values. /// private static string? ValidateAssignedTo(string? assignedTo) { if (string.IsNullOrWhiteSpace(assignedTo)) return null; var lower = assignedTo.Trim().ToLowerInvariant(); if (lower is "bao" or "iris") return lower; return null; } }