diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 770ddf8..199c8c4 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -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 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> 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> ResetStale( + [FromBody] ResetStaleRequest request, CancellationToken ct) + { + var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours)); + var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct); + return Ok(new ResetStaleResponse(count)); + } + + [HttpGet("tasks/{id:guid}/children")] + public async Task>> GetChildren(Guid id, CancellationToken ct) + { + var children = await taskService.GetChildTasksAsync(id, ct); + return Ok(children.Select(MapToDto).ToList()); + } + + [HttpGet("tasks/{id:guid}/activity")] + public async Task>> 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> 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); } diff --git a/backend/Controllers/HealthController.cs b/backend/Controllers/HealthController.cs index 2575a06..1afcd4e 100644 --- a/backend/Controllers/HealthController.cs +++ b/backend/Controllers/HealthController.cs @@ -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() { diff --git a/backend/Controllers/NotificationsController.cs b/backend/Controllers/NotificationsController.cs new file mode 100644 index 0000000..8ea9995 --- /dev/null +++ b/backend/Controllers/NotificationsController.cs @@ -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>> 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> 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 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 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); +} diff --git a/backend/Data/Entities.cs b/backend/Data/Entities.cs index 1892dc1..e5c8941 100644 --- a/backend/Data/Entities.cs +++ b/backend/Data/Entities.cs @@ -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" }; /// Valid task-state string values for API validation. - public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"]; + public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"]; /// Convert a TaskState enum to its API string representation. 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 ChildTasks { get; set; } = new List(); 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; } diff --git a/backend/Data/Migrations/20260618214335_AddNotifications.Designer.cs b/backend/Data/Migrations/20260618214335_AddNotifications.Designer.cs new file mode 100644 index 0000000..63991df --- /dev/null +++ b/backend/Data/Migrations/20260618214335_AddNotifications.Designer.cs @@ -0,0 +1,311 @@ +// +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 + { + /// + 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("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("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/20260618214335_AddNotifications.cs b/backend/Data/Migrations/20260618214335_AddNotifications.cs new file mode 100644 index 0000000..b68f736 --- /dev/null +++ b/backend/Data/Migrations/20260618214335_AddNotifications.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + Title = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Message = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + ForUser = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + TaskId = table.Column(type: "uuid", nullable: true), + IsRead = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + } + } +} diff --git a/backend/Data/Migrations/20260618233000_AddTaskParentChild.cs b/backend/Data/Migrations/20260618233000_AddTaskParentChild.cs new file mode 100644 index 0000000..4c20f17 --- /dev/null +++ b/backend/Data/Migrations/20260618233000_AddTaskParentChild.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddTaskParentChild : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/backend/Data/Migrations/20260618233001_AddTaskDueDate.cs b/backend/Data/Migrations/20260618233001_AddTaskDueDate.cs new file mode 100644 index 0000000..fe37f4f --- /dev/null +++ b/backend/Data/Migrations/20260618233001_AddTaskDueDate.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddTaskDueDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DueDate", + table: "Tasks", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DueDate", + table: "Tasks"); + } + } +} diff --git a/backend/Data/Migrations/20260618233002_AddActivityTaskReference.cs b/backend/Data/Migrations/20260618233002_AddActivityTaskReference.cs new file mode 100644 index 0000000..914cc68 --- /dev/null +++ b/backend/Data/Migrations/20260618233002_AddActivityTaskReference.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddActivityTaskReference : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TaskId", + table: "Activity", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Activity_TaskId", + table: "Activity", + column: "TaskId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Activity_TaskId", + table: "Activity"); + + migrationBuilder.DropColumn( + name: "TaskId", + table: "Activity"); + } + } +} diff --git a/backend/Data/Migrations/20260618233003_AddDelegatedState.Designer.cs b/backend/Data/Migrations/20260618233003_AddDelegatedState.Designer.cs new file mode 100644 index 0000000..59ec633 --- /dev/null +++ b/backend/Data/Migrations/20260618233003_AddDelegatedState.Designer.cs @@ -0,0 +1,270 @@ +// +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 + { + /// + 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.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("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("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/20260618233003_AddDelegatedState.cs b/backend/Data/Migrations/20260618233003_AddDelegatedState.cs new file mode 100644 index 0000000..1606356 --- /dev/null +++ b/backend/Data/Migrations/20260618233003_AddDelegatedState.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddDelegatedState : Migration + { + /// + 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. + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // No schema to revert. + } + } +} diff --git a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs index bbff781..c8379c8 100644 --- a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs +++ b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs @@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations .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"); }); @@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations 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") @@ -183,6 +231,12 @@ namespace Nexus.Api.Migrations .HasMaxLength(2000) .HasColumnType("character varying(2000)"); + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentTaskId") + .HasColumnType("uuid"); + b.Property("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 } } diff --git a/backend/Data/NexusDbContext.cs b/backend/Data/NexusDbContext.cs index e4e7a32..6f6aab0 100644 --- a/backend/Data/NexusDbContext.cs +++ b/backend/Data/NexusDbContext.cs @@ -6,6 +6,7 @@ public sealed class NexusDbContext(DbContextOptions options) : D { public DbSet Projects => Set(); public DbSet Tasks => Set(); + public DbSet Notifications => Set(); public DbSet Activity => Set(); public DbSet Users => Set(); public DbSet RefreshTokens => Set(); @@ -21,8 +22,25 @@ public sealed class NexusDbContext(DbContextOptions 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(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(entity => + { + entity.Property(x => x.Message).HasMaxLength(1000); + entity.HasIndex(x => x.TaskId); }); - modelBuilder.Entity().Property(x => x.Message).HasMaxLength(1000); modelBuilder.Entity().HasIndex(u => u.NormalizedEmail).IsUnique(); modelBuilder.Entity().HasIndex(r => r.TokenHash).IsUnique(); modelBuilder.Entity().HasIndex(r => new { r.UserId, r.FamilyId }); diff --git a/backend/Extensions/ApplicationBuilderExtensions.cs b/backend/Extensions/ApplicationBuilderExtensions.cs index 5139dee..7e370a4 100644 --- a/backend/Extensions/ApplicationBuilderExtensions.cs +++ b/backend/Extensions/ApplicationBuilderExtensions.cs @@ -68,6 +68,7 @@ public static class ApplicationBuilderExtensions { app.UseForwardedHeaders(); app.UseRateLimiter(); + app.UseApiKeyAuthentication(); app.UseAuthentication(); app.UseAuthorization(); app.UseSecurityHeaders(); diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs index 4b7d0d9..ecdde80 100644 --- a/backend/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -170,6 +170,7 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services) { + services.AddHttpContextAccessor(); services.AddTransient(); services.AddScoped(); services.AddScoped(); @@ -182,6 +183,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/backend/Middleware/ApiKeyMiddleware.cs b/backend/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..5ab1c6f --- /dev/null +++ b/backend/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; + +namespace Nexus.Api.Middleware; + +/// +/// 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). +/// +public sealed class ApiKeyMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var configuration = context.RequestServices.GetRequiredService(); + 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(); +} diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 7025071..03654ae 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -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 Offen, List InProgress, + List Delegated, List Review, List Blocked, List 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); diff --git a/backend/NexusDbContextFactory.cs b/backend/NexusDbContextFactory.cs new file mode 100644 index 0000000..21cb1b5 --- /dev/null +++ b/backend/NexusDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Nexus.Api.Data; + +namespace Nexus.Api; + +public class NexusDbContextFactory : IDesignTimeDbContextFactory +{ + public NexusDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + 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); + } +} diff --git a/backend/Repositories/TaskRepository.cs b/backend/Repositories/TaskRepository.cs index c086d5c..9641530 100644 --- a/backend/Repositories/TaskRepository.cs +++ b/backend/Repositories/TaskRepository.cs @@ -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); } diff --git a/backend/Services/INotificationService.cs b/backend/Services/INotificationService.cs new file mode 100644 index 0000000..71157a1 --- /dev/null +++ b/backend/Services/INotificationService.cs @@ -0,0 +1,13 @@ +using Nexus.Api.Data; +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +public interface INotificationService +{ + Task CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default); + Task> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default); + Task MarkAsReadAsync(Guid id, CancellationToken ct = default); + Task MarkAllAsReadAsync(string forUser, CancellationToken ct = default); + Task GetUnreadCountAsync(string forUser, CancellationToken ct = default); +} diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 7768f66..c6c46fd 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -22,8 +22,8 @@ public interface ITaskService // Dashboard-facing task operations Task> GetOpenAsync(CancellationToken ct = default); - Task CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default); - Task UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default); + Task CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, 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); @@ -31,5 +31,8 @@ public interface ITaskService // Task Board Task GetBoardAsync(CancellationToken ct = default); Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); + Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); + Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); + Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default); } diff --git a/backend/Services/NotificationService.cs b/backend/Services/NotificationService.cs new file mode 100644 index 0000000..14652bf --- /dev/null +++ b/backend/Services/NotificationService.cs @@ -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 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> 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 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 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 GetUnreadCountAsync(string forUser, CancellationToken ct = default) + { + return await db.Notifications + .CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct); + } +} diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 2056caf..376a9cd 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -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 ValidAssignees = + ["bao", "iris", "programmer", "reviewer", "architekt"]; + public async Task> 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 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 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(); var inProgress = new List(); + var delegated = new List(); var review = new List(); var blocked = new List(); var done = new List(); @@ -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 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 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> 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> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default) + { + var all = await activityRepo.GetRecentAsync(100, ct); + return all.Where(e => e.TaskId == taskId).ToList(); + } + public async Task 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); /// - /// Validates AssignedTo — only "bao", "iris", or null are accepted. + /// Validates AssignedTo — only recognized agent values are accepted. /// Returns null for invalid values. /// 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; } -} + + /// + /// Creates status-change notifications when a task moves to Review or Blocked. + /// + 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); + } + } +} diff --git a/backend/core.3048 b/backend/core.3048 new file mode 100644 index 0000000..ddb4405 Binary files /dev/null and b/backend/core.3048 differ diff --git a/compose.yaml b/compose.yaml index 8410b70..7cb5f1a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -58,6 +58,7 @@ services: Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Admin__ResetToken: ${Admin__ResetToken:-} + NexusApiKey: ${NEXUS_API_KEY:-} extra_hosts: - host.docker.internal:host-gateway depends_on: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ab651f5..68e14e6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -23,7 +23,7 @@ const activeView = computed(() => { const routePaths: Record = { Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security', Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar', - Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings', + Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings', } const navigate = (label: string) => { @@ -32,7 +32,7 @@ const navigate = (label: string) => { } const mobileNavOpen = ref(false) -const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board'].includes(activeView.value)) +const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'Notifications'].includes(activeView.value)) onMounted(() => { if (auth.isAuthenticated) store.refresh() diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 09fee6d..60e97ab 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -1,13 +1,14 @@ + + + + diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 5d1a803..8e4e236 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -218,6 +218,46 @@ onUnmounted(() => { + +
+
+ Delegiert + {{ taskStore.board.delegated.length }} +
+
+
+
+ + {{ priorityLabel(task.priority) }} + + + {{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} + +
+
{{ task.title }}
+
{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}
+
+
Keine delegierten Aufgaben
+
+
+