83e072bc27
- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht - CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller - Bao-Content-Änderungen triggern task_content_changed-Notification an Iris - Bao-Status-Änderungen triggern task_status_changed-Notification an Iris - Iris-Status-Änderungen triggern task_status_changed-Notification an Bao - Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string) - Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview - Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter - Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body - Volume-Name: nexus-postgres → postgres-data (Standardisierung)
622 lines
26 KiB
C#
622 lines
26 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,
|
|
IHttpContextAccessor httpContextAccessor) : ITaskService
|
|
{
|
|
private static readonly HashSet<string> ValidAssignees =
|
|
["bao", "iris", "programmer", "reviewer", "architekt"];
|
|
|
|
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<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);
|
|
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);
|
|
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);
|
|
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);
|
|
|
|
// 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<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);
|
|
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);
|
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
|
}
|
|
|
|
// ── Dashboard-facing operations ──
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<AgentWorkflowOverview> 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<WorkTask> 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<WorkTask> 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<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 = 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<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);
|
|
|
|
// 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<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);
|
|
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);
|
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
|
}
|
|
|
|
// ── Board operations ──
|
|
|
|
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
|
{
|
|
var all = await taskRepo.GetAllAsync(ct);
|
|
var offen = new List<DashboardTaskDto>();
|
|
var inProgress = new List<DashboardTaskDto>();
|
|
var delegated = 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 = 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<TaskOperationResult> 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<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
|
{
|
|
var normalizedHours = Math.Max(1, staleHours);
|
|
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
|
}
|
|
|
|
public async Task<int> 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<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 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);
|
|
|
|
/// <summary>
|
|
/// Validates AssignedTo — only recognized agent values are accepted.
|
|
/// Returns null for invalid values.
|
|
/// </summary>
|
|
private static string? ValidateAssignedTo(string? assignedTo)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
|
var lower = assignedTo.Trim().ToLowerInvariant();
|
|
return ValidAssignees.Contains(lower) ? lower : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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() ?? "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|