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, IAgentService agentService, IHttpContextAccessor httpContextAccessor, ILiveUpdateService liveUpdateService) : 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 GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default) { var allTasks = (await taskRepo.GetAllAsync(ct)).ToList(); var task = allTasks.FirstOrDefault(t => t.Id == id); if (task is null) return null; var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct); return MapToDtoWithChildren(task, allTasks, activity); } 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); await PublishBoardSnapshotAsync(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); await PublishBoardSnapshotAsync(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); await PublishBoardSnapshotAsync(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); var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct); } 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); await PublishBoardSnapshotAsync(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); await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success); } 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> 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(); } public async Task GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default) { var all = (await taskRepo.GetAllAsync(ct)).ToList(); var threshold = DateTimeOffset.UtcNow - staleThreshold; var agentTasks = all.Where(t => t.IsAgentTask).ToList(); var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct); List map(IEnumerable tasks) => tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList(); var waitingForBao = map(agentTasks .Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))); var waitingForIris = map(agentTasks .Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))); var waitingForOthers = map(agentTasks .Where(t => { var expected = (t.ExpectedFrom ?? "").ToLowerInvariant(); return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase); })); var staleTasks = map(agentTasks .Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold)); 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) { 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 normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant(); var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct); var isVisibleDelegation = parentTaskId.HasValue; var task = new WorkTask { Title = title.Trim(), Detail = detail?.Trim(), Source = normalizedSource, Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), AssignedTo = normalizedAssignee, ParentTaskId = parentTaskId, IsAgentTask = isVisibleDelegation }; await taskRepo.AddAsync(task, ct); var activityMessages = new List { $"Task \"{task.Title}\" created ({task.Source})" }; if (parentTaskId.HasValue) { activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}."); await activityRepo.AddAsync(new ActivityEvent { Type = "delegation", Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.", TaskId = parentTaskId.Value }, ct); } await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct); if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( "task_assigned", $"Neue Aufgabe: {task.Title}", detail, "bao", task.Id, ct); } await PublishBoardSnapshotAsync(ct); return task; } public async Task CreateAgentTaskAsync( string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default) { var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct); var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct); task.IsAgentTask = true; task.ExpectedFrom = normalizedExpectedFrom; task.State = ResolveInitialAgentTaskState(startsInProgress, initialState); 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); if (parentTaskId.HasValue) { await activityRepo.AddAsync(new ActivityEvent { Type = "delegation", Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.", TaskId = parentTaskId.Value }, ct); } await notificationService.CreateAsync( "agent_task_created", $"Neuer Agent-Task: {task.Title}", detail, "iris", task.Id, ct); await PublishBoardSnapshotAsync(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 = await NormalizeActorAsync(assignedTo, ct); if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase)) { changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}"); task.AssignedTo = validated; } } if (dueDate.HasValue && 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); if (changes.Count > 0 && caller == "bao") { await notificationService.CreateAsync( "task_content_changed", $"Bao hat \"{task.Title}\" geändert", changeSummary, "iris", task.Id, ct); } await PublishBoardSnapshotAsync(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 caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct); } public async Task StartCoordinationAsync(Guid id, CancellationToken ct = default) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase)) return new TaskOperationResult(TaskOperationOutcome.Success, task); return await UpdateTaskStatusInternalAsync( task, canonical: TaskStateHelper.ToStateString(TaskState.InProgress), actor: "nexus-system", activityType: "delegation", activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)", ct: ct); } 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); await PublishBoardSnapshotAsync(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); await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } public async Task GetBoardAsync(CancellationToken ct = default) { var all = (await taskRepo.GetAllAsync(ct)).ToList(); var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), 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 = MapToDtoWithChildren(task, all, activity); 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; } } offen.Sort(SortByPriorityThenCreatedAt); inProgress.Sort(SortByPriorityThenCreatedAt); review.Sort(SortByPriorityThenCreatedAt); blocked.Sort(SortByPriorityThenCreatedAt); done.Sort(SortByPriorityThenCreatedAt); return new BoardResponse(offen, inProgress, review, blocked, done); } private async Task PublishBoardSnapshotAsync(CancellationToken ct = default) { var board = await GetBoardAsync(ct); liveUpdateService.Publish("tasks.board.snapshot", board, "board"); } 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) { var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase)) ?? 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); var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct); } 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) && 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); } if (staleTasks.Count > 0) await PublishBoardSnapshotAsync(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 DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList allTasks, IEnumerable activity) { var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id) .OrderByDescending(t => t.UpdatedAt) .ToList(); var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList(); var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase)); var dto = MapToDtoWithActivity(task, activity, allTasks); return dto with { ChildTasks = childDtos, ChildTaskCount = childDtos.Count, OpenChildTaskCount = openChildTaskCount, HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask }; } 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); private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable activity, IReadOnlyList? _allTasks = null) { var last = activity .Where(e => e.TaskId == t.Id) .OrderByDescending(e => e.CreatedAt) .FirstOrDefault(); return new DashboardTaskDto( 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, last?.Message, last?.CreatedAt, null, 0, 0, t.ParentTaskId.HasValue || t.IsAgentTask); } private async Task NormalizeActorAsync(string? actorId, CancellationToken ct) { var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct)); return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); } private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState) { if (!string.IsNullOrWhiteSpace(initialState)) { var canonical = TaskStateHelper.AllStates.FirstOrDefault(state => state.Equals(initialState, StringComparison.OrdinalIgnoreCase)); if (canonical is not null) return canonical; } return startsInProgress ? TaskStateHelper.ToStateString(TaskState.InProgress) : TaskStateHelper.ToStateString(TaskState.Backlog); } private string ResolveCaller() { var httpContext = httpContextAccessor.HttpContext; if (httpContext is null) return "nexus-system"; 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() ?? ""; } private async Task UpdateTaskStatusInternalAsync( WorkTask task, string canonical, string actor, string activityType, string? activityMessage, CancellationToken ct) { task.State = canonical; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = activityType, Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct); await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, 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 {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 { 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); } } } }