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, IHttpContextAccessor httpContextAccessor) : 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); // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); 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); var changes = new List(); if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal)) { changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\""); task.Title = request.Title.Trim(); } if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase)) { changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}"); task.Priority = request.Priority.Trim(); } if (request.ProjectId.HasValue) { changes.Add($"Projekt-ID geändert"); task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; } await taskRepo.UpdateAsync(task, ct); var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen"; await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", 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(); } /// /// Returns agent-tasks that are still open and where an agent is expected to respond. /// Iris Dashboard uses this to see who she is waiting for. /// public async Task> GetWaitingTasksAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); return all .Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .OrderBy(t => t.ExpectedFrom != null ? 0 : 1) .ThenByDescending(t => t.UpdatedAt) .ToList(); } /// /// Returns agent-tasks grouped by which agent is expected to respond, /// with stale-detection: tasks in InProgress/Delegated that haven't been /// updated within the stale threshold. /// public async Task GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); var threshold = DateTimeOffset.UtcNow - staleThreshold; var agentTasks = all.Where(t => t.IsAgentTask).ToList(); var waitingForBao = agentTasks .Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .Select(MapToDto) .ToList(); var waitingForIris = agentTasks .Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .Select(MapToDto) .ToList(); var waitingForOthers = agentTasks .Where(t => { var expected = (t.ExpectedFrom ?? "").ToLowerInvariant(); return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase); }) .Select(MapToDto) .ToList(); var staleTasks = agentTasks .Where(t => (string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) && t.UpdatedAt < threshold) .Select(MapToDto) .ToList(); return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold); } 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 CreateAgentTaskAsync( string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default) { var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct); task.IsAgentTask = true; task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); // Persist the agent-task-specific fields await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "agent_task", Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})", TaskId = task.Id }, ct); // Notify iris about new agent-task await notificationService.CreateAsync( "agent_task_created", $"Neuer Agent-Task: {task.Title}", detail, "iris", 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 caller = ResolveCaller(); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); var changes = new List(); if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal)) { changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\""); task.Title = title.Trim(); } if (detail is not null) { var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(); if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal)) { changes.Add("Beschreibung aktualisiert"); task.Detail = newDetail; } } if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim(); if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase)) { changes.Add($"Priorität: {task.Priority} → {priority.Trim()}"); task.Priority = priority.Trim(); } if (assignedTo is not null) { var validated = ValidateAssignedTo(assignedTo); if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase)) { changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}"); task.AssignedTo = validated; } } if (dueDate.HasValue) { if (task.DueDate?.Date != dueDate.Value.Date) { changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}"); task.DueDate = dueDate; } } await taskRepo.UpdateAsync(task, ct); var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen"; await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}", TaskId = task.Id }, ct); // Notification: wenn Bao die Task geändert hat, Iris benachrichtigen if (changes.Count > 0 && caller == "bao") { await notificationService.CreateAsync( "task_content_changed", $"Bao hat \"{task.Title}\" geändert", $"{changeSummary}", "iris", 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); // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); 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; } } offen.Sort(SortByPriorityThenCreatedAt); inProgress.Sort(SortByPriorityThenCreatedAt); delegated.Sort(SortByPriorityThenCreatedAt); review.Sort(SortByPriorityThenCreatedAt); blocked.Sort(SortByPriorityThenCreatedAt); done.Sort(SortByPriorityThenCreatedAt); return new BoardResponse(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); // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); 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 Task ResetStaleAsync(int staleHours, CancellationToken ct = default) { var normalizedHours = Math.Max(1, staleHours); return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct); } 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, t.IsAgentTask, t.ExpectedFrom); /// /// 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; } /// /// Resolves the caller identity from the HTTP context. /// Reads the X-Agent-Id header for agent calls, falls back to JWT name. /// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops). /// private string ResolveCaller() { var httpContext = httpContextAccessor.HttpContext; if (httpContext is null) return "nexus-system"; // internal system ops allowed var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(agentHeader)) return agentHeader.Trim().ToLowerInvariant(); var user = httpContext.User; var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return nameClaim?.ToLowerInvariant() ?? ""; } /// /// Creates status-change notifications when a task moves to a new state. /// - Wenn Bao ändert → Iris benachrichtigen /// - Wenn Iris ändert → Bao benachrichtigen /// - Review/Blocked bekommen spezifische Töne /// private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) { var caller = ResolveCaller(); if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_review", $"Task zur Überprüfung: {task.Title}", $"Status auf Review geändert von {caller}", "bao", task.Id, ct); } else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_blocked", $"Aufgabe blockiert: {task.Title}", $"Die Task wurde von {caller} auf Blockiert gesetzt.", "iris", task.Id, ct); } else { // Allgemeine Statusänderung: Gegenüber benachrichtigen if (caller == "bao") { await notificationService.CreateAsync( "task_status_changed", $"Bao hat Status geändert: {task.Title}", $"Status → {canonical}", "iris", task.Id, ct); } else if (caller == "iris") { await notificationService.CreateAsync( "task_status_changed", $"Iris hat Status geändert: {task.Title}", $"Status → {canonical}", "bao", task.Id, ct); } } } }