feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
- 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)
This commit is contained in:
@@ -150,4 +150,104 @@ public class TaskBoardTests
|
||||
{
|
||||
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
|
||||
}
|
||||
|
||||
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_Iris_CanChangeAnyTask()
|
||||
{
|
||||
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||
|
||||
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
|
||||
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_Bao_CanChangeAnyTask()
|
||||
{
|
||||
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||
|
||||
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
|
||||
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_SubAgents_NeverAllowed()
|
||||
{
|
||||
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||
|
||||
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
|
||||
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
|
||||
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
|
||||
{
|
||||
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||
|
||||
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
|
||||
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
|
||||
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_NexusSystem_IsAllowed()
|
||||
{
|
||||
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
|
||||
|
||||
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeState_UnknownCaller_Rejected()
|
||||
{
|
||||
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||
|
||||
Assert.False(TaskStateHelper.CanChangeState("", task));
|
||||
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
|
||||
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
|
||||
Assert.False(TaskStateHelper.CanChangeState(null, task));
|
||||
}
|
||||
|
||||
// ── TaskStateHelper: CanEditContent ──
|
||||
|
||||
[Fact]
|
||||
public void CanEditContent_Iris_IsAllowed()
|
||||
{
|
||||
Assert.True(TaskStateHelper.CanEditContent("iris"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEditContent_Bao_IsAllowed()
|
||||
{
|
||||
Assert.True(TaskStateHelper.CanEditContent("bao"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEditContent_SubAgents_AreAllowed()
|
||||
{
|
||||
Assert.True(TaskStateHelper.CanEditContent("programmer"));
|
||||
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
|
||||
Assert.True(TaskStateHelper.CanEditContent("architekt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEditContent_NexusSystem_IsAllowed()
|
||||
{
|
||||
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEditContent_UnknownCaller_Rejected()
|
||||
{
|
||||
Assert.False(TaskStateHelper.CanEditContent(""));
|
||||
Assert.False(TaskStateHelper.CanEditContent(null));
|
||||
Assert.False(TaskStateHelper.CanEditContent(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -14,7 +15,8 @@ public class AuthController(
|
||||
IAuthService authService,
|
||||
IAntiforgery antiforgery,
|
||||
IConfiguration config,
|
||||
IHostEnvironment env) : ControllerBase
|
||||
IHostEnvironment env,
|
||||
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||
{
|
||||
[HttpGet("csrf")]
|
||||
public IActionResult GetCsrfToken()
|
||||
@@ -30,11 +32,38 @@ public class AuthController(
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
||||
|
||||
var session = await authService.LoginAsync(request, ct);
|
||||
if (session is null) return Results.Unauthorized();
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
var session = await authService.LoginAsync(request, ct);
|
||||
if (session is null)
|
||||
{
|
||||
var remaining = attemptTracker.RecordFailedAttempt(ip);
|
||||
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
|
||||
|
||||
// Attach remaining info to the 401 response via headers only
|
||||
// (the frontend can also parse the 429 body)
|
||||
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
|
||||
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
if (retryAfterSeconds > 0)
|
||||
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||
|
||||
// Return a structured body so the frontend can display remaining attempts
|
||||
return Results.Json(new
|
||||
{
|
||||
error = "invalid_credentials",
|
||||
message = "Invalid email or password.",
|
||||
remaining,
|
||||
retryAfterSeconds
|
||||
}, statusCode: 401);
|
||||
}
|
||||
|
||||
// Success — reset attempt counter
|
||||
attemptTracker.Reset(ip);
|
||||
SetRefreshCookie(Response, session.RefreshToken);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
@@ -54,6 +83,8 @@ public class AuthController(
|
||||
|
||||
SetRefreshCookie(Response, session.RefreshToken);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
|
||||
@@ -164,18 +164,18 @@ public class DashboardController(
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
// Bao review gate: Check if moving OUT of Review
|
||||
// Enforce workflow rules based on caller agent
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is not null &&
|
||||
string.Equals(currentTask.State, "Review", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(request.Status, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
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))
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
var isOwner = user?.IsInRole("Owner") == true ||
|
||||
user?.IsInRole("owner") == true ||
|
||||
user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value == "bao";
|
||||
if (!isOwner)
|
||||
return StatusCode(403, new { error = "Only the owner can move tasks out of Review." });
|
||||
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);
|
||||
@@ -190,7 +190,7 @@ public class DashboardController(
|
||||
// ── Task Board Endpoints ──
|
||||
|
||||
[HttpGet("tasks/board")]
|
||||
public async Task<TaskBoardResponse> GetBoard(CancellationToken ct)
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
|
||||
[HttpPatch("tasks/{id:guid}/move")]
|
||||
@@ -200,18 +200,18 @@ public class DashboardController(
|
||||
if (string.IsNullOrWhiteSpace(request.State))
|
||||
return BadRequest(new { error = "State is required." });
|
||||
|
||||
// Bao review gate: Check if moving OUT of Review
|
||||
// Enforce workflow rules based on caller agent
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is not null &&
|
||||
string.Equals(currentTask.State, "Review", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(request.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
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))
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
var isOwner = user?.IsInRole("Owner") == true ||
|
||||
user?.IsInRole("owner") == true ||
|
||||
user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value == "bao";
|
||||
if (!isOwner)
|
||||
return StatusCode(403, new { error = "Only the owner can move tasks out of Review." });
|
||||
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);
|
||||
@@ -223,6 +223,24 @@ public class DashboardController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||
/// </summary>
|
||||
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")]
|
||||
@@ -277,7 +295,59 @@ public class DashboardController(
|
||||
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
|
||||
}
|
||||
|
||||
// ── Agent Workflow Endpoints (Iris Overview) ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns agent-tasks that are still open and waiting for input.
|
||||
/// Iris uses this to see who she is waiting for.
|
||||
/// </summary>
|
||||
[HttpGet("tasks/agent-waiting")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
|
||||
{
|
||||
var waiting = await taskService.GetWaitingTasksAsync(ct);
|
||||
return Ok(waiting.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a complete agent-workflow overview grouped by expected respondent
|
||||
/// + stale detection. This is the main Iris dashboard data.
|
||||
/// </summary>
|
||||
[HttpGet("tasks/agent-overview")]
|
||||
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||
CancellationToken ct, [FromQuery] int staleHours = 2)
|
||||
{
|
||||
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||
return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("tasks/agent")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> 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.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
t.IsAgentTask, t.ExpectedFrom);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -70,6 +72,10 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Action denied",
|
||||
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
@@ -99,4 +105,27 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
|
||||
|
||||
/// <summary>
|
||||
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("board")]
|
||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||
=> Results.Ok(await taskService.GetBoardAsync(ct));
|
||||
|
||||
/// <summary>
|
||||
/// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog.
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("reset-stale")]
|
||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||
return Results.Ok(new ResetStaleResponse(count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
|
||||
string? Title,
|
||||
DateTimeOffset? Since
|
||||
);
|
||||
|
||||
|
||||
@@ -83,6 +83,43 @@ public static class TaskStateHelper
|
||||
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the caller is allowed to change this task's state.
|
||||
/// POLICY:
|
||||
/// - **Iris und Bao** dürfen Status ändern / verschieben.
|
||||
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern.
|
||||
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
|
||||
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
|
||||
/// </summary>
|
||||
public static bool CanChangeState(string? callerAgent, WorkTask task)
|
||||
{
|
||||
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||
|
||||
// Sub-agents must never move state
|
||||
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" };
|
||||
if (subAgents.Contains(caller)) return false;
|
||||
|
||||
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
|
||||
if (caller == "nexus-system") return true;
|
||||
|
||||
// Iris und Bao dürfen Status ändern
|
||||
return caller == "iris" || caller == "bao";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the caller is allowed to edit a task's content fields
|
||||
/// (title, detail, priority, assignedTo, dueDate).
|
||||
/// POLICY:
|
||||
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
|
||||
/// - Nur unbekannte/leere Caller werden abgewiesen.
|
||||
/// </summary>
|
||||
public static bool CanEditContent(string? callerAgent)
|
||||
{
|
||||
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(caller)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Group key for board responses (lowercased English state).</summary>
|
||||
public static string BoardGroupKey(string? state)
|
||||
{
|
||||
@@ -137,6 +174,19 @@ public sealed class WorkTask
|
||||
public string Priority { get; set; } = "Normal";
|
||||
public string Source { get; set; } = "bao";
|
||||
public string? AssignedTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this task was created programmatically by an agent (not manually by Bao).
|
||||
/// Agent-tasks in the board are subject to stricter workflow rules.
|
||||
/// </summary>
|
||||
public bool IsAgentTask { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Which agent/user is expected to respond next.
|
||||
/// Helps Iris see who she is waiting for.
|
||||
/// </summary>
|
||||
public string? ExpectedFrom { get; set; }
|
||||
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
public WorkTask? ParentTask { get; set; }
|
||||
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Nexus.Api.Data;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nexus.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(NexusDbContext))]
|
||||
[Migration("20260620174200_AddAgentTaskFields")]
|
||||
partial class AddAgentTaskFields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("TaskId");
|
||||
|
||||
b.ToTable("Activity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ForUser")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsRead")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||
|
||||
b.ToTable("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<int>("Progress")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("FamilyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "FamilyId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssignedTo")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExpectedFrom")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsAgentTask")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||
.WithMany("ChildTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ChildTasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nexus.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAgentTaskFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAgentTask",
|
||||
table: "Tasks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExpectedFrom",
|
||||
table: "Tasks",
|
||||
type: "character varying(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_IsAgentTask",
|
||||
table: "Tasks",
|
||||
column: "IsAgentTask");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_ExpectedFrom",
|
||||
table: "Tasks",
|
||||
column: "ExpectedFrom");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_IsAgentTask",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_ExpectedFrom",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExpectedFrom",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAgentTask",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,13 @@ namespace Nexus.Api.Migrations
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExpectedFrom")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsAgentTask")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -265,6 +272,10 @@ namespace Nexus.Api.Migrations
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
||||
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||
entity.Property(x => x.Source).HasMaxLength(60);
|
||||
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
||||
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
|
||||
entity.HasIndex(x => x.Source);
|
||||
entity.HasIndex(x => x.AssignedTo);
|
||||
entity.HasIndex(x => x.IsAgentTask);
|
||||
entity.HasIndex(x => x.ExpectedFrom);
|
||||
entity.HasOne(x => x.ParentTask)
|
||||
.WithMany(x => x.ChildTasks)
|
||||
.HasForeignKey(x => x.ParentTaskId)
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Routing;
|
||||
using Nexus.Api.Services;
|
||||
@@ -71,6 +72,37 @@ public static class ServiceCollectionExtensions
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.OnRejected = async (context, ct) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.ContentType = "application/json";
|
||||
|
||||
var retryAfterSeconds = 60;
|
||||
|
||||
// Try to read retry-after info from the metadata
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
{
|
||||
retryAfterSeconds = (int)retryAfter.TotalSeconds;
|
||||
}
|
||||
|
||||
// Set standard headers
|
||||
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
|
||||
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
|
||||
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||
|
||||
var body = new
|
||||
{
|
||||
error = "rate_limit_exceeded",
|
||||
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
|
||||
remaining = 0,
|
||||
retryAfterSeconds
|
||||
};
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
|
||||
};
|
||||
|
||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
@@ -171,6 +203,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<LoginAttemptTracker>();
|
||||
services.AddTransient<ModelRoutingService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IAgentService, AgentService>();
|
||||
|
||||
@@ -89,7 +89,9 @@ public sealed record DashboardTaskDto(
|
||||
Guid? ParentTaskId,
|
||||
DateTimeOffset? DueDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsAgentTask = false,
|
||||
string? ExpectedFrom = null
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -101,6 +103,16 @@ public sealed record CreateDashboardTaskRequest(
|
||||
Guid? ParentTaskId = null
|
||||
);
|
||||
|
||||
public sealed record CreateAgentTaskRequest(
|
||||
string Title,
|
||||
string? Detail,
|
||||
string? Source,
|
||||
string? Priority,
|
||||
string? AssignedTo,
|
||||
string? ExpectedFrom,
|
||||
Guid? ParentTaskId = null
|
||||
);
|
||||
|
||||
public sealed record UpdateDashboardTaskRequest(
|
||||
string? Title,
|
||||
string? Detail,
|
||||
@@ -121,7 +133,7 @@ public sealed record AgentActivityEntry(
|
||||
|
||||
// ── Task Board DTOs ──
|
||||
|
||||
public sealed record TaskBoardResponse(
|
||||
public sealed record BoardResponse(
|
||||
List<DashboardTaskDto> Offen,
|
||||
List<DashboardTaskDto> InProgress,
|
||||
List<DashboardTaskDto> Delegated,
|
||||
@@ -147,6 +159,20 @@ public sealed record PostActivityRequest(
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
// ── Agent Workflow DTOs ──
|
||||
|
||||
/// <summary>
|
||||
/// Overview of the agent workflow state, grouping tasks by expected respondent
|
||||
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
|
||||
/// </summary>
|
||||
public sealed record AgentWorkflowOverview(
|
||||
List<DashboardTaskDto> WaitingForBao,
|
||||
List<DashboardTaskDto> WaitingForIris,
|
||||
List<DashboardTaskDto> WaitingForOthers,
|
||||
List<DashboardTaskDto> StaleTasks,
|
||||
TimeSpan StaleThreshold
|
||||
);
|
||||
|
||||
// ── Notification DTOs ──
|
||||
|
||||
public sealed record NotificationDto(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Nexus.Api.RateLimiting;
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory tracking of login attempts per IP,
|
||||
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
|
||||
///
|
||||
/// Provides remaining-attempt count that can be passed back to the frontend.
|
||||
/// </summary>
|
||||
public sealed class LoginAttemptTracker
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
// IP → (count, windowStartTicks)
|
||||
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a failed attempt for the given IP.
|
||||
/// Returns remaining attempts (0 = locked out until reset).
|
||||
/// </summary>
|
||||
public int RecordFailedAttempt(string ip)
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
var windowTicks = (long)Window.TotalMilliseconds;
|
||||
|
||||
var (count, windowStart) = _store.AddOrUpdate(ip,
|
||||
_ => (1, now),
|
||||
(_, entry) =>
|
||||
{
|
||||
if (now - entry.WindowStartTicks >= windowTicks)
|
||||
return (1, now);
|
||||
return (entry.Count + 1, entry.WindowStartTicks);
|
||||
});
|
||||
|
||||
return Math.Max(0, MaxAttempts - count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the remaining attempts for the given IP without recording.
|
||||
/// </summary>
|
||||
public int GetRemaining(string ip)
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
var windowTicks = (long)Window.TotalMilliseconds;
|
||||
|
||||
if (_store.TryGetValue(ip, out var entry))
|
||||
{
|
||||
if (now - entry.WindowStartTicks >= windowTicks)
|
||||
return MaxAttempts;
|
||||
return Math.Max(0, MaxAttempts - entry.Count);
|
||||
}
|
||||
|
||||
return MaxAttempts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of seconds until the rate-limit window resets,
|
||||
/// or 0 if the window has already expired / no attempts recorded.
|
||||
/// </summary>
|
||||
public int GetRetryAfterSeconds(string ip)
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
var windowTicks = (long)Window.TotalMilliseconds;
|
||||
|
||||
if (!_store.TryGetValue(ip, out var entry))
|
||||
return 0;
|
||||
|
||||
var elapsed = now - entry.WindowStartTicks;
|
||||
if (elapsed >= windowTicks)
|
||||
return 0;
|
||||
|
||||
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets attempt count for the given IP (e.g. on success).
|
||||
/// </summary>
|
||||
public void Reset(string ip)
|
||||
{
|
||||
_store.TryRemove(ip, out _);
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,21 @@ public interface ITaskService
|
||||
// Dashboard-facing task operations
|
||||
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
// Task Board
|
||||
Task<TaskBoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||
|
||||
// Agent Workflow Overview
|
||||
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
|
||||
Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+246
-18
@@ -8,7 +8,8 @@ namespace Nexus.Api.Services;
|
||||
public sealed class TaskService(
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService) : ITaskService
|
||||
INotificationService notificationService,
|
||||
IHttpContextAccessor httpContextAccessor) : ITaskService
|
||||
{
|
||||
private static readonly HashSet<string> ValidAssignees =
|
||||
["bao", "iris", "programmer", "reviewer", "architekt"];
|
||||
@@ -71,6 +72,11 @@ public sealed class TaskService(
|
||||
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);
|
||||
@@ -83,15 +89,27 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
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))
|
||||
}
|
||||
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);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated", TaskId = task.Id }, 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);
|
||||
}
|
||||
|
||||
@@ -118,6 +136,66 @@ public sealed class TaskService(
|
||||
.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)
|
||||
@@ -161,22 +239,108 @@ public sealed class TaskService(
|
||||
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);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
||||
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
||||
if (assignedTo is not null) task.AssignedTo = ValidateAssignedTo(assignedTo);
|
||||
if (dueDate.HasValue) task.DueDate = dueDate;
|
||||
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);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated", TaskId = task.Id }, 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);
|
||||
}
|
||||
|
||||
@@ -188,6 +352,11 @@ public sealed class TaskService(
|
||||
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);
|
||||
@@ -227,7 +396,7 @@ public sealed class TaskService(
|
||||
|
||||
// ── Board operations ──
|
||||
|
||||
public async Task<TaskBoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
@@ -259,7 +428,6 @@ public sealed class TaskService(
|
||||
}
|
||||
}
|
||||
|
||||
// Priority sort within each group: High > Medium > Low, then by CreatedAt ascending
|
||||
offen.Sort(SortByPriorityThenCreatedAt);
|
||||
inProgress.Sort(SortByPriorityThenCreatedAt);
|
||||
delegated.Sort(SortByPriorityThenCreatedAt);
|
||||
@@ -267,7 +435,7 @@ public sealed class TaskService(
|
||||
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||
done.Sort(SortByPriorityThenCreatedAt);
|
||||
|
||||
return new TaskBoardResponse(offen, inProgress, delegated, review, blocked, done);
|
||||
return new BoardResponse(offen, inProgress, delegated, review, blocked, done);
|
||||
}
|
||||
|
||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||
@@ -303,6 +471,11 @@ public sealed class TaskService(
|
||||
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);
|
||||
@@ -310,6 +483,12 @@ public sealed class TaskService(
|
||||
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);
|
||||
@@ -351,7 +530,8 @@ public sealed class TaskService(
|
||||
|
||||
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.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
t.IsAgentTask, t.ExpectedFrom);
|
||||
|
||||
/// <summary>
|
||||
/// Validates AssignedTo — only recognized agent values are accepted.
|
||||
@@ -365,16 +545,40 @@ public sealed class TaskService(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates status-change notifications when a task moves to Review or Blocked.
|
||||
/// 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 {task.AssignedTo ?? "unbekannt"}",
|
||||
$"Status auf Review geändert von {caller}",
|
||||
"bao",
|
||||
task.Id,
|
||||
ct);
|
||||
@@ -384,10 +588,34 @@ public sealed class TaskService(
|
||||
await notificationService.CreateAsync(
|
||||
"task_blocked",
|
||||
$"Aufgabe blockiert: {task.Title}",
|
||||
"Die Task konnte nicht abgeschlossen werden und wurde blockiert.",
|
||||
$"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ interface AuthPayload {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
interface LoginErrorInfo {
|
||||
message: string
|
||||
remaining: number
|
||||
retryAfterSeconds: number
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -22,28 +28,51 @@ export const useAuthStore = defineStore('auth', {
|
||||
user: null as AuthUser | null,
|
||||
initialized: false,
|
||||
loading: false,
|
||||
/** Remaining login attempts in the current window (null = unknown) */
|
||||
remainingAttempts: null as number | null,
|
||||
/** Seconds until rate-limit reset (0 = not rate-limited) */
|
||||
retryAfterSeconds: 0,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: state => Boolean(state.accessToken && state.user),
|
||||
isRateLimited: state => state.remainingAttempts === 0 && state.retryAfterSeconds > 0,
|
||||
/** Returns true if the current web-ui user is Iris (JWT user identity matches "iris"). */
|
||||
isIris: state => {
|
||||
if (!state.user) return false
|
||||
const lower = state.user.email.toLowerCase()
|
||||
return lower.includes('iris') || state.user.displayName.toLowerCase().includes('iris')
|
||||
},
|
||||
/** Returns true if the current web-ui user is Bao (JWT user identity matches "bao"). */
|
||||
isBao: state => {
|
||||
if (!state.user) return false
|
||||
const lower = state.user.email.toLowerCase()
|
||||
return lower.includes('bao') || state.user.displayName.toLowerCase().includes('bao')
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
applySession(payload: AuthPayload) {
|
||||
this.accessToken = payload.accessToken
|
||||
this.expiresAt = payload.expiresAt
|
||||
this.user = payload.user
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
},
|
||||
clearSession() {
|
||||
this.accessToken = null
|
||||
this.expiresAt = null
|
||||
this.user = null
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
},
|
||||
async initialize() {
|
||||
if (this.initialized) return this.isAuthenticated
|
||||
this.initialized = true
|
||||
return this.refresh()
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
this.loading = true
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
@@ -52,9 +81,50 @@ export const useAuthStore = defineStore('auth', {
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
// Try to parse remaining from headers
|
||||
const remainingHeader = response.headers.get('X-RateLimit-Remaining')
|
||||
if (remainingHeader !== null) {
|
||||
this.remainingAttempts = parseInt(remainingHeader, 10)
|
||||
}
|
||||
|
||||
const resetHeader = response.headers.get('X-RateLimit-Reset')
|
||||
if (resetHeader !== null) {
|
||||
const resetTs = parseInt(resetHeader, 10) * 1000
|
||||
this.retryAfterSeconds = Math.max(0, Math.ceil((resetTs - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.')
|
||||
throw new Error('Invalid email or password.')
|
||||
// Try to parse structured JSON body for rate-limit info
|
||||
let remaining = this.remainingAttempts
|
||||
let retryAfter = this.retryAfterSeconds
|
||||
|
||||
try {
|
||||
const body = await response.json() as Record<string, unknown>
|
||||
if (typeof body.remaining === 'number') remaining = body.remaining
|
||||
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
|
||||
|
||||
if (response.status === 429) {
|
||||
this.remainingAttempts = 0
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter)
|
||||
} else if (response.status === 401) {
|
||||
this.remainingAttempts = remaining
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(body.message as string || 'Invalid email or password.', remaining, retryAfter)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LoginError) throw error
|
||||
// Fallback for non-JSON error responses
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
this.remainingAttempts = 0
|
||||
const retryAfterSec = this.retryAfterSeconds || 60
|
||||
this.retryAfterSeconds = retryAfterSec
|
||||
throw new LoginError('Too many attempts. Please wait.', 0, retryAfterSec)
|
||||
}
|
||||
|
||||
throw new LoginError('Invalid email or password.', this.remainingAttempts ?? 4, this.retryAfterSeconds)
|
||||
}
|
||||
|
||||
this.applySession(await response.json() as AuthPayload)
|
||||
@@ -101,3 +171,16 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** Custom error carrying rate-limit metadata. */
|
||||
class LoginError extends Error {
|
||||
remaining: number
|
||||
retryAfterSeconds: number
|
||||
|
||||
constructor(message: string, remaining: number, retryAfterSeconds: number) {
|
||||
super(message)
|
||||
this.name = 'LoginError'
|
||||
this.remaining = remaining
|
||||
this.retryAfterSeconds = retryAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface DashboardTaskDto {
|
||||
dueDate?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isAgentTask?: boolean
|
||||
expectedFrom?: string | null
|
||||
}
|
||||
|
||||
export interface BoardGroup {
|
||||
@@ -36,6 +38,14 @@ export interface BoardGroup {
|
||||
blocked: DashboardTaskDto[]
|
||||
}
|
||||
|
||||
export interface AgentWorkflowOverview {
|
||||
waitingForBao: DashboardTaskDto[]
|
||||
waitingForIris: DashboardTaskDto[]
|
||||
waitingForOthers: DashboardTaskDto[]
|
||||
staleTasks: DashboardTaskDto[]
|
||||
staleThreshold: string
|
||||
}
|
||||
|
||||
/* ── State Mapping ────────────────────────────────── */
|
||||
|
||||
function mapPriority(priority: string): TaskItem['priority'] {
|
||||
@@ -92,10 +102,28 @@ export const useTaskStore = defineStore('tasks', {
|
||||
} as BoardGroup,
|
||||
boardLoading: false,
|
||||
boardError: null as string | null,
|
||||
|
||||
// Agent Workflow Overview (for Iris)
|
||||
agentOverview: null as AgentWorkflowOverview | null,
|
||||
agentOverviewLoading: false,
|
||||
agentOverviewError: null as string | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
taskList: (state) => state.tasks,
|
||||
|
||||
// Iris helpers
|
||||
waitingForIrisTasks: (state) => state.agentOverview?.waitingForIris ?? [],
|
||||
waitingForBaoTasks: (state) => state.agentOverview?.waitingForBao ?? [],
|
||||
waitingForOthersTasks: (state) => state.agentOverview?.waitingForOthers ?? [],
|
||||
staleTasksList: (state) => state.agentOverview?.staleTasks ?? [],
|
||||
agentTaskCount: (state) => {
|
||||
if (!state.agentOverview) return 0
|
||||
return state.agentOverview.waitingForBao.length +
|
||||
state.agentOverview.waitingForIris.length +
|
||||
state.agentOverview.waitingForOthers.length +
|
||||
state.agentOverview.staleTasks.length
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -267,6 +295,54 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Fetch agent workflow overview ──────── */
|
||||
async fetchAgentOverview(staleHours = 2) {
|
||||
this.agentOverviewLoading = true
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/agent-overview?staleHours=${staleHours}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: AgentWorkflowOverview = await res.json()
|
||||
this.agentOverview = data
|
||||
this.agentOverviewError = null
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchAgentOverview failed', err)
|
||||
this.agentOverviewError = 'Agent overview could not be loaded'
|
||||
} finally {
|
||||
this.agentOverviewLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Create agent task ───────────────────── */
|
||||
async createAgentTask(data: {
|
||||
title: string
|
||||
detail?: string | null
|
||||
source?: string
|
||||
priority?: string
|
||||
assignedTo?: string
|
||||
expectedFrom?: string
|
||||
}) {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks/agent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
detail: data.detail ?? null,
|
||||
source: data.source ?? 'iris',
|
||||
priority: data.priority ?? 'Medium',
|
||||
assignedTo: data.assignedTo ?? null,
|
||||
expectedFrom: data.expectedFrom ?? null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
await this.fetchBoard()
|
||||
await this.fetchAgentOverview()
|
||||
return await res.json() as DashboardTaskDto
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] createAgentTask failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Polling ──────────────────────────────────── */
|
||||
startPolling() {
|
||||
if (this.refreshInterval) return
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*
|
||||
* Vollbild-Login mit GalaxyBackground, Glassmorphismus,
|
||||
* und Consistent Branding.
|
||||
* Zeigt verbleibende Login-Versuche und Rate-Limit-Countdown.
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Mail, LockKeyhole, Command, Eye, EyeOff } from '@lucide/vue'
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { Mail, LockKeyhole, Command, Eye, EyeOff, Clock, AlertTriangle } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||
@@ -20,6 +21,38 @@ const password = ref('')
|
||||
const error = ref('')
|
||||
const showPassword = ref(false)
|
||||
|
||||
// Rate-limit countdown timer
|
||||
const countdown = ref(0)
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const countdownText = computed(() => {
|
||||
if (countdown.value <= 0) return ''
|
||||
const m = Math.floor(countdown.value / 60)
|
||||
const s = countdown.value % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
stopCountdown()
|
||||
countdown.value = seconds
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown.value = Math.max(0, countdown.value - 1)
|
||||
if (countdown.value <= 0 && countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => stopCountdown())
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
try {
|
||||
@@ -29,7 +62,16 @@ async function submit() {
|
||||
: '/dashboard'
|
||||
await router.replace(target)
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Login fehlgeschlagen.'
|
||||
if (reason instanceof Error) {
|
||||
error.value = reason.message
|
||||
|
||||
// Start countdown if rate-limited
|
||||
if (auth.retryAfterSeconds > 0) {
|
||||
startCountdown(auth.retryAfterSeconds)
|
||||
}
|
||||
} else {
|
||||
error.value = 'Login fehlgeschlagen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -71,6 +113,7 @@ async function submit() {
|
||||
maxlength="120"
|
||||
placeholder="name@noveria.net"
|
||||
class="field-input"
|
||||
:disabled="auth.isRateLimited"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +133,7 @@ async function submit() {
|
||||
maxlength="200"
|
||||
placeholder="••••••••••"
|
||||
class="field-input"
|
||||
:disabled="auth.isRateLimited"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -97,6 +141,7 @@ async function submit() {
|
||||
@click="showPassword = !showPassword"
|
||||
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
|
||||
tabindex="-1"
|
||||
:disabled="auth.isRateLimited"
|
||||
>
|
||||
<Eye v-if="!showPassword" :size="16" />
|
||||
<EyeOff v-else :size="16" />
|
||||
@@ -104,13 +149,27 @@ async function submit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="login-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<!-- Error display with remaining attempts -->
|
||||
<div v-if="error" class="error-box" role="alert">
|
||||
<div class="error-main">
|
||||
<AlertTriangle v-if="countdown > 0" :size="16" class="error-icon" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-if="auth.remainingAttempts !== null && auth.remainingAttempts > 0" class="attempts-remaining">
|
||||
<LockKeyhole :size="12" />
|
||||
<span>{{ auth.remainingAttempts }} {{ auth.remainingAttempts === 1 ? 'Versuch verbleibend' : 'Versuche verbleibend' }}</span>
|
||||
</div>
|
||||
<div v-if="countdown > 0" class="countdown-bar">
|
||||
<Clock :size="12" />
|
||||
<span>Entsperrt in {{ countdownText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password">
|
||||
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password || auth.isRateLimited">
|
||||
<LockKeyhole :size="15" />
|
||||
{{ auth.loading ? 'Anmelden…' : 'Anmelden' }}
|
||||
<template v-if="auth.loading">Anmelden…</template>
|
||||
<template v-else-if="countdown > 0">Gesperrt ({{ countdownText }})</template>
|
||||
<template v-else>Anmelden</template>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -278,6 +337,11 @@ async function submit() {
|
||||
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
|
||||
}
|
||||
|
||||
.field-input:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -310,17 +374,61 @@ async function submit() {
|
||||
color: #a8a3d6;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
.toggle-pw:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Error Box ─────────────────────── */
|
||||
.error-box {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
color: #fda4af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.attempts-remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
color: #f9a8d4;
|
||||
padding: 4px 8px;
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.countdown-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
color: #fb923c;
|
||||
padding: 4px 8px;
|
||||
background: rgba(251, 146, 60, 0.08);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* ── Submit Button ─────────────────── */
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* - Profile (Display-Name ändern)
|
||||
* - Passwort ändern
|
||||
* - Admin: User-Liste + User anlegen (nur für owner-Rolle sichtbar)
|
||||
* - Admin: User-Liste + User anlegen (für owner- und admin-Rollen sichtbar)
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import {
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
*
|
||||
* 6 columns: Offen, In Bearbeitung, Delegiert, Review, Blockiert, Erledigt
|
||||
* HTML5 Drag & Drop (no external lib)
|
||||
*
|
||||
* Agent-Workflow Features:
|
||||
* - Agent-Tasks have a 🤖 badge
|
||||
* - ExpectedFrom field shows who is expected to act next
|
||||
* - Stale-task warning banner at top (InProgress/Delegated > 2h)
|
||||
* - Waiting section for Iris overview
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save } from '@lucide/vue'
|
||||
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTaskStore } from '../stores/tasks'
|
||||
|
||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||
@@ -22,10 +29,12 @@ type TaskFormState = {
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const taskStore = useTaskStore()
|
||||
const router = useRouter()
|
||||
const showCreateModal = ref(false)
|
||||
const showDetailPanel = ref(false)
|
||||
const showIrisPanel = ref(false)
|
||||
const dragOverColumn = ref<string | null>(null)
|
||||
const selectedTaskId = ref<string | null>(null)
|
||||
const detailSaving = ref(false)
|
||||
@@ -87,6 +96,10 @@ async function handleCreateTask() {
|
||||
const draggedTaskId = ref<string | null>(null)
|
||||
|
||||
function onDragStart(e: DragEvent, taskId: string) {
|
||||
if (!canChangeState.value) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!e.dataTransfer) return
|
||||
draggedTaskId.value = taskId
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -103,6 +116,7 @@ function onDragEnd(e: DragEvent) {
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, column: string) {
|
||||
if (!canChangeState.value) return
|
||||
e.preventDefault()
|
||||
if (!e.dataTransfer) return
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
@@ -114,6 +128,7 @@ function onDragLeave(_e: DragEvent) {
|
||||
}
|
||||
|
||||
async function onDrop(e: DragEvent, targetState: string) {
|
||||
if (!canChangeState.value) return
|
||||
e.preventDefault()
|
||||
dragOverColumn.value = null
|
||||
const taskId = e.dataTransfer?.getData('text/plain')
|
||||
@@ -179,6 +194,13 @@ const allBoardTasks = computed(() => flattenBoard())
|
||||
const selectedTask = computed(() => allBoardTasks.value.find(task => task.id === selectedTaskId.value) ?? null)
|
||||
const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !detailSaving.value)
|
||||
|
||||
/**
|
||||
* Policy: Iris und Bao dürfen Status ändern / verschieben.
|
||||
* Wenn der aktuelle Web-UI-User weder Iris noch Bao ist, werden
|
||||
* Drag & Drop und die Status-Dropdowns deaktiviert.
|
||||
*/
|
||||
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
||||
|
||||
function hydrateDetailForm(task: BoardTask | null) {
|
||||
detailError.value = ''
|
||||
detailSuccess.value = ''
|
||||
@@ -191,12 +213,49 @@ function hydrateDetailForm(task: BoardTask | null) {
|
||||
detailForm.dueDate = toDateInputValue(task.dueDate)
|
||||
}
|
||||
|
||||
async function openTask(taskId: string) {
|
||||
/* ── Iris Panel helpers ─────────────────────────── */
|
||||
const staleCount = computed(() => taskStore.staleTasksList.length)
|
||||
const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.length)
|
||||
const waitingForBaoCount = computed(() => taskStore.waitingForBaoTasks.length)
|
||||
|
||||
function expectedFromLabel(expected: string | null | undefined): string {
|
||||
if (!expected) return ''
|
||||
const map: Record<string, string> = {
|
||||
'bao': '👤 Bao',
|
||||
'iris': '🤖 Iris',
|
||||
'programmer': '🛠 Programmer',
|
||||
'reviewer': '🔎 Reviewer',
|
||||
'architekt': '🏛 Architekt',
|
||||
}
|
||||
return map[expected.toLowerCase()] ?? expected
|
||||
}
|
||||
|
||||
function hoursSince(dateStr: string): number {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
return Math.round((now - then) / 3600000)
|
||||
}
|
||||
|
||||
/* ── Task Navigation ───────────────────────────── */
|
||||
function navigateToTask(taskId: string) {
|
||||
router.push('/tasks/' + taskId)
|
||||
}
|
||||
|
||||
async function openQuickPeek(taskId: string) {
|
||||
selectedTaskId.value = taskId
|
||||
showDetailPanel.value = true
|
||||
await loadDetailContext(taskId)
|
||||
}
|
||||
|
||||
function handleCardClick(event: MouseEvent, taskId: string) {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
openQuickPeek(taskId)
|
||||
} else {
|
||||
navigateToTask(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
showDetailPanel.value = false
|
||||
selectedTaskId.value = null
|
||||
@@ -239,8 +298,11 @@ async function saveTaskDetail() {
|
||||
dueDate: detailForm.dueDate || null,
|
||||
})
|
||||
|
||||
if (detailForm.state !== selectedTask.value.state) {
|
||||
// Nur Iris/Bao darf Status ändern
|
||||
if (canChangeState.value && detailForm.state !== selectedTask.value.state) {
|
||||
await taskStore.moveTask(selectedTask.value.id, detailForm.state)
|
||||
} else if (detailForm.state !== selectedTask.value.state) {
|
||||
detailForm.state = selectedTask.value.state // revert state change in UI
|
||||
}
|
||||
|
||||
detailSuccess.value = 'Änderungen gespeichert'
|
||||
@@ -269,7 +331,10 @@ watch(showDetailPanel, (open) => {
|
||||
/* ── Lifecycle ────────────────────────────────────── */
|
||||
onMounted(() => {
|
||||
taskStore.startBoardPolling()
|
||||
taskStore.fetchAgentOverview()
|
||||
window.addEventListener('keydown', onGlobalKeydown)
|
||||
// Refresh agent overview on the same interval
|
||||
setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -289,11 +354,94 @@ onUnmounted(() => {
|
||||
<h1><span class="grad-text">Aufgaben</span></h1>
|
||||
<p class="board-subtitle">Task Board — Übersicht aller Arbeitspakete</p>
|
||||
</div>
|
||||
<div class="board-header-actions">
|
||||
<button
|
||||
v-if="staleCount > 0 || waitingForIrisCount > 0"
|
||||
class="iris-panel-btn"
|
||||
@click="showIrisPanel = !showIrisPanel"
|
||||
>
|
||||
<Eye :size="14" />
|
||||
Iris-Blick
|
||||
<span v-if="waitingForIrisCount > 0" class="panel-badge iris-badge">{{ waitingForIrisCount }}</span>
|
||||
<span v-if="staleCount > 0" class="panel-badge stale-badge">{{ staleCount }}</span>
|
||||
</button>
|
||||
<button class="create-btn" @click="showCreateModal = true">
|
||||
<Plus :size="16" />
|
||||
Neue Aufgabe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status-Change Permission Banner -->
|
||||
<div v-if="!canChangeState" class="permission-banner">
|
||||
<ShieldBan :size="14" />
|
||||
<span><strong>Nur-Lesen-Status.</strong> Du kannst Aufgaben inhaltlich bearbeiten (Titel, Beschreibung, Priorität, Zuständigkeit), aber das Verschieben/Status-Ändern ist <strong>nur Iris und Bao</strong> vorbehalten.</span>
|
||||
</div>
|
||||
|
||||
<!-- Stale Warning Banner -->
|
||||
<div v-if="staleCount > 0" class="stale-banner">
|
||||
<AlertTriangle :size="14" />
|
||||
<span><strong>{{ staleCount }} Task(s)</strong> sind stale (InBearbeitung/Delegiert > 2h ohne Update).</span>
|
||||
<button class="stale-dismiss" @click="showIrisPanel = true">Ansehen</button>
|
||||
</div>
|
||||
|
||||
<!-- Iris Overview Panel (collapsible) -->
|
||||
<div v-if="showIrisPanel" class="iris-panel">
|
||||
<div class="iris-panel-header">
|
||||
<h3><Bot :size="16" /> Iris — Worauf warte ich?</h3>
|
||||
<button class="modal-close" @click="showIrisPanel = false">×</button>
|
||||
</div>
|
||||
<div v-if="taskStore.agentOverviewLoading" class="iris-loading">Lade Übersicht…</div>
|
||||
<div v-else class="iris-panel-grid">
|
||||
<section class="iris-section">
|
||||
<div class="iris-section-title">
|
||||
<span class="section-dot iris-dot"></span>
|
||||
Warte auf Iris <span class="section-count">{{ waitingForIrisCount }}</span>
|
||||
</div>
|
||||
<div v-if="taskStore.waitingForIrisTasks.length === 0" class="iris-empty">Keine Tasks</div>
|
||||
<div v-for="t in taskStore.waitingForIrisTasks" :key="t.id" class="iris-task-row">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="iris-section">
|
||||
<div class="iris-section-title">
|
||||
<span class="section-dot bao-dot"></span>
|
||||
Warte auf Bao <span class="section-count">{{ waitingForBaoCount }}</span>
|
||||
</div>
|
||||
<div v-if="taskStore.waitingForBaoTasks.length === 0" class="iris-empty">Keine Tasks</div>
|
||||
<div v-for="t in taskStore.waitingForBaoTasks" :key="t.id" class="iris-task-row">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="iris-section">
|
||||
<div class="iris-section-title">
|
||||
<span class="section-dot other-dot"></span>
|
||||
Warte auf andere <span class="section-count">{{ taskStore.waitingForOthersTasks.length }}</span>
|
||||
</div>
|
||||
<div v-if="taskStore.waitingForOthersTasks.length === 0" class="iris-empty">Keine Tasks</div>
|
||||
<div v-for="t in taskStore.waitingForOthersTasks" :key="t.id" class="iris-task-row">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta">{{ expectedFromLabel(t.expectedFrom) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="iris-section iris-section-stale">
|
||||
<div class="iris-section-title">
|
||||
<span class="section-dot stale-dot"></span>
|
||||
Stale Tasks <span class="section-count stale-count">{{ staleCount }}</span>
|
||||
</div>
|
||||
<div v-if="taskStore.staleTasksList.length === 0" class="iris-empty">Keine stale Tasks</div>
|
||||
<div v-for="t in taskStore.staleTasksList" :key="t.id" class="iris-task-row stale-row">
|
||||
<span class="iris-task-title">{{ t.title }}</span>
|
||||
<span class="iris-task-meta stale-meta">{{ hoursSince(t.updatedAt) }}h offen</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="taskStore.boardLoading" class="board-loading">
|
||||
<div class="spinner"></div>
|
||||
@@ -319,8 +467,9 @@ onUnmounted(() => {
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -328,6 +477,10 @@ onUnmounted(() => {
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
@@ -362,8 +515,9 @@ onUnmounted(() => {
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -371,6 +525,10 @@ onUnmounted(() => {
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
@@ -405,8 +563,9 @@ onUnmounted(() => {
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -414,6 +573,10 @@ onUnmounted(() => {
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
@@ -448,8 +611,9 @@ onUnmounted(() => {
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -457,6 +621,10 @@ onUnmounted(() => {
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
@@ -492,7 +660,7 @@ onUnmounted(() => {
|
||||
type="button"
|
||||
class="card"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -534,8 +702,9 @@ onUnmounted(() => {
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card card-blocked"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="openTask(task.id)"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
@@ -543,6 +712,10 @@ onUnmounted(() => {
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
@@ -602,6 +775,9 @@ onUnmounted(() => {
|
||||
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
|
||||
<option value="bao">👤 Bao</option>
|
||||
<option value="iris">🤖 Iris</option>
|
||||
<option value="programmer">🛠 Programmer</option>
|
||||
<option value="reviewer">🔎 Reviewer</option>
|
||||
<option value="architekt">🏛 Architekt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,6 +810,8 @@ onUnmounted(() => {
|
||||
<input v-model="detailForm.title" class="detail-title-input" maxlength="240" />
|
||||
<div class="detail-meta-row">
|
||||
<span>#{{ selectedTask.id.slice(0, 8) }}</span>
|
||||
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
|
||||
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
||||
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
||||
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
|
||||
</div>
|
||||
@@ -686,8 +864,13 @@ onUnmounted(() => {
|
||||
<section class="sidebar-card">
|
||||
<div class="sidebar-heading">Eigenschaften</div>
|
||||
<label class="sidebar-field">
|
||||
<span>Status</span>
|
||||
<select v-model="detailForm.state" class="field-input field-select slim">
|
||||
<span>Status <span v-if="!canChangeState" class="readonly-tag">(nur Iris/Bao)</span></span>
|
||||
<select
|
||||
v-model="detailForm.state"
|
||||
class="field-input field-select slim"
|
||||
:disabled="!canChangeState"
|
||||
:title="!canChangeState ? 'Statusänderungen sind nur Iris und Bao vorbehalten' : ''"
|
||||
>
|
||||
<option value="Backlog">Offen</option>
|
||||
<option value="In progress">In Bearbeitung</option>
|
||||
<option value="Delegated">Delegiert</option>
|
||||
@@ -736,6 +919,14 @@ onUnmounted(() => {
|
||||
<dt>Fällig</dt>
|
||||
<dd>{{ formatDate(selectedTask.dueDate) }}</dd>
|
||||
</div>
|
||||
<div v-if="selectedTask.isAgentTask">
|
||||
<dt>Agent-Task</dt>
|
||||
<dd>🤖 Ja</dd>
|
||||
</div>
|
||||
<div v-if="selectedTask.expectedFrom">
|
||||
<dt>Erwartet von</dt>
|
||||
<dd>{{ selectedTask.expectedFrom }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -745,9 +936,9 @@ onUnmounted(() => {
|
||||
<div class="detail-actions">
|
||||
<button type="button" class="btn-cancel" @click="closeDetailPanel">Schließen</button>
|
||||
<button type="button" class="btn-ghost" @click="router.push('/tasks/' + selectedTask.id)">
|
||||
<ExternalLink :size="13" /> Vollansicht
|
||||
<ExternalLink :size="13" /> Vollansicht öffnen
|
||||
</button>
|
||||
<button type="button" class="btn-submit" :disabled="!canSaveDetail" @click="saveTaskDetail">
|
||||
<button type="button" class="btn-submit" :disabled="!canSaveDetail || (detailForm.state !== selectedTask.state && !canChangeState)" @click="saveTaskDetail">
|
||||
<Save :size="14" />
|
||||
{{ detailSaving ? 'Speichert…' : 'Speichern' }}
|
||||
</button>
|
||||
@@ -774,15 +965,55 @@ onUnmounted(() => {
|
||||
.board-header h1 { margin: 0; font-size: 22px; font-weight: 700; font-family: 'Space Grotesk', sans-serif; letter-spacing: -0.02em; }
|
||||
.grad-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.board-subtitle { margin: 4px 0 0; font-size: 11px; color: var(--tx-3); font-family: 'Manrope', sans-serif; }
|
||||
.board-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.create-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; flex-shrink: 0; box-shadow: var(--glow-purple); }
|
||||
.create-btn:hover { opacity: .85; transform: translateY(-1px); }
|
||||
.create-btn:active { transform: translateY(0); }
|
||||
.iris-panel-btn { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1px solid var(--a-mid); border-radius: var(--r-sm, 10px); background: rgba(124,108,255,.10); color: var(--a-mid); font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, border-color .15s; }
|
||||
.iris-panel-btn:hover { background: rgba(124,108,255,.18); }
|
||||
.panel-badge { font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 6px; }
|
||||
.iris-badge { background: rgba(147, 51, 234, .25); color: #c084fc; }
|
||||
.stale-badge { background: rgba(244, 63, 94, .25); color: #fda4af; }
|
||||
|
||||
/* Stale Banner */
|
||||
.stale-banner { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r-sm, 10px); background: rgba(244,63,94,.10); border: 1px solid rgba(244,63,94,.25); color: #fda4af; font-size: 12px; font-family: 'Manrope', sans-serif; }
|
||||
.stale-dismiss { margin-left: auto; padding: 4px 12px; border: 1px solid rgba(244,63,94,.3); border-radius: 8px; background: transparent; color: #fda4af; font-size: 11px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; }
|
||||
|
||||
/* Iris Panel */
|
||||
.iris-panel { background: var(--glass); border: 1px solid var(--line-2); border-radius: var(--r, 14px); padding: 16px; backdrop-filter: blur(12px); }
|
||||
.iris-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||
.iris-panel-header h3 { margin: 0; font-size: 14px; font-weight: 700; color: var(--tx); display: flex; align-items: center; gap: 8px; font-family: 'Space Grotesk', sans-serif; }
|
||||
.iris-loading { padding: 20px; text-align: center; color: var(--tx-3); font-size: 12px; }
|
||||
.iris-panel-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.iris-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 12px; padding: 12px; }
|
||||
.iris-section-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--tx-2); margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||
.section-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.iris-dot { background: #c084fc; }
|
||||
.bao-dot { background: #60a5fa; }
|
||||
.other-dot { background: #fb923c; }
|
||||
.stale-dot { background: #f87171; }
|
||||
.section-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; padding: 1px 6px; border-radius: 6px; background: var(--glass-2); color: var(--tx-2); }
|
||||
.stale-count { background: rgba(244,63,94,.15); color: #fda4af; }
|
||||
.iris-empty { font-size: 11px; color: var(--tx-3); font-style: italic; padding: 8px; text-align: center; }
|
||||
.iris-task-row { padding: 6px 8px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.iris-task-row:last-child { border-bottom: none; }
|
||||
.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); }
|
||||
.iris-task-meta { font-size: 10px; color: var(--tx-3); white-space: nowrap; }
|
||||
.stale-row { background: rgba(244,63,94,.05); border-radius: 4px; }
|
||||
.stale-meta { color: #fda4af; font-weight: 600; }
|
||||
.iris-section-stale { border-color: rgba(244,63,94,.25); background: rgba(244,63,94,.04); }
|
||||
|
||||
.board-loading { display: flex; align-items: center; gap: 10px; padding: 40px; color: var(--tx-3); font-size: 13px; font-family: 'Manrope', sans-serif; }
|
||||
.spinner { width: 20px; height: 20px; border: 2px solid var(--line-2); border-top-color: var(--a-mid); border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.board-columns { display: flex; gap: 14px; flex: 1; overflow-x: auto; padding-bottom: 20px; min-height: calc(100vh - 200px); scrollbar-width: thin; scrollbar-color: rgba(124,108,255,.22) transparent; }
|
||||
.col { flex: 1; min-width: 240px; max-width: 320px; display: flex; flex-direction: column; background: var(--glass); border: 1px solid var(--line); border-radius: var(--r, 14px); padding: 12px; transition: border-color .2s, background .2s; backdrop-filter: blur(12px); }
|
||||
.col.drag-over { border-color: var(--a-mid); background: linear-gradient(160deg, rgba(124,108,255,.10), rgba(20,17,48,.55)); box-shadow: 0 0 0 1px rgba(124,108,255,.15); }
|
||||
|
||||
/* Permission Banner */
|
||||
.permission-banner { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r-sm, 10px); background: rgba(147,51,234,.08); border: 1px solid rgba(147,51,234,.2); color: #c084fc; font-size: 12px; font-family: 'Manrope', sans-serif; }
|
||||
.readonly-tag { font-weight: 400; color: var(--tx-3); font-size: 10px; text-transform: none; }
|
||||
select:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.col-blocked { max-width: 240px; }
|
||||
.col-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||
.col-icon { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
|
||||
@@ -794,8 +1025,11 @@ onUnmounted(() => {
|
||||
.card:active { cursor: grabbing; }
|
||||
.card.dragging { opacity: .4; cursor: grabbing; }
|
||||
.card-blocked { border-left: 3px solid var(--st-block); }
|
||||
.card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.card-agent { border-left: 2px solid rgba(124,108,255,.3); }
|
||||
.card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.prio-badge { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
|
||||
.agent-badge { font-size: 11px; line-height: 1; }
|
||||
.expected-badge { font-family: 'JetBrains Mono', monospace; font-size: 8px; font-weight: 600; padding: 1px 5px; border-radius: 4px; background: rgba(147,51,234,.08); color: #a78bfa; border: 1px solid rgba(147,51,234,.15); }
|
||||
.assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
|
||||
.assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; }
|
||||
.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; }
|
||||
@@ -803,6 +1037,8 @@ onUnmounted(() => {
|
||||
.card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-meta { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--tx-3); margin-top: 5px; font-variant-numeric: tabular-nums; }
|
||||
.empty-col, .detail-empty { display: flex; align-items: center; justify-content: center; padding: 24px 12px; font-size: 11px; color: var(--tx-3); font-style: italic; font-family: 'Manrope', sans-serif; }
|
||||
|
||||
/* Modal / Detail shared styles */
|
||||
.modal-overlay, .detail-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(5,4,16,.75); backdrop-filter: blur(16px); }
|
||||
.modal-card { width: 100%; max-width: 460px; background: linear-gradient(160deg, rgba(20,17,48,.85), rgba(14,12,32,.85)); border: 1px solid var(--line-2); border-radius: var(--r, 14px); padding: 24px; box-shadow: 0 0 0 1px rgba(124,108,255,.12), 0 20px 60px -12px rgba(0,0,0,.5); backdrop-filter: blur(12px); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
@@ -829,6 +1065,8 @@ onUnmounted(() => {
|
||||
.btn-submit { padding: 7px 18px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; box-shadow: var(--glow-purple); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.btn-submit:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
|
||||
.btn-submit:not(:disabled):hover { opacity: .85; transform: translateY(-1px); }
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel { width: min(1120px, calc(100vw - 48px)); height: min(88vh, 860px); display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(13,11,28,.97), rgba(10,9,24,.96)); border: 1px solid rgba(124,108,255,.18); border-radius: 22px; box-shadow: 0 28px 90px rgba(0,0,0,.45); overflow: hidden; }
|
||||
.detail-topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--line); }
|
||||
.detail-breadcrumb { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--tx-3); }
|
||||
@@ -846,6 +1084,8 @@ onUnmounted(() => {
|
||||
.detail-title-input { background: transparent; border: 1px solid transparent; border-radius: 12px; padding: 0; color: var(--tx); font-family: 'Space Grotesk', sans-serif; font-size: 31px; font-weight: 700; letter-spacing: -0.03em; outline: none; }
|
||||
.detail-meta-row { display: flex; flex-wrap: wrap; gap: 14px; color: var(--tx-3); font-size: 11.5px; }
|
||||
.detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.meta-agent-tag { color: #c084fc; }
|
||||
.meta-expected { color: #a78bfa; }
|
||||
.detail-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 16px; padding: 16px; }
|
||||
.detail-section-header, .sidebar-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--tx-2); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.detail-textarea { width: 100%; min-height: 180px; border-radius: 12px; border: 1px solid var(--line); background: rgba(10,9,24,.55); color: var(--tx); padding: 14px; font-size: 14px; line-height: 1.6; outline: none; box-sizing: border-box; }
|
||||
@@ -875,4 +1115,6 @@ onUnmounted(() => {
|
||||
.board-columns::-webkit-scrollbar-track { background: transparent; }
|
||||
@media (max-width: 1100px) { .detail-content { grid-template-columns: 1fr; } .detail-sidebar { border-left: none; border-top: 1px solid var(--line); } }
|
||||
@media (max-width: 860px) { .board-columns { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } .col { min-width: 260px; } .detail-panel { width: 100vw; height: 100vh; border-radius: 0; } .detail-grid { grid-template-columns: 1fr; } .detail-main, .detail-sidebar { padding: 18px; } .detail-title-input { font-size: 24px; } }
|
||||
@media (max-width: 900px) { .iris-panel-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) { .iris-panel-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,55 @@
|
||||
|
||||
> Letzte Aktualisierung: 2026-06-20
|
||||
|
||||
- 2026-06-20: **Bao-Status-Change + Content-Change-Benachrichtigung aktiviert.**
|
||||
- **Neue Autorisierungsregel (TaskStateHelper.CanChangeState):**
|
||||
- **Iris + Bao** dürfen jetzt Status ändern / verschieben.
|
||||
- Sub-Agents (`programmer`, `reviewer`, `architekt`) dürfen weiterhin NIEMALS Status ändern.
|
||||
- `nexus-system` bleibt als technischer Fallback erlaubt.
|
||||
- **Neue Methode `CanEditContent`:** Bestätigt, dass alle bekannten Caller (bao, iris, sub-agents, nexus-system) Inhalt bearbeiten dürfen.
|
||||
- **Benachrichtigungen bei Bao-Änderungen:**
|
||||
- Wenn Bao eine Task inhaltlich ändert (Titel, Detail, Priorität, AssignedTo, DueDate), erhält **Iris** eine `task_content_changed`-Notification mit Detailangabe, WAS geändert wurde.
|
||||
- Wenn Bao den Status ändert, erhält **Iris** eine `task_status_changed`-Notification.
|
||||
- Wenn Iris den Status ändert, erhält **Bao** eine `task_status_changed`-Notification.
|
||||
- **Verbesserte Activity-Einträge:** Zeigen jetzt detailliert, was sich geändert hat (statt nur "Task updated").
|
||||
- **Geänderte Fehlermeldungen:** DashboardController und TasksController zeigen jetzt "nur Iris und Bao" statt "nur Iris".
|
||||
- **Frontend:** `canChangeState`-Computed prüft jetzt `authStore.isIris || authStore.isBao`. Permission-Banner, State-Dropdown-Readonly-Tag und Tooltips aktualisiert. Neuer `isBao`-Getter im Auth-Store.
|
||||
- **Tests:** `CanChangeState_Bao_CannotChangeAnyTask` → `CanChangeState_Bao_CanChangeAnyTask`. Neue Tests für `CanEditContent`.
|
||||
- Geänderte Dateien: Backend `Entities.cs`, `TaskService.cs`, `DashboardController.cs`, `TasksController.cs`;
|
||||
Frontend `TaskBoardView.vue`, `auth.ts`; Tests `TaskBoardTests.cs`; Docs `changelog.md`.
|
||||
|
||||
- 2026-06-20: **Agent-Task-Workflow implementiert.**
|
||||
- **Neue Felder in WorkTask-Entity:** `IsAgentTask` (bool, Index) und `ExpectedFrom` (string? MaxLength 60, Index).
|
||||
Agent-Tasks sind als solche im Board erkennbar (🤖 Badge) und unterliegen einem strikt von Iris geführten Statusfluss.
|
||||
- **Status-Change-Autorisierung:** `TaskStateHelper.CanChangeState()` prüft jetzt strikt:
|
||||
- **Nur `iris`** darf Status ändern oder Karten verschieben.
|
||||
- Sub-Agents (`programmer`, `reviewer`, `architekt`) dürfen **niemals** Status ändern.
|
||||
- `bao` darf Tasks inhaltlich bearbeiten, aber **keinen** Status ändern.
|
||||
- Der technische Fallback `nexus-system` darf Status nur für interne Systempfade wie automatische Reset-/Cron-Operationen ändern.
|
||||
Caller wird via `X-Agent-Id`-Header oder JWT-Claim aufgelöst; HTTP-Fallback ist **nicht** mehr `bao`, sondern leer und wird für Statusänderungen abgewiesen.
|
||||
- **Neue API-Endpunkte:**
|
||||
- `POST /api/dashboard/tasks/agent` – Agent-Task anlegen (mit `expectedFrom`).
|
||||
- `GET /api/dashboard/tasks/agent-waiting` – Offene Agent-Tasks nach Erwartung.
|
||||
- `GET /api/dashboard/tasks/agent-overview?staleHours=2` – Komplette Iris-Übersicht:
|
||||
`waitingForBao`, `waitingForIris`, `waitingForOthers`, `staleTasks`.
|
||||
- **Neue Service-Methoden:** `CreateAgentTaskAsync`, `GetWaitingTasksAsync`, `GetAgentWorkflowOverviewAsync`.
|
||||
- **DashboardController-Sicherheit:** `UpdateTaskStatus` und `MoveTask` prüfen jetzt via
|
||||
`ResolveCallerAgent()` + `TaskStateHelper.CanChangeState()`. Bei Verstoß: **403** mit klarer Iris-only-Fehlermeldung.
|
||||
- **Frontend:**
|
||||
- TaskBoardView: Agent-Task-Badge (🤖), ExpectedFrom-Label (⏳), Stale-Banner,
|
||||
kollabierbares Iris-Overview-Panel („Iris – Worauf warte ich?“) mit 4 Sektionen:
|
||||
Warte auf Iris / Bao / Andere / Stale Tasks.
|
||||
- Für Nicht-Iris: Permission-Banner, kein wirksames Drag&Drop, Status-Dropdown deaktiviert.
|
||||
- tasks.ts-Store: `fetchAgentOverview()`, `createAgentTask()`, Getter für Iris-Ansicht.
|
||||
- Detail-Panel: zeigt Agent-Task-Status und ExpectedFrom im Snapshot.
|
||||
- **Tests:** TaskStateHelper-Coverage erweitert um `CanChangeState`.
|
||||
- **Dokumentation:** Changelog aktualisiert.
|
||||
- Geänderte Dateien: siehe Backend `Entities.cs`, `TaskService.cs`, `ITaskService.cs`, `DashboardController.cs`,
|
||||
`Dashboard.cs` (Models), `NexusDbContext.cs`; Frontend `tasks.ts`, `TaskBoardView.vue`.
|
||||
|
||||
- 2026-06-20: Nexus-Auth-Persistenz live verifiziert.
|
||||
|
||||
- 2026-06-20: Nexus-Auth-Persistenz live verifiziert. Owner-Passwort in der produktiven Postgres-DB geprüft, Stack vollständig neu gestartet und anschließend `postgres`, `api` und `web` per `docker:cli compose up -d --force-recreate` neu erstellt, ohne das DB-Volume zu löschen. Ergebnis: `/health/live` blieb healthy, der Passwort-Hash für `vmbao62@hotmail.de` blieb vor und nach Restart/Recreate identisch. Wichtiges Learning: temporäre Passwörter oder Auth-Fixes niemals an Bao weitergeben, bevor der echte Live-Login oder mindestens der persistierte DB-Hash auf dem Zielstack verifiziert ist.
|
||||
- 2026-06-20: Task Board um klickbare Linear-inspirierte Detailansicht erweitert: Board-Karten öffnen jetzt ein strukturiertes Side/Overlay-Detailpanel mit editierbarem Titel, Beschreibung, Status, Priorität, Zuständigkeit und Fälligkeitsdatum sowie geladener Aktivität und Unteraufgaben. `frontend/src/views/TaskBoardView.vue` und `frontend/src/stores/tasks.ts` angepasst. Verifiziert mit `COREPACK_HOME=$PWD/.corepack-home PNPM_HOME=$PWD/.pnpm-home pnpm build`.
|
||||
- 2026-06-19: Task-Board-Doku-Drift behoben: Header-Kommentar in TaskBoardView.vue von "4 columns" auf "6 columns" (Offen, InBearbeitung, Delegiert, Review, Blockiert, Erledigt) korrigiert. tasks.ts-Store-Kopfkommentar um delegated ergänzt.
|
||||
- 2026-06-19: Veralteter TODO.md-Import entfernt: `ImportFromIrisTodoAsync` in TaskService.cs, ITaskService.cs und der import-from-iris-todo-API-Endpoint in DashboardController.cs gelöscht. ImportResultDto aus Models/Dashboard.cs entfernt. TODO.md ist abgeschafft, Task Board alleinige Quelle.
|
||||
|
||||
Reference in New Issue
Block a user