feat: Phase 2 — Delegated State, Auth, Review-Gate, Notifications, Zombie-Reset
CI - Build & Test / Backend (.NET) (push) Successful in 37s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 24s
CI - Build & Test / Security Check (push) Successful in 4s

This commit is contained in:
2026-06-18 23:47:41 +02:00
parent 12998170e3
commit dcc8450c62
32 changed files with 1758 additions and 38 deletions
+75 -6
View File
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
@@ -5,9 +7,13 @@ using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard")]
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
public class DashboardController(
IDashboardService dashboardService,
ITaskService taskService,
IHttpContextAccessor httpContextAccessor) : ControllerBase
{
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
@@ -115,9 +121,16 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
try
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("tasks/{id:guid}")]
@@ -125,7 +138,7 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var result = await taskService.UpdateDashboardTaskAsync(
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
@@ -149,6 +162,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
// Bao review gate: Check if moving OUT of Review
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))
{
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." });
}
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch
{
@@ -171,6 +198,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
if (string.IsNullOrWhiteSpace(request.State))
return BadRequest(new { error = "State is required." });
// Bao review gate: Check if moving OUT of Review
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))
{
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." });
}
var result = await taskService.MoveTaskAsync(id, request.State, ct);
return result.Outcome switch
{
@@ -180,6 +221,33 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
};
}
// ── New Endpoints: Reset Stale, Children, Activity ──
[HttpPost("tasks/reset-stale")]
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
[FromBody] ResetStaleRequest request, CancellationToken ct)
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
}
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var children = await taskService.GetChildTasksAsync(id, ct);
return Ok(children.Select(MapToDto).ToList());
}
[HttpGet("tasks/{id:guid}/activity")]
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
{
var events = await taskService.GetTaskActivityAsync(id, ct);
return Ok(events);
}
// ── Import ──
[HttpPost("tasks/import-from-iris-todo")]
public async Task<ActionResult<ImportResultDto>> ImportFromIrisTodo(
[FromQuery] bool delete = false, CancellationToken ct = default)
@@ -189,5 +257,6 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt);
}
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Integrations;
@@ -7,6 +8,7 @@ namespace Nexus.Api.Controllers;
[ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{
[AllowAnonymous]
[HttpGet("/health/live")]
public IResult Live()
{
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard/notifications")]
public class NotificationsController(INotificationService notificationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
}
[HttpGet("unread-count")]
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
return Ok(new UnreadCountDto(count));
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+24 -1
View File
@@ -18,6 +18,7 @@ public enum TaskState
{
Backlog,
InProgress,
Delegated,
Blocked,
Done,
Review
@@ -29,6 +30,7 @@ public static class TaskStateHelper
{
[TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress",
[TaskState.Delegated] = "Delegated",
[TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done",
[TaskState.Review] = "Review"
@@ -38,6 +40,7 @@ public static class TaskStateHelper
{
["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress,
["Delegated"] = TaskState.Delegated,
["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done,
["Review"] = TaskState.Review
@@ -48,13 +51,14 @@ public static class TaskStateHelper
{
["Backlog"] = "Offen",
["In progress"] = "In Bearbeitung",
["Delegated"] = "Delegiert",
["Review"] = "Review",
["Blocked"] = "Blockiert",
["Done"] = "Erledigt"
};
/// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"];
public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"];
/// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state];
@@ -88,6 +92,7 @@ public static class TaskStateHelper
{
"backlog" => "offen",
"in progress" => "inProgress",
"delegated" => "delegated",
"review" => "review",
"blocked" => "blocked",
"done" => "done",
@@ -104,6 +109,7 @@ public static class TaskStateHelper
{
"offen" => "Backlog",
"inprogress" => "In progress",
"delegated" => "Delegated",
"review" => "Review",
"blocked" => "Blocked",
"done" => "Done",
@@ -131,15 +137,32 @@ public sealed class WorkTask
public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; }
public Guid? ParentTaskId { get; set; }
public WorkTask? ParentTask { get; set; }
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
public Guid? ProjectId { get; set; }
public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class Notification
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
public string? Message { get; set; } // Detailtext
public required string ForUser { get; set; } // "bao" oder "iris"
public Guid? TaskId { get; set; } // Verknüpfte Task
public bool IsRead { get; set; } = false;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ActivityEvent
{
public long Id { get; init; }
public required string Type { get; set; }
public required string Message { get; set; }
public Guid? TaskId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,311 @@
// <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("20260618214335_AddNotifications")]
partial class AddNotifications
{
/// <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<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("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,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
table: "Notifications",
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskParentChild : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentTaskId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDueDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DueDate",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DueDate",
table: "Tasks");
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddActivityTaskReference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "TaskId",
table: "Activity",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Activity_TaskId",
table: "Activity",
column: "TaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Activity_TaskId",
table: "Activity");
migrationBuilder.DropColumn(
name: "TaskId",
table: "Activity");
}
}
}
@@ -0,0 +1,270 @@
// <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("20260618233003_AddDelegatedState")]
partial class AddDelegatedState
{
/// <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.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<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("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,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddDelegatedState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delegated state is a pure code change to the TaskState enum and
// TaskStateHelper. No schema change required since the State column
// is already a free-form string column.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// No schema to revert.
}
}
}
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
.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");
});
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
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")
@@ -183,6 +231,12 @@ namespace Nexus.Api.Migrations
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
@@ -211,6 +265,8 @@ namespace Nexus.Api.Migrations
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
@@ -227,10 +283,25 @@ namespace Nexus.Api.Migrations
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
}
}
+19 -1
View File
@@ -6,6 +6,7 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
@@ -21,8 +22,25 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
entity.Property(x => x.AssignedTo).HasMaxLength(60);
entity.HasIndex(x => x.Source);
entity.HasIndex(x => x.AssignedTo);
entity.HasOne(x => x.ParentTask)
.WithMany(x => x.ChildTasks)
.HasForeignKey(x => x.ParentTaskId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Notification>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Message).HasMaxLength(1000);
entity.Property(x => x.Type).HasMaxLength(60);
entity.Property(x => x.ForUser).HasMaxLength(60);
entity.HasIndex(x => new { x.ForUser, x.IsRead, x.CreatedAt });
});
modelBuilder.Entity<ActivityEvent>(entity =>
{
entity.Property(x => x.Message).HasMaxLength(1000);
entity.HasIndex(x => x.TaskId);
});
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
@@ -68,6 +68,7 @@ public static class ApplicationBuilderExtensions
{
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseApiKeyAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
@@ -170,6 +170,7 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
@@ -182,6 +183,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
return services;
+39
View File
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace Nexus.Api.Middleware;
/// <summary>
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
/// On match, sets a ClaimsPrincipal with role "Service".
/// On mismatch or absent header, passes through to next middleware (JWT auth).
/// </summary>
public sealed class ApiKeyMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
var apiKey = configuration["NexusApiKey"];
if (!string.IsNullOrWhiteSpace(apiKey) &&
context.Request.Headers.TryGetValue("X-Nexus-Api-Key", out var providedKey) &&
string.Equals(apiKey, providedKey, StringComparison.Ordinal))
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "service"),
new Claim(ClaimTypes.Name, "ApiService"),
new Claim(ClaimTypes.Role, "Service")
};
var identity = new ClaimsIdentity(claims, "ApiKey");
context.User = new ClaimsPrincipal(identity);
}
await next(context);
}
}
public static class ApiKeyMiddlewareExtensions
{
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder)
=> builder.UseMiddleware<ApiKeyMiddleware>();
}
+24 -2
View File
@@ -86,6 +86,8 @@ public sealed record DashboardTaskDto(
string State,
string Priority,
string? AssignedTo,
Guid? ParentTaskId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);
@@ -95,7 +97,8 @@ public sealed record CreateDashboardTaskRequest(
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
string? AssignedTo,
Guid? ParentTaskId = null
);
public sealed record UpdateDashboardTaskRequest(
@@ -103,7 +106,8 @@ public sealed record UpdateDashboardTaskRequest(
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
string? AssignedTo,
DateTimeOffset? DueDate = null
);
public sealed record UpdateDashboardTaskStatusRequest(
@@ -120,6 +124,7 @@ public sealed record AgentActivityEntry(
public sealed record TaskBoardResponse(
List<DashboardTaskDto> Offen,
List<DashboardTaskDto> InProgress,
List<DashboardTaskDto> Delegated,
List<DashboardTaskDto> Review,
List<DashboardTaskDto> Blocked,
List<DashboardTaskDto> Done
@@ -132,3 +137,20 @@ public sealed record MoveTaskRequest(
public sealed record ImportResultDto(
int Imported
);
public sealed record ResetStaleRequest(
int StaleHours = 2
);
public sealed record ResetStaleResponse(
int ResetCount
);
// ── Notification DTOs ──
public sealed record NotificationDto(
Guid Id, string Type, string Title, string? Message,
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
);
public sealed record UnreadCountDto(int Count);
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Nexus.Api.Data;
namespace Nexus.Api;
public class NexusDbContextFactory : IDesignTimeDbContextFactory<NexusDbContext>
{
public NexusDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<NexusDbContext>();
var connectionString = args.Length > 0
? args[0]
: Environment.GetEnvironmentVariable("ConnectionStrings__Nexus")
?? "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus";
optionsBuilder.UseNpgsql(connectionString);
return new NexusDbContext(optionsBuilder.Options);
}
}
+1
View File
@@ -30,6 +30,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
await db.SaveChangesAsync(ct);
}
+13
View File
@@ -0,0 +1,13 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface INotificationService
{
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
}
+5 -2
View File
@@ -22,8 +22,8 @@ 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, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, 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);
@@ -31,5 +31,8 @@ public interface ITaskService
// Task Board
Task<TaskBoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, 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);
Task<int> ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default);
}
+61
View File
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification
{
Type = type,
Title = title,
Message = message,
ForUser = forUser.ToLowerInvariant(),
TaskId = taskId
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
return notification;
}
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var query = db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant());
if (unreadOnly)
query = query.Where(n => !n.IsRead);
return await query
.OrderByDescending(n => n.CreatedAt)
.Take(limit)
.ToListAsync(ct);
}
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
{
var notification = await db.Notifications.FindAsync([id], ct);
if (notification is null) return false;
notification.IsRead = true;
await db.SaveChangesAsync(ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var count = await db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
return count;
}
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
{
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
}
+150 -22
View File
@@ -8,8 +8,12 @@ namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ITaskService
IActivityRepository activityRepo,
INotificationService notificationService) : ITaskService
{
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt"];
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
@@ -28,7 +32,7 @@ public sealed class TaskService(
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
return task;
}
@@ -42,7 +46,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -56,7 +60,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -70,7 +74,8 @@ public sealed class TaskService(
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -87,7 +92,7 @@ public sealed class TaskService(
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" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -99,7 +104,7 @@ public sealed class TaskService(
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
@@ -115,23 +120,51 @@ public sealed class TaskService(
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
// Validate parent task exists if specified
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
if (parent is null)
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
}
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = ValidateAssignedTo(assignedTo)
AssignedTo = ValidateAssignedTo(assignedTo),
ParentTaskId = parentTaskId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
var message = $"Task \"{task.Title}\" created ({task.Source})";
if (parentTaskId.HasValue)
message += $" [child of {parentTaskId.Value}]";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
// Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_assigned",
$"Neue Aufgabe: {task.Title}",
detail,
"bao",
task.Id,
ct);
}
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
Guid id, string? title, string? detail, string? source,
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
@@ -141,9 +174,10 @@ public sealed class TaskService(
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;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -158,7 +192,8 @@ public sealed class TaskService(
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -169,7 +204,7 @@ public sealed class TaskService(
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -187,7 +222,7 @@ public sealed class TaskService(
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -198,6 +233,7 @@ public sealed class TaskService(
var all = await taskRepo.GetAllAsync(ct);
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var delegated = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
@@ -211,6 +247,8 @@ public sealed class TaskService(
offen.Add(dto); break;
case "in progress":
inProgress.Add(dto); break;
case "delegated":
delegated.Add(dto); break;
case "review":
review.Add(dto); break;
case "blocked":
@@ -222,9 +260,32 @@ public sealed class TaskService(
}
}
return new TaskBoardResponse(offen, inProgress, review, blocked, done);
// Priority sort within each group: High > Medium > Low, then by CreatedAt ascending
offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
delegated.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new TaskBoardResponse(offen, inProgress, delegated, review, blocked, done);
}
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
// Resolve canonical state: accept board group keys or canonical strings
@@ -245,10 +306,50 @@ public sealed class TaskService(
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
var prevState = task.State;
task.State = "Backlog";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
TaskId = task.Id
}, ct);
}
return staleTasks.Count;
}
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{
var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList();
}
public async Task<int> ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default)
{
var todoPath = "/mnt/workspace-iris/TODO.md";
@@ -318,17 +419,44 @@ 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.CreatedAt, t.UpdatedAt);
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt);
/// <summary>
/// Validates AssignedTo — only "bao", "iris", or null are accepted.
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
/// </summary>
private static string? ValidateAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var lower = assignedTo.Trim().ToLowerInvariant();
if (lower is "bao" or "iris") return lower;
return null;
return ValidAssignees.Contains(lower) ? lower : null;
}
}
/// <summary>
/// Creates status-change notifications when a task moves to Review or Blocked.
/// </summary>
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
{
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"}",
"bao",
task.Id,
ct);
}
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_blocked",
$"Aufgabe blockiert: {task.Title}",
"Die Task konnte nicht abgeschlossen werden und wurde blockiert.",
"iris",
task.Id,
ct);
}
}
}
BIN
View File
Binary file not shown.