using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; [Authorize] [ApiController] [Route("api/dashboard")] public class DashboardController( IDashboardService dashboardService, ITaskService taskService, IActivityRepository activityService, IHttpContextAccessor httpContextAccessor) : ControllerBase { [HttpGet("status")] public async Task GetStatus() => await dashboardService.GetStatusAsync(); [HttpGet("agents")] public async Task> GetAgents() => await dashboardService.GetAgentsAsync(); [HttpGet("operations")] public async Task> GetOperations( [FromQuery] int limit = 20, [FromQuery] string? agent = null) => await dashboardService.GetOperationsAsync(limit, agent); [HttpPost("chat/send")] public async Task SendChat([FromBody] ChatRequest request) { if (string.IsNullOrWhiteSpace(request.Message)) return new ChatResponse(false, null, "Message is required"); var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim(); return await dashboardService.SendChatAsync(agentId, request.Message.Trim()); } [HttpGet("chat/messages")] public async Task> GetMessages( [FromQuery] string? sessionKey, [FromQuery] int limit = 50, [FromQuery] int offset = 0) => await dashboardService.GetMessagesAsync(sessionKey, limit, offset); [HttpGet("queue")] public async Task> GetQueue(CancellationToken ct) => await dashboardService.GetQueueAsync(ct); [HttpDelete("queue/{id}")] public async Task DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) { var result = await dashboardService.DeleteQueueItemAsync(id, source, ct); return result.Outcome switch { 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" }) }; } [HttpPut("queue/{id}/priority")] public async Task ChangeQueuePriority(string id, CancellationToken ct) { var result = await dashboardService.CycleQueuePriorityAsync(id, ct); return result.Outcome switch { 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 }) }; } [HttpGet("agents/{id}/model")] public async Task> GetAgentModel(string id) { var info = await dashboardService.GetAgentModelAsync(id); return info is null ? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" }) : Ok(info); } [HttpPut("agents/{id}/model")] public async Task SetAgentModel(string id, [FromBody] SetModelRequest request) { if (string.IsNullOrWhiteSpace(request.Model)) return BadRequest(new { error = "Model is required" }); 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" }); } [HttpGet("agents/{id}/activity")] public async Task> GetAgentActivity(string id, [FromQuery] int limit = 5) => await dashboardService.GetAgentActivityAsync(id, limit); [HttpGet("models")] public ActionResult> GetAvailableModels() => Ok(dashboardService.GetAvailableModels()); // ── Task Endpoints ── [HttpGet("tasks")] public async Task> GetTasks(CancellationToken ct) { var tasks = await taskService.GetOpenAsync(ct); return tasks.Select(MapToDto).ToList(); } [HttpPost("tasks")] public async Task> CreateTask( [FromBody] CreateDashboardTaskRequest request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required." }); try { var task = await taskService.CreateDashboardTaskAsync( request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); } catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } } [HttpPut("tasks/{id:guid}")] public async Task> UpdateTask( Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) { var result = await taskService.UpdateDashboardTaskAsync( id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct); return result.Outcome switch { TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), _ => Ok(MapToDto(result.Task!)) }; } [HttpDelete("tasks/{id:guid}")] public async Task DeleteTask(Guid id, CancellationToken ct) { var result = await taskService.DeleteAsync(id, ct); return result.Outcome switch { 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() }; } [HttpPatch("tasks/{id:guid}/status")] public async Task> UpdateTaskStatus( Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) { // Enforce workflow rules based on caller agent var currentTask = await taskService.GetByIdAsync(id, ct); if (currentTask is null) return NotFound(new { error = "Task not found." }); // Resolve caller agent from header or JWT var callerAgent = ResolveCallerAgent(); // Nur Iris und Bao dürfen Status ändern if (!TaskStateHelper.CanChangeState(callerAgent, currentTask)) { return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." }); } var result = await taskService.UpdateStatusAsync(id, request.Status, ct); return result.Outcome switch { 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!)) }; } // ── Task Board Endpoints ── [HttpGet("tasks/board")] public async Task GetBoard(CancellationToken ct) => await taskService.GetBoardAsync(ct); [HttpPatch("tasks/{id:guid}/move")] public async Task> MoveTask( Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.State)) return BadRequest(new { error = "State is required." }); // Enforce workflow rules based on caller agent var currentTask = await taskService.GetByIdAsync(id, ct); if (currentTask is null) return NotFound(new { error = "Task not found." }); // Resolve caller agent from header or JWT var callerAgent = ResolveCallerAgent(); // Nur Iris und Bao dürfen Status ändern if (!TaskStateHelper.CanChangeState(callerAgent, currentTask)) { return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." }); } var result = await taskService.MoveTaskAsync(id, request.State, ct); return result.Outcome switch { TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }), TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), _ => Ok(MapToDto(result.Task!)) }; } /// /// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim. /// Falls back to empty string (which authorization helpers reject accordingly). /// private string ResolveCallerAgent() { var httpContext = httpContextAccessor.HttpContext; if (httpContext is null) return ""; 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() ?? ""; } // ── New Endpoints: Reset Stale, Children, Activity ── [HttpPost("tasks/reset-stale")] public async Task> ResetStale( [FromBody] ResetStaleRequest request, CancellationToken ct) { var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours)); var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct); return Ok(new ResetStaleResponse(count)); } [HttpGet("tasks/{id:guid}/children")] public async Task>> GetChildren(Guid id, CancellationToken ct) { var children = await taskService.GetChildTasksAsync(id, ct); return Ok(children.Select(MapToDto).ToList()); } [HttpGet("tasks/{id:guid}")] public async Task> GetTask(Guid id, CancellationToken ct) { var task = await taskService.GetByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); return Ok(MapToDto(task)); } [HttpGet("tasks/{id:guid}/activity")] public async Task>> GetTaskActivity(Guid id, CancellationToken ct) { var events = await taskService.GetTaskActivityAsync(id, ct); return Ok(events); } [HttpPost("tasks/{id:guid}/activity")] public async Task> PostTaskActivity( Guid id, [FromBody] PostActivityRequest request, CancellationToken ct) { var task = await taskService.GetByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); if (string.IsNullOrWhiteSpace(request.Message)) return BadRequest(new { error = "Message is required." }); var ev = new ActivityEvent { Type = request.Type ?? "comment", Message = request.Message.Trim(), TaskId = id }; await activityService.AddAsync(ev, ct); return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev); } // ── Agent Workflow Endpoints (Iris Overview) ── /// /// Returns agent-tasks that are still open and waiting for input. /// Iris uses this to see who she is waiting for. /// [HttpGet("tasks/agent-waiting")] public async Task>> GetAgentWaitingTasks(CancellationToken ct) { var waiting = await taskService.GetWaitingTasksAsync(ct); return Ok(waiting.Select(MapToDto).ToList()); } /// /// Returns a complete agent-workflow overview grouped by expected respondent /// + stale detection. This is the main Iris dashboard data. /// [HttpGet("tasks/agent-overview")] public async Task> GetAgentOverview( CancellationToken ct, [FromQuery] int staleHours = 2) { var threshold = TimeSpan.FromHours(Math.Max(1, staleHours)); return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct)); } /// /// Creates an agent-task: a task that is tracked as originating from the agent workflow. /// Sub-agents (programmer, reviewer) can only CREATE, not move state. /// [HttpPost("tasks/agent")] public async Task> CreateAgentTask( [FromBody] CreateAgentTaskRequest request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required." }); try { var task = await taskService.CreateAgentTaskAsync( request.Title, request.Detail, request.Source ?? "iris", request.Priority, request.AssignedTo, request.ExpectedFrom, request.ParentTaskId, ct); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); } catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } } 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); }