From 81af81fb6ff52f719824ec559d07390d172b3320 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 11 Jun 2026 15:51:47 +0200 Subject: [PATCH] feat(dashboard): task system with DB persistence, CRUD endpoints, frontend API integration --- backend/Controllers/DashboardController.cs | 156 +++++++++++- backend/Data/Entities.cs | 4 + ...0611154800_AddTaskDetailFields.Designer.cs | 240 ++++++++++++++++++ .../20260611154800_AddTaskDetailFields.cs | 81 ++++++ .../Migrations/NexusDbContextModelSnapshot.cs | 20 ++ backend/Data/NexusDbContext.cs | 10 +- backend/Models/Dashboard.cs | 34 +++ frontend/src/composables/useDashboardData.ts | 62 ++++- 8 files changed, 599 insertions(+), 8 deletions(-) create mode 100644 backend/Data/Migrations/20260611154800_AddTaskDetailFields.Designer.cs create mode 100644 backend/Data/Migrations/20260611154800_AddTaskDetailFields.cs diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index c30be2a..14b7eb1 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -1,12 +1,18 @@ using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Data; using Nexus.Api.Models; +using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/dashboard")] -public class DashboardController(OpenClawGatewayClient gateway, ILogger logger) +public class DashboardController( + OpenClawGatewayClient gateway, + ITaskRepository taskRepo, + IActivityRepository activityRepo, + ILogger logger) : ControllerBase { /// @@ -211,8 +217,156 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger + /// Returns all non-done tasks (status != 'Done'), ordered by creation date descending. + /// + [HttpGet("tasks")] + public async Task> GetTasks(CancellationToken ct) + { + try + { + var tasks = await taskRepo.GetAllAsync(ct); + return tasks + .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(t => t.CreatedAt) + .Select(MapToDto) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard tasks fetch failed"); + return new List(); + } + } + + /// + /// Creates a new task and logs an activity event. + /// + [HttpPost("tasks")] + public async Task> CreateTask( + [FromBody] CreateDashboardTaskRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Title)) + return BadRequest(new { error = "Title is required." }); + + var task = new WorkTask + { + Title = request.Title.Trim(), + Detail = request.Detail?.Trim(), + Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(), + Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), + AssignedTo = request.AssignedTo?.Trim(), + }; + + await taskRepo.AddAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent + { + Type = "task", + Message = $"Task \"{task.Title}\" created ({task.Source})" + }, ct); + + return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); + } + + /// + /// Updates an existing task (title, detail, source, priority, assignedTo). + /// + [HttpPut("tasks/{id:guid}")] + public async Task> UpdateTask( + Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) + return NotFound(new { error = "Task not found." }); + + if (!string.IsNullOrWhiteSpace(request.Title)) + task.Title = request.Title.Trim(); + if (request.Detail is not null) + task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim(); + if (!string.IsNullOrWhiteSpace(request.Source)) + task.Source = request.Source.Trim(); + if (!string.IsNullOrWhiteSpace(request.Priority)) + task.Priority = request.Priority.Trim(); + if (request.AssignedTo is not null) + task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim(); + + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent + { + Type = "task", + Message = $"Task \"{task.Title}\" updated" + }, ct); + + return Ok(MapToDto(task)); + } + + /// + /// Deletes a task (only if status is 'Done' or 'Backlog'). + /// + [HttpDelete("tasks/{id:guid}")] + public async Task DeleteTask(Guid id, CancellationToken ct) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) + return NotFound(new { error = "Task not found." }); + + if (!TaskStateHelper.IsDoneOrBacklog(task.State)) + return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }); + + await activityRepo.AddAsync(new ActivityEvent + { + Type = "task", + Message = $"Task \"{task.Title}\" deleted" + }, ct); + await taskRepo.DeleteAsync(task, ct); + + return NoContent(); + } + + /// + /// Changes the status of a task. + /// + [HttpPatch("tasks/{id:guid}/status")] + public async Task> UpdateTaskStatus( + Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) + { + if (!TaskStateHelper.IsValidState(request.Status)) + return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }); + + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) + return NotFound(new { error = "Task not found." }); + + var canonicalState = TaskStateHelper.AllStates.First(s => + s.Equals(request.Status, StringComparison.OrdinalIgnoreCase)); + task.State = canonicalState; + + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent + { + Type = "task", + Message = $"Task \"{task.Title}\" → {canonicalState}" + }, ct); + + return Ok(MapToDto(task)); + } + // ========== Helpers ========== + 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 + ); + private static DateTimeOffset ParseTimestamp(string timestamp) { if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) diff --git a/backend/Data/Entities.cs b/backend/Data/Entities.cs index 4bdcaad..7ce0c02 100644 --- a/backend/Data/Entities.cs +++ b/backend/Data/Entities.cs @@ -77,9 +77,13 @@ public sealed class WorkTask { public Guid Id { get; init; } = Guid.NewGuid(); public required string Title { get; set; } + public string? Detail { get; set; } public string State { get; set; } = "Backlog"; public string Priority { get; set; } = "Normal"; + public string Source { get; set; } = "bao"; + public string? AssignedTo { get; set; } public Guid? ProjectId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } diff --git a/backend/Data/Migrations/20260611154800_AddTaskDetailFields.Designer.cs b/backend/Data/Migrations/20260611154800_AddTaskDetailFields.Designer.cs new file mode 100644 index 0000000..0288293 --- /dev/null +++ b/backend/Data/Migrations/20260611154800_AddTaskDetailFields.Designer.cs @@ -0,0 +1,240 @@ +// +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("20260611154800_AddTaskDetailFields")] + partial class AddTaskDetailFields + { + /// + 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("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + 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("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("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.NexusUser", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Data/Migrations/20260611154800_AddTaskDetailFields.cs b/backend/Data/Migrations/20260611154800_AddTaskDetailFields.cs new file mode 100644 index 0000000..01d074b --- /dev/null +++ b/backend/Data/Migrations/20260611154800_AddTaskDetailFields.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nexus.Api.Migrations +{ + /// + public partial class AddTaskDetailFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AssignedTo", + table: "Tasks", + type: "character varying(60)", + maxLength: 60, + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Tasks", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "NOW()"); + + migrationBuilder.AddColumn( + name: "Detail", + table: "Tasks", + type: "character varying(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "Source", + table: "Tasks", + type: "character varying(60)", + maxLength: 60, + nullable: false, + defaultValue: "bao"); + + migrationBuilder.CreateIndex( + name: "IX_Tasks_AssignedTo", + table: "Tasks", + column: "AssignedTo"); + + migrationBuilder.CreateIndex( + name: "IX_Tasks_Source", + table: "Tasks", + column: "Source"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tasks_AssignedTo", + table: "Tasks"); + + migrationBuilder.DropIndex( + name: "IX_Tasks_Source", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "AssignedTo", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "Detail", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "Source", + table: "Tasks"); + } + } +} diff --git a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs index 7823a93..bbff781 100644 --- a/backend/Data/Migrations/NexusDbContextModelSnapshot.cs +++ b/backend/Data/Migrations/NexusDbContextModelSnapshot.cs @@ -172,6 +172,17 @@ namespace Nexus.Api.Migrations .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("Priority") .IsRequired() .HasColumnType("text"); @@ -179,6 +190,11 @@ namespace Nexus.Api.Migrations b.Property("ProjectId") .HasColumnType("uuid"); + b.Property("Source") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + b.Property("State") .IsRequired() .HasColumnType("text"); @@ -193,6 +209,10 @@ namespace Nexus.Api.Migrations b.HasKey("Id"); + b.HasIndex("AssignedTo"); + + b.HasIndex("Source"); + b.ToTable("Tasks"); }); diff --git a/backend/Data/NexusDbContext.cs b/backend/Data/NexusDbContext.cs index b9b9787..e4e7a32 100644 --- a/backend/Data/NexusDbContext.cs +++ b/backend/Data/NexusDbContext.cs @@ -13,7 +13,15 @@ public sealed class NexusDbContext(DbContextOptions options) : D protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().Property(x => x.Name).HasMaxLength(160); - modelBuilder.Entity().Property(x => x.Title).HasMaxLength(240); + modelBuilder.Entity(entity => + { + entity.Property(x => x.Title).HasMaxLength(240); + entity.Property(x => x.Detail).HasMaxLength(2000); + entity.Property(x => x.Source).HasMaxLength(60); + entity.Property(x => x.AssignedTo).HasMaxLength(60); + entity.HasIndex(x => x.Source); + entity.HasIndex(x => x.AssignedTo); + }); modelBuilder.Entity().Property(x => x.Message).HasMaxLength(1000); modelBuilder.Entity().HasIndex(u => u.NormalizedEmail).IsUnique(); modelBuilder.Entity().HasIndex(r => r.TokenHash).IsUnique(); diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 560da02..2b16484 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -62,3 +62,37 @@ public sealed record ModelOption( string Name, string Provider ); + +// ── Dashboard Task DTOs ── + +public sealed record DashboardTaskDto( + Guid Id, + string Title, + string? Detail, + string Source, + string State, + string Priority, + string? AssignedTo, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); + +public sealed record CreateDashboardTaskRequest( + string Title, + string? Detail, + string? Source, + string? Priority, + string? AssignedTo +); + +public sealed record UpdateDashboardTaskRequest( + string? Title, + string? Detail, + string? Source, + string? Priority, + string? AssignedTo +); + +public sealed record UpdateDashboardTaskStatusRequest( + string Status +); diff --git a/frontend/src/composables/useDashboardData.ts b/frontend/src/composables/useDashboardData.ts index e69d7e3..8af0fd0 100644 --- a/frontend/src/composables/useDashboardData.ts +++ b/frontend/src/composables/useDashboardData.ts @@ -103,6 +103,18 @@ interface DashboardQueueItem { status: string } +interface DashboardTaskResponse { + id: string + title: string + detail: string | null + source: string + state: string + priority: string + assignedTo: string | null + createdAt: string + updatedAt: string +} + // ── Agent Catalog (static enrichment) ── const AGENT_CATALOG: Record> = { @@ -244,12 +256,8 @@ const busySince = ref(0) // Operations Feed const feedEntries = ref([]) -// Open Tasks (mock only – no API endpoint) -const openTasks = ref([ - { id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' }, - { id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' }, - { id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' }, -]) +// Open Tasks (fetched from API) +const openTasks = ref([]) // Queue const queue = ref([]) @@ -342,6 +350,45 @@ async function fetchQueue(): Promise { } } +async function fetchTasks(): Promise { + try { + const res = await apiFetch('/api/dashboard/tasks') + if (!res.ok) return + const data: DashboardTaskResponse[] = await res.json() + openTasks.value = data.map(mapTaskResponse) + } catch { + // API unreachable – keep current values + } +} + +function mapTaskResponse(t: DashboardTaskResponse): OpenTask { + const source: OpenTask['source'] = t.source === 'iris' ? 'iris' : 'bao' + // Format createdAt as relative time string (like "22:30") + const created = new Date(t.createdAt) + const now = new Date() + const diffMs = now.getTime() - created.getTime() + const diffMins = Math.floor(diffMs / 60000) + + let createdAt: string + if (diffMins < 1) { + createdAt = 'just now' + } else if (diffMins < 60) { + createdAt = `${diffMins}m` + } else if (diffMins < 1440) { + createdAt = `${Math.floor(diffMins / 60)}h` + } else { + createdAt = created.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' }) + } + + return { + id: t.id, + title: t.title, + detail: t.detail ?? '', + source, + createdAt, + } +} + // ── Chat Send ── async function sendChatMessage(text: string): Promise { @@ -454,6 +501,7 @@ function startPolling(): void { fetchOperations() fetchChatMessages() fetchQueue() + fetchTasks() // Polling intervals intervals.push(setInterval(fetchStatus, 5000)) @@ -461,6 +509,7 @@ function startPolling(): void { intervals.push(setInterval(fetchOperations, 10000)) intervals.push(setInterval(fetchChatMessages, 3000)) intervals.push(setInterval(fetchQueue, 10000)) + intervals.push(setInterval(fetchTasks, 15000)) } function stopPolling(): void { @@ -512,5 +561,6 @@ export function useDashboardData() { fetchOperations, fetchChatMessages, fetchQueue, + fetchTasks, } }