refactor: SOLID architecture — backend service layer + frontend V2 components
## Backend — Service Layer & Repository Refactoring ### Neue Services (21 neue Dateien) **Interfaces & Implementierungen:** - `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse) - `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert - `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP) - `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController) - `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation - `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController - `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController - `IMemoryService` / `MemoryService` — File-I/O aus MemoryController - `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController - `IDocService` / `DocService` — Directory-Scan aus DocsController - `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController ### Repository-Fixes **IUserRepository / UserRepository:** - `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern) - `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges - `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges - `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save) ### AuthService-Fixes - `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()` - `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync` - `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync` ### Bug-Fix - `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt) ### Controller (16 Stück — alle slim) Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben. Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log). **Program.cs — neue Registrierungen:** - `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse) - Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService - Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService --- ## Frontend — Dashboard V2 Components **AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:** - V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente - Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern - NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,403 +1,113 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
public class DashboardController(
|
||||
OpenClawGatewayClient gateway,
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo,
|
||||
ILogger<DashboardController> logger)
|
||||
: ControllerBase
|
||||
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gateway health + session_status + subagents count.
|
||||
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
|
||||
/// </summary>
|
||||
[HttpGet("status")]
|
||||
public async Task<DashboardStatus> GetStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard status check failed");
|
||||
return new DashboardStatus(false, "Offline", 0, 0);
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetStatusAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all agents with their current status.
|
||||
/// Combines sessions_list + sub_agents_list.
|
||||
/// </summary>
|
||||
[HttpGet("agents")]
|
||||
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||
return new List<DashboardAgentInfo>();
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetAgentsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest assistant messages aggregated from ALL agent sessions.
|
||||
/// Events are sorted by timestamp descending (newest first).
|
||||
/// Supports optional agent filter via ?agent= query parameter.
|
||||
/// Falls back to Iris-only feed if multi-agent feed fails.
|
||||
/// </summary>
|
||||
[HttpGet("operations")]
|
||||
public async Task<List<FeedEntry>> GetOperations(
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? agent = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||
|
||||
// Optional agent filter
|
||||
if (!string.IsNullOrWhiteSpace(agent))
|
||||
{
|
||||
entries = entries
|
||||
.Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||
return new List<FeedEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a chat message to the Iris session.
|
||||
/// </summary>
|
||||
[HttpPost("chat/send")]
|
||||
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
return new ChatResponse(false, null, "Message is required");
|
||||
|
||||
try
|
||||
{
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
|
||||
? "iris"
|
||||
: request.AgentId.Trim();
|
||||
|
||||
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
}
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns chat messages (user + assistant only, not tool messages).
|
||||
/// </summary>
|
||||
[HttpGet("chat/messages")]
|
||||
public async Task<List<MessageEntry>> GetMessages(
|
||||
[FromQuery] string? sessionKey,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int offset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||
|
||||
return messages
|
||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||
return new List<MessageEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
|
||||
/// </summary>
|
||||
[HttpGet("queue")]
|
||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fetch cron jobs and open tasks concurrently
|
||||
var cronTask = gateway.GetQueueAsync();
|
||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
||||
=> await dashboardService.GetQueueAsync(ct);
|
||||
|
||||
await Task.WhenAll(cronTask, tasksTask);
|
||||
|
||||
var cronJobs = cronTask.Result;
|
||||
var openTasks = tasksTask.Result
|
||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var merged = new List<QueueItem>();
|
||||
|
||||
// Map cron jobs (already in QueueItem format from gateway)
|
||||
merged.AddRange(cronJobs);
|
||||
|
||||
// Map open tasks to QueueItems
|
||||
foreach (var t in openTasks)
|
||||
{
|
||||
var priority = NormalizePriority(t.Priority);
|
||||
merged.Add(new QueueItem(
|
||||
"task-" + t.Id.ToString(),
|
||||
t.Title,
|
||||
t.State,
|
||||
priority,
|
||||
"task",
|
||||
"--"
|
||||
));
|
||||
}
|
||||
|
||||
// Sort: high priority first, then medium, then low
|
||||
var priorityOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["high"] = 0,
|
||||
["medium"] = 1,
|
||||
["low"] = 2
|
||||
};
|
||||
|
||||
return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||
return new List<QueueItem>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePriority(string priority)
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" or "critical" or "urgent" => "high",
|
||||
"low" or "minor" => "low",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
|
||||
/// </summary>
|
||||
[HttpDelete("queue/{id}")]
|
||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ok = await gateway.DeleteCronJobAsync(id);
|
||||
if (!ok)
|
||||
return StatusCode(502, new { error = "Gateway could not delete cron job" });
|
||||
return NoContent();
|
||||
}
|
||||
else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract the actual GUID from the prefixed id ("task-{guid}")
|
||||
if (!id.StartsWith("task-"))
|
||||
return BadRequest(new { error = "Invalid task id format" });
|
||||
|
||||
var guidStr = id["task-".Length..];
|
||||
if (!Guid.TryParse(guidStr, out var guid))
|
||||
return BadRequest(new { error = "Invalid task id" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found" });
|
||||
|
||||
// Set task status to Done instead of deleting
|
||||
task.State = "Done";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" completed via queue"
|
||||
}, ct);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Default: try cron
|
||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||
if (!deleted)
|
||||
return NotFound(new { error = "Queue item not found" });
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
QueueDeleteOutcome.Deleted => NoContent(),
|
||||
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
|
||||
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
|
||||
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => StatusCode(500, new { error = "Internal error" })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
|
||||
/// Cycles: high → medium → low → high.
|
||||
/// </summary>
|
||||
[HttpPut("queue/{id}/priority")]
|
||||
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
if (!id.StartsWith("task-"))
|
||||
return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
|
||||
|
||||
var guidStr = id["task-".Length..];
|
||||
if (!Guid.TryParse(guidStr, out var guid))
|
||||
return BadRequest(new { error = "Invalid task id" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found" });
|
||||
|
||||
// Cycle priority: high → medium → low → high
|
||||
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}"
|
||||
}, ct);
|
||||
|
||||
return Ok(new { status = "ok", priority = task.Priority });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
|
||||
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => Ok(new { status = "ok", priority = result.NewPriority })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current model and provider for a specific agent session.
|
||||
/// Calls session_status with the agent's session key.
|
||||
/// </summary>
|
||||
[HttpGet("agents/{id}/model")]
|
||||
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = await gateway.GetAgentModelAsync(id);
|
||||
if (info is null)
|
||||
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
|
||||
return Ok(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
var info = await dashboardService.GetAgentModelAsync(id);
|
||||
return info is null
|
||||
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||
: Ok(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the model for a specific agent session.
|
||||
/// Calls session_status with model parameter.
|
||||
/// </summary>
|
||||
[HttpPut("agents/{id}/model")]
|
||||
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "Model is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var ok = await gateway.SetAgentModelAsync(id, request.Model);
|
||||
if (!ok)
|
||||
return StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||
return Ok(new { status = "ok", model = request.Model });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
|
||||
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the most recent activity entries (assistant messages) for a specific agent.
|
||||
/// </summary>
|
||||
[HttpGet("agents/{id}/activity")]
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
|
||||
return new List<AgentActivityEntry>();
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of available models that can be assigned to agents.
|
||||
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
|
||||
/// </summary>
|
||||
[HttpGet("models")]
|
||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||
{
|
||||
var models = gateway.GetAvailableModels();
|
||||
return Ok(models);
|
||||
}
|
||||
=> Ok(dashboardService.GetAvailableModels());
|
||||
|
||||
// ========== Task Endpoints ==========
|
||||
// ── Task Endpoints ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
|
||||
/// </summary>
|
||||
[HttpGet("tasks")]
|
||||
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = await taskRepo.GetAllAsync(ct);
|
||||
return tasks
|
||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Select(MapToDto)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard tasks fetch failed");
|
||||
return new List<DashboardTaskDto>();
|
||||
}
|
||||
var tasks = await taskService.GetOpenAsync(ct);
|
||||
return tasks.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new task and logs an activity event.
|
||||
/// </summary>
|
||||
[HttpPost("tasks")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||
@@ -405,121 +115,49 @@ public class DashboardController(
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required." });
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Detail = request.Detail?.Trim(),
|
||||
Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||
AssignedTo = request.AssignedTo?.Trim(),
|
||||
};
|
||||
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" created ({task.Source})"
|
||||
}, ct);
|
||||
|
||||
var task = await taskService.CreateDashboardTaskAsync(
|
||||
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing task (title, detail, source, priority, assignedTo).
|
||||
/// </summary>
|
||||
[HttpPut("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
task.Title = request.Title.Trim();
|
||||
if (request.Detail is not null)
|
||||
task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
||||
task.Source = request.Source.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
task.Priority = request.Priority.Trim();
|
||||
if (request.AssignedTo is not null)
|
||||
task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim();
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.UpdateDashboardTaskAsync(
|
||||
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" updated"
|
||||
}, ct);
|
||||
|
||||
return Ok(MapToDto(task));
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a task (only if status is 'Done' or 'Backlog').
|
||||
/// </summary>
|
||||
[HttpDelete("tasks/{id:guid}")]
|
||||
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." });
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" deleted"
|
||||
}, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
|
||||
return NoContent();
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
|
||||
_ => NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the status of a task.
|
||||
/// </summary>
|
||||
[HttpPatch("tasks/{id:guid}/status")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!TaskStateHelper.IsValidState(request.Status))
|
||||
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
var canonicalState = TaskStateHelper.AllStates.First(s =>
|
||||
s.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
|
||||
task.State = canonicalState;
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" → {canonicalState}"
|
||||
}, ct);
|
||||
|
||||
return Ok(MapToDto(task));
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id,
|
||||
t.Title,
|
||||
t.Detail,
|
||||
t.Source,
|
||||
t.State,
|
||||
t.Priority,
|
||||
t.AssignedTo,
|
||||
t.CreatedAt,
|
||||
t.UpdatedAt
|
||||
);
|
||||
|
||||
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user