662 lines
28 KiB
C#
662 lines
28 KiB
C#
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,
|
|
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
|
|
{
|
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
|
=> await taskRepo.GetAllAsync(ct);
|
|
|
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
|
=> await taskRepo.GetByIdAsync(id, ct);
|
|
|
|
public async Task<DashboardTaskDto?> 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<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
|
|
|
public async Task<WorkTask> 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<TaskOperationResult> 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<TaskOperationResult> 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<TaskOperationResult> 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<TaskOperationResult> 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<string>();
|
|
|
|
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<TaskOperationResult> 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<IReadOnlyList<WorkTask>> 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<IReadOnlyList<WorkTask>> 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<AgentWorkflowOverview> 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<DashboardTaskDto> map(IEnumerable<WorkTask> 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<WorkTask> 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<string> { $"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<WorkTask> 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<TaskOperationResult> 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<string>();
|
|
|
|
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<TaskOperationResult> 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<TaskOperationResult> 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<TaskOperationResult> 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<TaskOperationResult> 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<BoardResponse> 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<DashboardTaskDto>();
|
|
var inProgress = new List<DashboardTaskDto>();
|
|
var review = new List<DashboardTaskDto>();
|
|
var blocked = new List<DashboardTaskDto>();
|
|
var done = new List<DashboardTaskDto>();
|
|
|
|
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<TaskOperationResult> 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<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
|
{
|
|
var normalizedHours = Math.Max(1, staleHours);
|
|
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
|
}
|
|
|
|
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
|
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
|
|
|
|
public async Task<IReadOnlyList<WorkTask>> 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<List<ActivityEvent>> 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<WorkTask> allTasks, IEnumerable<ActivityEvent> 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<ActivityEvent> activity, IReadOnlyList<WorkTask>? _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<string?> 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<TaskOperationResult> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|