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, INotificationService notificationService) : ITaskService { private static readonly HashSet ValidAssignees = ["bao", "iris", "programmer", "reviewer", "architekt"]; 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", TaskId = task.Id }, 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", TaskId = task.Id }, 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", TaskId = task.Id }, 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}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, 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", TaskId = task.Id }, 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", TaskId = task.Id }, 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, Guid? parentTaskId = null, CancellationToken ct = default) { // Validate parent task exists if specified if (parentTaskId.HasValue) { var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct); if (parent is null) throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId)); } 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), ParentTaskId = parentTaskId }; await taskRepo.AddAsync(task, ct); var message = $"Task \"{task.Title}\" created ({task.Source})"; if (parentTaskId.HasValue) message += $" [child of {parentTaskId.Value}]"; await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct); // Auto-notify: if assigned to bao, create a task_assigned notification if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_assigned", $"Neue Aufgabe: {task.Title}", detail, "bao", task.Id, ct); } return task; } public async Task UpdateDashboardTaskAsync( Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, 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); if (dueDate.HasValue) task.DueDate = dueDate; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated", TaskId = task.Id }, 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}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, 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", TaskId = task.Id }, 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}", TaskId = task.Id }, 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 delegated = 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 "delegated": delegated.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; } } // Priority sort within each group: High > Medium > Low, then by CreatedAt ascending offen.Sort(SortByPriorityThenCreatedAt); inProgress.Sort(SortByPriorityThenCreatedAt); delegated.Sort(SortByPriorityThenCreatedAt); review.Sort(SortByPriorityThenCreatedAt); blocked.Sort(SortByPriorityThenCreatedAt); done.Sort(SortByPriorityThenCreatedAt); return new TaskBoardResponse(offen, inProgress, delegated, review, blocked, done); } private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b) { var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority)); return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt); } private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch { "high" => 3, "medium" => 2, "normal" => 2, "low" => 1, _ => 2 }; 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}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); var threshold = DateTimeOffset.UtcNow - staleThreshold; var staleTasks = all.Where(t => (string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) && t.UpdatedAt < threshold).ToList(); foreach (var task in staleTasks) { var prevState = task.State; task.State = "Backlog"; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)", TaskId = task.Id }, ct); } return staleTasks.Count; } public async Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); return all.Where(t => t.ParentTaskId == parentId) .OrderByDescending(t => t.CreatedAt) .ToList(); } public async Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default) { var all = await activityRepo.GetRecentAsync(100, ct); return all.Where(e => e.TaskId == taskId).ToList(); } private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt); /// /// Validates AssignedTo — only recognized agent values are accepted. /// Returns null for invalid values. /// private static string? ValidateAssignedTo(string? assignedTo) { if (string.IsNullOrWhiteSpace(assignedTo)) return null; var lower = assignedTo.Trim().ToLowerInvariant(); return ValidAssignees.Contains(lower) ? lower : null; } /// /// Creates status-change notifications when a task moves to Review or Blocked. /// private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) { if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_review", $"Task zur Überprüfung: {task.Title}", $"Status auf Review geändert von {task.AssignedTo ?? "unbekannt"}", "bao", task.Id, ct); } else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_blocked", $"Aufgabe blockiert: {task.Title}", "Die Task konnte nicht abgeschlossen werden und wurde blockiert.", "iris", task.Id, ct); } } }