From 83e072bc2789863f96aae3900a8039b22ba1a44a Mon Sep 17 00:00:00 2001 From: DevOps Date: Sat, 20 Jun 2026 18:42:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Bao/Iris-Statusrechte=20+=20Bao?= =?UTF-8?q?=E2=86=92Iris-Notifications=20+=20Agent-Workflow-=C3=9Cbersicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend-tests/TaskBoardTests.cs | 100 ++++++ backend/Controllers/AuthController.cs | 37 +- backend/Controllers/DashboardController.cs | 114 +++++-- backend/Controllers/TasksController.cs | 29 ++ backend/DTOs/Responses.cs | 1 + backend/Data/Entities.cs | 50 +++ ...60620174200_AddAgentTaskFields.Designer.cs | 322 ++++++++++++++++++ .../20260620174200_AddAgentTaskFields.cs | 58 ++++ .../Migrations/NexusDbContextModelSnapshot.cs | 11 + backend/Data/NexusDbContext.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 33 ++ backend/Models/Dashboard.cs | 30 +- backend/RateLimiting/LoginAttemptTracker.cs | 84 +++++ backend/Services/ITaskService.cs | 8 +- backend/Services/TaskService.cs | 264 +++++++++++++- frontend/src/stores/auth.ts | 89 ++++- frontend/src/stores/tasks.ts | 76 +++++ frontend/src/views/LoginView.vue | 132 ++++++- frontend/src/views/SettingsView.vue | 2 +- frontend/src/views/TaskBoardView.vue | 278 ++++++++++++++- phases/changelog.md | 49 +++ 21 files changed, 1690 insertions(+), 80 deletions(-) create mode 100644 backend/Data/Migrations/20260620174200_AddAgentTaskFields.Designer.cs create mode 100644 backend/Data/Migrations/20260620174200_AddAgentTaskFields.cs create mode 100644 backend/RateLimiting/LoginAttemptTracker.cs diff --git a/backend-tests/TaskBoardTests.cs b/backend-tests/TaskBoardTests.cs index 375639a..43eae58 100644 --- a/backend-tests/TaskBoardTests.cs +++ b/backend-tests/TaskBoardTests.cs @@ -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(" ")); + } } diff --git a/backend/Controllers/AuthController.cs b/backend/Controllers/AuthController.cs index d27f793..205d9e7 100644 --- a/backend/Controllers/AuthController.cs +++ b/backend/Controllers/AuthController.cs @@ -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 { ["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)); } diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 4654a24..b6975ac 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -164,18 +164,18 @@ public class DashboardController( public async Task> 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 GetBoard(CancellationToken ct) + public async Task 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( }; } + /// + /// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim. + /// Falls back to empty string (which authorization helpers reject accordingly). + /// + private string ResolveCallerAgent() + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is null) return ""; + + var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(agentHeader)) + return agentHeader.Trim().ToLowerInvariant(); + + var user = httpContext.User; + var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return nameClaim?.ToLowerInvariant() ?? ""; + } + // ── New Endpoints: Reset Stale, Children, Activity ── [HttpPost("tasks/reset-stale")] @@ -277,7 +295,59 @@ public class DashboardController( return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev); } + // ── Agent Workflow Endpoints (Iris Overview) ── + + /// + /// Returns agent-tasks that are still open and waiting for input. + /// Iris uses this to see who she is waiting for. + /// + [HttpGet("tasks/agent-waiting")] + public async Task>> GetAgentWaitingTasks(CancellationToken ct) + { + var waiting = await taskService.GetWaitingTasksAsync(ct); + return Ok(waiting.Select(MapToDto).ToList()); + } + + /// + /// Returns a complete agent-workflow overview grouped by expected respondent + /// + stale detection. This is the main Iris dashboard data. + /// + [HttpGet("tasks/agent-overview")] + public async Task> GetAgentOverview( + CancellationToken ct, [FromQuery] int staleHours = 2) + { + var threshold = TimeSpan.FromHours(Math.Max(1, staleHours)); + return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct)); + } + + /// + /// Creates an agent-task: a task that is tracked as originating from the agent workflow. + /// Sub-agents (programmer, reviewer) can only CREATE, not move state. + /// + [HttpPost("tasks/agent")] + public async Task> CreateAgentTask( + [FromBody] CreateAgentTaskRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Title)) + return BadRequest(new { error = "Title is required." }); + + try + { + var task = await taskService.CreateAgentTaskAsync( + request.Title, request.Detail, request.Source ?? "iris", + request.Priority, request.AssignedTo, request.ExpectedFrom, + request.ParentTaskId, ct); + + return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, - t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt); + t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, + t.IsAgentTask, t.ExpectedFrom); } diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs index 953cab7..4c3ec3d 100644 --- a/backend/Controllers/TasksController.cs +++ b/backend/Controllers/TasksController.cs @@ -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) ── + + /// + /// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert). + /// Wird vom Iris Autonomous Worker genutzt. + /// + [AllowAnonymous] + [HttpGet("board")] + public async Task GetBoard(CancellationToken ct) + => Results.Ok(await taskService.GetBoardAsync(ct)); + + /// + /// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog. + /// Wird vom Iris Autonomous Worker genutzt. + /// + [AllowAnonymous] + [HttpPost("reset-stale")] + public async Task ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct) + { + var count = await taskService.ResetStaleAsync(request.StaleHours, ct); + return Results.Ok(new ResetStaleResponse(count)); + } } diff --git a/backend/DTOs/Responses.cs b/backend/DTOs/Responses.cs index 235dab6..357bdab 100644 --- a/backend/DTOs/Responses.cs +++ b/backend/DTOs/Responses.cs @@ -12,3 +12,4 @@ public sealed record IncidentInfoDto( string? Title, DateTimeOffset? Since ); + diff --git a/backend/Data/Entities.cs b/backend/Data/Entities.cs index e5c8941..ecc4c06 100644 --- a/backend/Data/Entities.cs +++ b/backend/Data/Entities.cs @@ -83,6 +83,43 @@ public static class TaskStateHelper string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase) || string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase); + /// + /// 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. + /// + public static bool CanChangeState(string? callerAgent, WorkTask task) + { + var caller = callerAgent?.Trim().ToLowerInvariant() ?? ""; + + // Sub-agents must never move state + var subAgents = new HashSet { "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"; + } + + /// + /// 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. + /// + public static bool CanEditContent(string? callerAgent) + { + var caller = callerAgent?.Trim().ToLowerInvariant() ?? ""; + if (string.IsNullOrWhiteSpace(caller)) return false; + return true; + } + /// Group key for board responses (lowercased English state). 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; } + + /// + /// 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. + /// + public bool IsAgentTask { get; set; } = false; + + /// + /// Which agent/user is expected to respond next. + /// Helps Iris see who she is waiting for. + /// + public string? ExpectedFrom { get; set; } + public Guid? ParentTaskId { get; set; } public WorkTask? ParentTask { get; set; } public ICollection ChildTasks { get; set; } = new List(); diff --git a/backend/Data/Migrations/20260620174200_AddAgentTaskFields.Designer.cs b/backend/Data/Migrations/20260620174200_AddAgentTaskFields.Designer.cs new file mode 100644 index 0000000..b1eb194 --- /dev/null +++ b/backend/Data/Migrations/20260620174200_AddAgentTaskFields.Designer.cs @@ -0,0 +1,322 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ForUser") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Progress") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedTo") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedFrom") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsAgentTask") + .HasColumnType("boolean"); + + b.Property("ParentTaskId") + .HasColumnType("uuid"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("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 + } + } +} diff --git a/backend/Data/Migrations/20260620174200_AddAgentTaskFields.cs b/backend/Data/Migrations/20260620174200_AddAgentTaskFields.cs new file mode 100644 index 0000000..a8c4a7b --- /dev/null +++ b/backend/Data/Migrations/20260620174200_AddAgentTaskFields.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddAgentTaskFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAgentTask", + table: "Tasks", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + 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"); + } + + /// + 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"); + } + } +} diff --git a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs index c8379c8..9cdd5ba 100644 --- a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs +++ b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs @@ -234,6 +234,13 @@ namespace Nexus.Api.Migrations b.Property("DueDate") .HasColumnType("timestamp with time zone"); + b.Property("ExpectedFrom") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsAgentTask") + .HasColumnType("boolean"); + b.Property("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"); diff --git a/backend/Data/NexusDbContext.cs b/backend/Data/NexusDbContext.cs index 6f6aab0..50b865e 100644 --- a/backend/Data/NexusDbContext.cs +++ b/backend/Data/NexusDbContext.cs @@ -20,8 +20,11 @@ public sealed class NexusDbContext(DbContextOptions 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) diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs index ecdde80..773f7a4 100644 --- a/backend/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -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(); services.AddTransient(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 430b975..3945268 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -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 Offen, List InProgress, List Delegated, @@ -147,6 +159,20 @@ public sealed record PostActivityRequest( string? Type = null ); +// ── Agent Workflow DTOs ── + +/// +/// 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. +/// +public sealed record AgentWorkflowOverview( + List WaitingForBao, + List WaitingForIris, + List WaitingForOthers, + List StaleTasks, + TimeSpan StaleThreshold +); + // ── Notification DTOs ── public sealed record NotificationDto( diff --git a/backend/RateLimiting/LoginAttemptTracker.cs b/backend/RateLimiting/LoginAttemptTracker.cs new file mode 100644 index 0000000..9d13e39 --- /dev/null +++ b/backend/RateLimiting/LoginAttemptTracker.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; + +namespace Nexus.Api.RateLimiting; + +/// +/// 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. +/// +public sealed class LoginAttemptTracker +{ + private const int MaxAttempts = 5; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + // IP → (count, windowStartTicks) + private static readonly ConcurrentDictionary _store = new(); + + /// + /// Registers a failed attempt for the given IP. + /// Returns remaining attempts (0 = locked out until reset). + /// + 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); + } + + /// + /// Returns the remaining attempts for the given IP without recording. + /// + 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; + } + + /// + /// Returns the number of seconds until the rate-limit window resets, + /// or 0 if the window has already expired / no attempts recorded. + /// + 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); + } + + /// + /// Resets attempt count for the given IP (e.g. on success). + /// + public void Reset(string ip) + { + _store.TryRemove(ip, out _); + } +} diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 7779a42..1fc8097 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -23,15 +23,21 @@ public interface ITaskService // Dashboard-facing task operations Task> GetOpenAsync(CancellationToken ct = default); Task CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default); + Task CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default); Task UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default); Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task CyclePriorityAsync(Guid id, CancellationToken ct = default); // Task Board - Task GetBoardAsync(CancellationToken ct = default); + Task GetBoardAsync(CancellationToken ct = default); Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); + Task ResetStaleAsync(int staleHours, CancellationToken ct = default); Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); + + // Agent Workflow Overview + Task> GetWaitingTasksAsync(CancellationToken ct = default); + Task GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default); } diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index aec14f5..0ce6f6f 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -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 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(); + + 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(); } + /// + /// 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. + /// + public async Task> 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(); + } + + /// + /// 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. + /// + public async Task 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 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 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 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(); + + 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 GetBoardAsync(CancellationToken ct = default) + public async Task GetBoardAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); var offen = new List(); @@ -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 ResetStaleAsync(int staleHours, CancellationToken ct = default) + { + var normalizedHours = Math.Max(1, staleHours); + return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct); + } + public async Task 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); /// /// Validates AssignedTo — only recognized agent values are accepted. @@ -365,16 +545,40 @@ public sealed class TaskService( } /// - /// 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). + /// + 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() ?? ""; + } + + /// + /// 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 /// 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); + } + } } } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 07ec9b6..4a6f6fc 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -13,6 +13,12 @@ interface AuthPayload { user: AuthUser } +interface LoginErrorInfo { + message: string + remaining: number + retryAfterSeconds: number +} + let refreshInFlight: Promise | 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 { 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 + 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 + } +} diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 873ea43..95e57de 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -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 diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 58cbf04..8a0f0d3 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -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 | 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.' + } } } @@ -71,6 +113,7 @@ async function submit() { maxlength="120" placeholder="name@noveria.net" class="field-input" + :disabled="auth.isRateLimited" /> @@ -90,6 +133,7 @@ async function submit() { maxlength="200" placeholder="••••••••••" class="field-input" + :disabled="auth.isRateLimited" /> @@ -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; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 074b71e..ca72873 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -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 { diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 40e237d..9f54489 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -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[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(null) const selectedTaskId = ref(null) const detailSaving = ref(false) @@ -87,6 +96,10 @@ async function handleCreateTask() { const draggedTaskId = ref(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 = { + '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,10 +354,93 @@ onUnmounted(() => {

Aufgaben

Task Board — Übersicht aller Arbeitspakete

- +
+ + +
+ + + +
+ + Nur-Lesen-Status. Du kannst Aufgaben inhaltlich bearbeiten (Titel, Beschreibung, Priorität, Zuständigkeit), aber das Verschieben/Status-Ändern ist nur Iris und Bao vorbehalten. +
+ + +
+ + {{ staleCount }} Task(s) sind stale (InBearbeitung/Delegiert > 2h ohne Update). + +
+ + +
+
+

Iris — Worauf warte ich?

+ +
+
Lade Übersicht…
+
+
+
+ + Warte auf Iris {{ waitingForIrisCount }} +
+
Keine Tasks
+
+ {{ t.title }} + {{ stateLabel(t.state) }} +
+
+ +
+
+ + Warte auf Bao {{ waitingForBaoCount }} +
+
Keine Tasks
+
+ {{ t.title }} + {{ stateLabel(t.state) }} +
+
+ +
+
+ + Warte auf andere {{ taskStore.waitingForOthersTasks.length }} +
+
Keine Tasks
+
+ {{ t.title }} + {{ expectedFromLabel(t.expectedFrom) }} +
+
+ +
+
+ + Stale Tasks {{ staleCount }} +
+
Keine stale Tasks
+
+ {{ t.title }} + {{ hoursSince(t.updatedAt) }}h offen +
+
+
@@ -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(() => { {{ priorityLabel(task.priority) }} + 🤖 + + ⏳ {{ task.expectedFrom }} + { :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(() => { {{ priorityLabel(task.priority) }} + 🤖 + + ⏳ {{ task.expectedFrom }} + { :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(() => { {{ priorityLabel(task.priority) }} + 🤖 + + ⏳ {{ task.expectedFrom }} + { :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(() => { {{ priorityLabel(task.priority) }} + 🤖 + + ⏳ {{ task.expectedFrom }} + { 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(() => { {{ priorityLabel(task.priority) }} + 🤖 + + ⏳ {{ task.expectedFrom }} + {
@@ -634,6 +810,8 @@ onUnmounted(() => {
#{{ selectedTask.id.slice(0, 8) }} + 🤖 Agent-Task + ⏳ Erwartet: {{ selectedTask.expectedFrom }} Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }} Erstellt {{ formatDate(selectedTask.createdAt) }}
@@ -686,8 +864,13 @@ onUnmounted(() => { @@ -745,9 +936,9 @@ onUnmounted(() => {
- @@ -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; } } diff --git a/phases/changelog.md b/phases/changelog.md index dc35248..8c21779 100644 --- a/phases/changelog.md +++ b/phases/changelog.md @@ -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.