From e445332ac1ff6a0a1bd3ceb797a2a6552930234e Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:04 +0200 Subject: [PATCH 1/9] feat: Add lock status fields to TaskDto for UI feedback --- .../Server/Controllers/TaskLockController.cs | 166 ------------------ server/Server/Models/DTOs/Task/TaskDto.cs | 4 + 2 files changed, 4 insertions(+), 166 deletions(-) delete mode 100644 server/Server/Controllers/TaskLockController.cs diff --git a/server/Server/Controllers/TaskLockController.cs b/server/Server/Controllers/TaskLockController.cs deleted file mode 100644 index 778e3269..00000000 --- a/server/Server/Controllers/TaskLockController.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using server.Services.Interfaces; -using System.Security.Claims; - -namespace server.Controllers; - -/// -/// Controller for managing task locks. -/// -[ApiController] -[Route("api/[controller]")] -[Authorize] -public class TaskLockController : ControllerBase -{ - private readonly ITaskLockingService _taskLockingService; - private readonly ILogger _logger; - - public TaskLockController(ITaskLockingService taskLockingService, ILogger logger) - { - _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Attempts to lock a task for the current user. - /// - /// The ID of the task to lock. - /// Duration of the lock in minutes (default: 30). - /// Success status of the lock attempt. - [HttpPost("{taskId}/lock")] - public async Task LockTaskAsync(int taskId, [FromQuery] int lockDurationMinutes = 30) - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("User ID not found in claims"); - } - - _logger.LogInformation("User {UserId} attempting to lock task {TaskId} for {Duration} minutes", userId, taskId, lockDurationMinutes); - - var success = await _taskLockingService.TryLockTaskAsync(taskId, userId, lockDurationMinutes); - - if (success) - { - _logger.LogInformation("Successfully locked task {TaskId} for user {UserId}", taskId, userId); - return Ok(new { success = true, message = "Task locked successfully" }); - } - else - { - _logger.LogInformation("Failed to lock task {TaskId} for user {UserId} - already locked by another user", taskId, userId); - return BadRequest(new { success = false, message = "Task is already locked by another user" }); - } - } - - /// - /// Releases a task lock owned by the current user. - /// - /// The ID of the task to unlock. - /// Success status of the unlock attempt. - [HttpPost("{taskId}/unlock")] - public async Task UnlockTaskAsync(int taskId) - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("User ID not found in claims"); - } - - _logger.LogInformation("User {UserId} attempting to unlock task {TaskId}", userId, taskId); - - var success = await _taskLockingService.ReleaseLockAsync(taskId, userId); - - if (success) - { - _logger.LogInformation("Successfully unlocked task {TaskId} for user {UserId}", taskId, userId); - return Ok(new { success = true, message = "Task unlocked successfully" }); - } - else - { - _logger.LogInformation("Failed to unlock task {TaskId} for user {UserId} - user doesn't own the lock", taskId, userId); - return BadRequest(new { success = false, message = "You don't own the lock for this task" }); - } - } - - /// - /// Extends the lock duration for a task owned by the current user. - /// - /// The ID of the task to extend the lock for. - /// Additional minutes to extend the lock (default: 30). - /// Success status of the lock extension. - [HttpPost("{taskId}/extend-lock")] - public async Task ExtendLockAsync(int taskId, [FromQuery] int extensionMinutes = 30) - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("User ID not found in claims"); - } - - _logger.LogInformation("User {UserId} attempting to extend lock for task {TaskId} by {Extension} minutes", userId, taskId, extensionMinutes); - - var success = await _taskLockingService.ExtendLockAsync(taskId, userId, extensionMinutes); - - if (success) - { - _logger.LogInformation("Successfully extended lock for task {TaskId} for user {UserId}", taskId, userId); - return Ok(new { success = true, message = "Task lock extended successfully" }); - } - else - { - _logger.LogInformation("Failed to extend lock for task {TaskId} for user {UserId} - user doesn't own an active lock", taskId, userId); - return BadRequest(new { success = false, message = "You don't own an active lock for this task" }); - } - } - - /// - /// Checks if a task is currently locked. - /// - /// The ID of the task to check. - /// Lock status of the task. - [HttpGet("{taskId}/is-locked")] - public async Task IsTaskLockedAsync(int taskId) - { - var isLocked = await _taskLockingService.IsTaskLockedAsync(taskId); - return Ok(new { isLocked }); - } - - /// - /// Gets all tasks currently locked by the current user. - /// - /// List of tasks locked by the user. - [HttpGet("my-locks")] - public async Task GetMyLockedTasksAsync() - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("User ID not found in claims"); - } - - var lockedTasks = await _taskLockingService.GetTasksLockedByUserAsync(userId); - return Ok(lockedTasks); - } - - /// - /// Releases all locks held by the current user (useful for logout/disconnect scenarios). - /// - /// Number of locks that were released. - [HttpPost("release-all")] - public async Task ReleaseAllLocksAsync() - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("User ID not found in claims"); - } - - _logger.LogInformation("User {UserId} releasing all locks", userId); - - var releasedCount = await _taskLockingService.ReleaseAllUserLocksAsync(userId); - - _logger.LogInformation("Released {Count} locks for user {UserId}", releasedCount, userId); - return Ok(new { releasedCount, message = $"Released {releasedCount} locks" }); - } -} \ No newline at end of file diff --git a/server/Server/Models/DTOs/Task/TaskDto.cs b/server/Server/Models/DTOs/Task/TaskDto.cs index 62a599fa..fd82d366 100644 --- a/server/Server/Models/DTOs/Task/TaskDto.cs +++ b/server/Server/Models/DTOs/Task/TaskDto.cs @@ -23,4 +23,8 @@ public record class TaskDto public int WorkflowStageId { get; init; } public string? AssignedToEmail { get; init; } public string? LastWorkedOnByEmail { get; init; } + + // Lock status fields for UI feedback + public bool IsLocked { get; set; } + public bool IsLockedByCurrentUser { get; set; } } From 03514a150147bff1bfcf33cd01fb63cc43eb7161 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:13 +0200 Subject: [PATCH 2/9] feat: Add background service for expired lock cleanup --- server/Server/Extensions/ServiceCollectionExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/Server/Extensions/ServiceCollectionExtensions.cs b/server/Server/Extensions/ServiceCollectionExtensions.cs index 861bb190..7226c8bd 100644 --- a/server/Server/Extensions/ServiceCollectionExtensions.cs +++ b/server/Server/Extensions/ServiceCollectionExtensions.cs @@ -213,6 +213,9 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); + // Add background services + services.AddHostedService(); + return services; } From 81fc5717c41f67b040ffc53bf6d9077ce1808ba4 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:19 +0200 Subject: [PATCH 3/9] feat: Enhance GetTaskByIdAsync to support automatic lock acquisition and user validation --- .../Services/Interfaces/ITaskService.cs | 6 +- server/Server/Services/TaskService.cs | 85 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/server/Server/Services/Interfaces/ITaskService.cs b/server/Server/Services/Interfaces/ITaskService.cs index 7fa5f39d..ce9dcde9 100644 --- a/server/Server/Services/Interfaces/ITaskService.cs +++ b/server/Server/Services/Interfaces/ITaskService.cs @@ -40,11 +40,13 @@ Task> GetTasksForUserAsync( ); /// - /// Retrieves a task by its ID. + /// Retrieves a task by its ID with optional automatic lock acquisition. /// /// The ID of the task to retrieve. + /// The ID of the user requesting the task (for lock operations). + /// Whether to automatically acquire a lock for the task. /// A task that represents the asynchronous operation, containing the TaskDto if found, otherwise null. - Task GetTaskByIdAsync(int taskId); + Task GetTaskByIdAsync(int taskId, string? userId = null, bool acquireLock = false); /// /// Gets all tasks for a specific workflow stage, properly filtered by the stage's input data source. diff --git a/server/Server/Services/TaskService.cs b/server/Server/Services/TaskService.cs index 993e9fa8..466424b9 100644 --- a/server/Server/Services/TaskService.cs +++ b/server/Server/Services/TaskService.cs @@ -22,6 +22,7 @@ public class TaskService : ITaskService private readonly IWorkflowStageRepository _workflowStageRepository; private readonly UserManager _userManager; private readonly IProjectMembershipService _projectMembershipService; + private readonly ITaskLockingService _taskLockingService; private readonly ILogger _logger; public TaskService( @@ -32,6 +33,7 @@ public TaskService( IWorkflowStageRepository workflowStageRepository, UserManager userManager, IProjectMembershipService projectMembershipService, + ITaskLockingService taskLockingService, ILogger logger) { _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); @@ -41,6 +43,7 @@ public TaskService( _workflowStageRepository = workflowStageRepository ?? throw new ArgumentNullException(nameof(workflowStageRepository)); _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); _projectMembershipService = projectMembershipService ?? throw new ArgumentNullException(nameof(projectMembershipService)); + _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -108,9 +111,9 @@ public async Task> GetTasksForUserAsync( }; } - public async Task GetTaskByIdAsync(int taskId) + public async Task GetTaskByIdAsync(int taskId, string? userId = null, bool acquireLock = false) { - _logger.LogInformation("Fetching task with ID: {TaskId}", taskId); + _logger.LogInformation("Fetching task with ID: {TaskId} for user: {UserId}, acquireLock: {AcquireLock}", taskId, userId, acquireLock); var task = await _taskRepository.GetByIdAsync(taskId); @@ -120,8 +123,36 @@ public async Task> GetTasksForUserAsync( return null; } + // Automatically acquire lock if user is provided and acquireLock is true + if (!string.IsNullOrEmpty(userId) && acquireLock) + { + _logger.LogInformation("Attempting to acquire lock for task {TaskId} for user {UserId}", taskId, userId); + var lockAcquired = await _taskLockingService.TryLockTaskAsync(taskId, userId); + + if (!lockAcquired) + { + _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - task is locked by another user", taskId, userId); + // Return the task but indicate it's locked by another user + var lockedTaskDto = MapToDto(task); + lockedTaskDto.IsLockedByCurrentUser = false; + lockedTaskDto.IsLocked = true; + return lockedTaskDto; + } + + _logger.LogInformation("Successfully acquired lock for task {TaskId} for user {UserId}", taskId, userId); + } + _logger.LogInformation("Successfully fetched task with ID: {TaskId}", taskId); - return MapToDto(task); + var taskDto = MapToDto(task); + + // Set lock status if user is provided + if (!string.IsNullOrEmpty(userId)) + { + taskDto.IsLockedByCurrentUser = await _taskLockingService.IsTaskLockedByUserAsync(taskId, userId); + taskDto.IsLocked = await _taskLockingService.IsTaskLockedAsync(taskId); + } + + return taskDto; } public async Task> GetTasksForWorkflowStageAsync( @@ -197,6 +228,12 @@ public async Task CreateTaskAsync(int projectId, CreateTaskDto createDt return null; } + // Validate lock ownership before allowing modifications + if (!string.IsNullOrEmpty(updatingUserId)) + { + await ValidateTaskLockOwnershipAsync(taskId, updatingUserId, "UPDATE"); + } + // Update the task properties if (updateDto.Priority.HasValue) { @@ -387,6 +424,9 @@ public async Task CreateTasksForWorkflowStageAsync(int projectId, int workf return null; } + // Validate lock ownership before allowing status changes + await ValidateTaskLockOwnershipAsync(taskId, userId, "CHANGE STATUS"); + var currentStatus = existingTask.Status; // Skip if already in target status @@ -519,5 +559,44 @@ private static TaskDto MapToDto(LaberisTask task) LastWorkedOnByEmail = task.LastWorkedOnByUser?.Email }; } + /// + /// Validates that the user owns the lock for the task before allowing modifications + /// + /// The task ID to validate lock for + /// The user attempting the modification + /// The operation being attempted (for logging) + /// True if user owns the lock, false otherwise + /// Thrown when user doesn't own the lock + private async Task ValidateTaskLockOwnershipAsync(int taskId, string userId, string operation) + { + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("Cannot validate lock ownership for task {TaskId} - no user ID provided", taskId); + return false; + } + + var isLockedByUser = await _taskLockingService.IsTaskLockedByUserAsync(taskId, userId); + + if (!isLockedByUser) + { + var isLocked = await _taskLockingService.IsTaskLockedAsync(taskId); + if (isLocked) + { + _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but task is locked by another user", + userId, operation, taskId); + throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - it is currently locked by another user."); + } + else + { + _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but doesn't own a lock", + userId, operation, taskId); + throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - you must acquire a lock first by navigating to the task."); + } + } + + _logger.LogDebug("Lock ownership validated for user {UserId} on task {TaskId} for {Operation}", userId, taskId, operation); + return true; + } + #endregion } \ No newline at end of file From 4cfefd54d523c5ff41f81bbfa71d67e8d90bf04d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:24 +0200 Subject: [PATCH 4/9] feat: Enhance task navigation by releasing current task lock before acquiring next or previous task lock --- .../Server/Services/TaskNavigationService.cs | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/server/Server/Services/TaskNavigationService.cs b/server/Server/Services/TaskNavigationService.cs index c02434b3..f9b3f505 100644 --- a/server/Server/Services/TaskNavigationService.cs +++ b/server/Server/Services/TaskNavigationService.cs @@ -65,17 +65,29 @@ public async Task GetNextTaskAsync(string userId, int current Message = hasNext ? null : "No more tasks available" }; - // Attempt to lock the next task if one is found + // Release lock on current task and attempt to lock the next task if one is found if (hasNext && nextTask != null) { - _logger.LogInformation("Attempting to lock next task {TaskId} for user {UserId}", nextTask.TaskId, userId); + _logger.LogInformation("Releasing lock for current task {CurrentTaskId} and attempting to lock next task {NextTaskId} for user {UserId}", + currentTaskId, nextTask.TaskId, userId); + + // Release current task lock + await _taskLockingService.ReleaseLockAsync(currentTaskId, userId); + + // Acquire lock for next task - this must succeed for navigation to be allowed var lockAcquired = await _taskLockingService.TryLockTaskAsync(nextTask.TaskId, userId); if (!lockAcquired) { - _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId}", nextTask.TaskId, userId); - // Note: We still return the navigation result even if locking fails - // The client should handle this by checking if the task is locked when they try to work on it + _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - navigation denied", nextTask.TaskId, userId); + return new TaskNavigationDto + { + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + HasNext = false, + HasPrevious = currentIndex > 0, + Message = "Cannot navigate to next task - it is currently locked by another user" + }; } } @@ -130,17 +142,29 @@ public async Task GetPreviousTaskAsync(string userId, int cur Message = hasPrevious ? null : "No previous tasks available" }; - // Attempt to lock the previous task if one is found + // Release lock on current task and attempt to lock the previous task if one is found if (hasPrevious && previousTask != null) { - _logger.LogInformation("Attempting to lock previous task {TaskId} for user {UserId}", previousTask.TaskId, userId); + _logger.LogInformation("Releasing lock for current task {CurrentTaskId} and attempting to lock previous task {PreviousTaskId} for user {UserId}", + currentTaskId, previousTask.TaskId, userId); + + // Release current task lock + await _taskLockingService.ReleaseLockAsync(currentTaskId, userId); + + // Acquire lock for previous task - this must succeed for navigation to be allowed var lockAcquired = await _taskLockingService.TryLockTaskAsync(previousTask.TaskId, userId); if (!lockAcquired) { - _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId}", previousTask.TaskId, userId); - // Note: We still return the navigation result even if locking fails - // The client should handle this by checking if the task is locked when they try to work on it + _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - navigation denied", previousTask.TaskId, userId); + return new TaskNavigationDto + { + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + HasNext = currentIndex < navigableTasks.Count - 1, + HasPrevious = false, + Message = "Cannot navigate to previous task - it is currently locked by another user" + }; } } From 6de818599f4b4513eb7ee4a40048f918bcbb0b0e Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:28 +0200 Subject: [PATCH 5/9] feat: Update GetTaskById to support automatic lock acquisition with optional autoAssign parameter --- server/Server/Controllers/TasksController.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/Server/Controllers/TasksController.cs b/server/Server/Controllers/TasksController.cs index 4f7285ac..b9df3f89 100644 --- a/server/Server/Controllers/TasksController.cs +++ b/server/Server/Controllers/TasksController.cs @@ -117,18 +117,21 @@ public async Task GetMyTasks( } /// - /// Gets a specific task by its unique ID, with optional auto-assignment to the requesting user. + /// Gets a specific task by its unique ID, with automatic lock acquisition if autoAssign=true. /// /// The ID of the task. + /// Whether to automatically acquire a lock for the task. /// The requested task. [HttpGet("{taskId:int}")] [ProducesResponseType(typeof(TaskDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetTaskById(int taskId) + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetTaskById(int taskId, [FromQuery] bool autoAssign = false) { try { - TaskDto? task = await _taskService.GetTaskByIdAsync(taskId); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + TaskDto? task = await _taskService.GetTaskByIdAsync(taskId, userId, autoAssign); if (task == null) { From 55345e0c2ef6774bcc2b2a4f7782dbc1ed9e7a23 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:31:32 +0200 Subject: [PATCH 6/9] feat: Integrate task locking service into task navigation and service tests for improved lock management --- .../Services/TaskNavigationServiceTests.cs | 18 +++++++++++ .../Server.Tests/Services/TaskServiceTests.cs | 3 ++ .../Services/TaskServiceUnifiedStatusTests.cs | 31 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/server/Server.Tests/Services/TaskNavigationServiceTests.cs b/server/Server.Tests/Services/TaskNavigationServiceTests.cs index fc16bdc2..f1643ce8 100644 --- a/server/Server.Tests/Services/TaskNavigationServiceTests.cs +++ b/server/Server.Tests/Services/TaskNavigationServiceTests.cs @@ -83,6 +83,12 @@ public async System.Threading.Tasks.Task GetNextTaskAsync_WithValidCurrentTask_R _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) .ReturnsAsync(tasks); + // Set up task locking service to simulate successful lock acquisition + _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) + .ReturnsAsync(true); + _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(2, userId, It.IsAny())) // Next task ID is 2 + .ReturnsAsync(true); + // Act var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId); @@ -188,6 +194,12 @@ public async System.Threading.Tasks.Task GetPreviousTaskAsync_WithValidCurrentTa _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) .ReturnsAsync(tasks); + // Set up task locking service to simulate successful lock acquisition + _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) + .ReturnsAsync(true); + _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(1, userId, It.IsAny())) // Previous task ID is 1 + .ReturnsAsync(true); + // Act var result = await _service.GetPreviousTaskAsync(userId, currentTaskId, workflowStageId); @@ -370,6 +382,12 @@ public async System.Threading.Tasks.Task GetNextTaskAsync_WithWorkflowStageFilte _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId20)) .ReturnsAsync(tasksStage20); + // Set up task locking service to simulate successful lock acquisition + _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) + .ReturnsAsync(true); + _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(2, userId, It.IsAny())) // Next task ID is 2 + .ReturnsAsync(true); + // Act - Get next task from stage 10 only var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId10); diff --git a/server/Server.Tests/Services/TaskServiceTests.cs b/server/Server.Tests/Services/TaskServiceTests.cs index d5a3eaec..c1d8b281 100644 --- a/server/Server.Tests/Services/TaskServiceTests.cs +++ b/server/Server.Tests/Services/TaskServiceTests.cs @@ -19,6 +19,7 @@ public class TaskServiceTests private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; + private readonly Mock _mockTaskLockingService; private readonly Mock> _mockLogger; private readonly TaskService _taskService; @@ -32,6 +33,7 @@ public TaskServiceTests() _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); + _mockTaskLockingService = new Mock(); _mockLogger = new Mock>(); _taskService = new TaskService( @@ -42,6 +44,7 @@ public TaskServiceTests() _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, + _mockTaskLockingService.Object, _mockLogger.Object ); } diff --git a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs index 51d2ff52..43692c32 100644 --- a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs +++ b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs @@ -21,6 +21,7 @@ public class TaskServiceUnifiedStatusTests private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; + private readonly Mock _mockTaskLockingService; private readonly Mock> _mockLogger; private readonly TaskService _taskService; @@ -33,8 +34,11 @@ public TaskServiceUnifiedStatusTests() _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); + _mockTaskLockingService = new Mock(); _mockLogger = new Mock>(); + // Set up default mock behavior for task locking service + SetupTaskLockingServiceDefaults(); _taskService = new TaskService( _mockTaskRepository.Object, @@ -44,10 +48,37 @@ public TaskServiceUnifiedStatusTests() _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, + _mockTaskLockingService.Object, _mockLogger.Object ); } + /// + /// Sets up default mock behavior for TaskLockingService to simulate successful lock ownership + /// + private void SetupTaskLockingServiceDefaults() + { + // By default, assume the user owns the lock for any task + _mockTaskLockingService + .Setup(x => x.IsTaskLockedByUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // By default, assume tasks are not locked by other users + _mockTaskLockingService + .Setup(x => x.IsTaskLockedAsync(It.IsAny())) + .ReturnsAsync(false); + + // By default, assume lock acquisition succeeds + _mockTaskLockingService + .Setup(x => x.TryLockTaskAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // By default, assume lock release succeeds + _mockTaskLockingService + .Setup(x => x.ReleaseLockAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + } + [Fact] public async System.Threading.Tasks.Task ChangeTaskStatusAsync_TaskNotFound_ShouldReturnNull() { From df68d338a32f2faa599721639b5a96a2c933a282 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 18:20:46 +0200 Subject: [PATCH 7/9] ref: Remove locking and adapt assigning of the users as locking system --- server/Server/Controllers/AuthController.cs | 7 +- server/Server/Controllers/TasksController.cs | 156 +- .../Data/Configurations/TaskConfiguration.cs | 9 - ...153011_RemoveTaskLockingFields.Designer.cs | 1883 +++++++++++++++++ .../20250907153011_RemoveTaskLockingFields.cs | 71 + .../Laberis/LaberisDbContextModelSnapshot.cs | 21 - .../Extensions/ServiceCollectionExtensions.cs | 3 - server/Server/Models/DTOs/Task/TaskDto.cs | 6 +- .../Models/DTOs/Task/TaskNavigationDto.cs | 15 + server/Server/Models/Domain/Task.cs | 6 - server/Server/Program.cs | 2 - server/Server/Repositories/TaskRepository.cs | 27 +- .../Services/ExpiredLockCleanupService.cs | 42 - .../Interfaces/ITaskLockingService.cs | 70 - .../Services/Interfaces/ITaskService.cs | 17 + server/Server/Services/TaskLockingService.cs | 276 --- .../Server/Services/TaskNavigationService.cs | 251 ++- server/Server/Services/TaskService.cs | 144 +- 18 files changed, 2448 insertions(+), 558 deletions(-) create mode 100644 server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.Designer.cs create mode 100644 server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.cs delete mode 100644 server/Server/Services/ExpiredLockCleanupService.cs delete mode 100644 server/Server/Services/Interfaces/ITaskLockingService.cs delete mode 100644 server/Server/Services/TaskLockingService.cs diff --git a/server/Server/Controllers/AuthController.cs b/server/Server/Controllers/AuthController.cs index 538d1cbb..9e94bfbb 100644 --- a/server/Server/Controllers/AuthController.cs +++ b/server/Server/Controllers/AuthController.cs @@ -15,14 +15,12 @@ namespace server.Controllers; public class AuthController : ControllerBase { private readonly IAuthService _authManager; - private readonly ITaskLockingService _taskLockingService; private readonly ILogger _logger; private readonly IWebHostEnvironment _environment; - public AuthController(IAuthService authManager, ITaskLockingService taskLockingService, ILogger logger, IWebHostEnvironment environment) + public AuthController(IAuthService authManager, ILogger logger, IWebHostEnvironment environment) { _authManager = authManager ?? throw new ArgumentNullException(nameof(authManager)); - _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _environment = environment ?? throw new ArgumentNullException(nameof(environment)); } @@ -149,8 +147,7 @@ public async Task Logout() return Unauthorized(); } - // Release all task locks held by the user - await _taskLockingService.ReleaseAllUserLocksAsync(userId); + // Note: No need to release task locks as we now use assignment-based system await _authManager.RevokeRefreshTokenAsync(userId); diff --git a/server/Server/Controllers/TasksController.cs b/server/Server/Controllers/TasksController.cs index b9df3f89..d8649140 100644 --- a/server/Server/Controllers/TasksController.cs +++ b/server/Server/Controllers/TasksController.cs @@ -116,21 +116,77 @@ public async Task GetMyTasks( } } + /// + /// Gets all tasks assigned to the current user or unassigned tasks with optional filtering, sorting, and pagination. + /// + /// The field to filter on (e.g., "priority", "project_id", "current_workflow_stage_id", "is_completed"). + /// The value to filter by. + /// The field to sort by (e.g., "priority", "due_date", "created_at", "completed_at"). + /// Whether to sort in ascending order (true) or descending (false). + /// The page number for pagination (1-based). + /// The number of items per page. + /// A list of task DTOs. + /// Returns the list of task DTOs. + /// If the user is not authenticated. + /// If an unexpected error occurs. + [HttpGet("my-tasks-or-unassigned")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetMyTasksOrUnassigned( + [FromQuery] string? filterOn = null, + [FromQuery] string? filterQuery = null, + [FromQuery] string? sortBy = null, + [FromQuery] bool isAscending = true, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 25 + ) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized("User ID claim not found in token."); + } + + try + { + var tasks = await _taskService.GetTasksForUserOrUnassignedAsync( + userId, filterOn, filterQuery, sortBy, isAscending, pageNumber, pageSize); + return Ok(tasks); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching tasks for current user or unassigned tasks {UserId}.", userId); + return StatusCode( + StatusCodes.Status500InternalServerError, + "An unexpected error occurred. Please try again later." + ); + } + } + /// /// Gets a specific task by its unique ID, with automatic lock acquisition if autoAssign=true. + /// If the task is locked by another user and autoAssign=true, attempts to find and return the next available task. /// /// The ID of the task. /// Whether to automatically acquire a lock for the task. - /// The requested task. + /// The requested task or next available task if original is locked. [HttpGet("{taskId:int}")] [ProducesResponseType(typeof(TaskDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task GetTaskById(int taskId, [FromQuery] bool autoAssign = false) { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized("User ID claim not found in token."); + } + try { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); TaskDto? task = await _taskService.GetTaskByIdAsync(taskId, userId, autoAssign); if (task == null) @@ -138,8 +194,74 @@ public async Task GetTaskById(int taskId, [FromQuery] bool autoAs return NotFound($"Task with ID {taskId} not found."); } + // Check if user has permission to access this task + // Users can only access tasks that are: 1) assigned to them, 2) unassigned, or 3) if they're a manager + var userEmail = User.FindFirstValue(ClaimTypes.Email); + var isManager = HasProjectPermission("project:update"); + + var canAccessTask = string.IsNullOrEmpty(task.AssignedToEmail) || // Unassigned task + task.AssignedToEmail == userEmail || // Assigned to current user + isManager; // User is a manager + + if (!canAccessTask) + { + _logger.LogWarning("User {UserId} attempted to access task {TaskId} assigned to {AssignedTo}", userId, taskId, task.AssignedToEmail); + + // Try to find an alternative task for the user + try + { + var userOrUnassignedTasks = await _taskService.GetTasksForUserOrUnassignedAsync(userId, pageSize: 1); + if (userOrUnassignedTasks.Data.Any()) + { + var alternativeTask = userOrUnassignedTasks.Data.First(); + _logger.LogInformation("Redirecting user {UserId} from unavailable task {TaskId} to available task {AlternativeTaskId}", + userId, taskId, alternativeTask.Id); + + return StatusCode(409, new { + error = "Task assigned to another user", + message = $"This task is assigned to another user. Redirecting you to an available task.", + redirectToTask = alternativeTask.Id, + alternativeTask = alternativeTask, + toastMessage = "This task was assigned to another user. We've found you an available task to work on." + }); + } + else + { + // No alternative tasks available + return StatusCode(404, new { + error = "No tasks available", + message = "This task is assigned to another user and no other tasks are available.", + redirectToTaskView = true, + toastMessage = "This task is assigned to another user. No other tasks are currently available for you to work on." + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while finding alternative task for user {UserId}", userId); + return StatusCode(404, new { + error = "Task not available", + message = "This task is assigned to another user. You can only access tasks assigned to you or unassigned tasks.", + redirectToTaskView = true, + toastMessage = "This task is assigned to another user. You cannot access it." + }); + } + } + return Ok(task); } + catch (InvalidOperationException ex) when (ex.Message.Contains("No tasks available")) + { + // No available tasks left for annotation - redirect user back to task view + _logger.LogWarning("No available tasks for user {UserId}", userId); + return StatusCode(404, new { + error = "No tasks available", + message = "All tasks are either completed or being worked on by other users.", + availableTasks = 0, + redirectToTaskView = true, + toastMessage = "No tasks available for annotation right now. All tasks are either completed or being worked on by other users." + }); + } catch (Exception ex) { _logger.LogError(ex, "An error occurred while fetching task {TaskId}.", taskId); @@ -150,6 +272,16 @@ public async Task GetTaskById(int taskId, [FromQuery] bool autoAs } } + /// + /// Helper method to check if user has a specific project permission + /// + private bool HasProjectPermission(string permission) + { + // This is a simplified check - in a real implementation, you'd check against the project's permissions + // For now, we'll use the UPDATE permission as a proxy for MANAGER role + return User.IsInRole("MANAGER") || User.HasClaim("permission", permission); + } + /// /// Creates a new task for a project. /// @@ -215,7 +347,7 @@ public async Task UpdateTask(int taskId, [FromBody] UpdateTaskDto catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, "Unauthorized task update attempt by user {UpdatingUserId} for task {TaskId}.", updatingUserId, taskId); - return Forbid(ex.Message); + return StatusCode(403, ex.Message); } catch (Exception ex) { @@ -231,10 +363,12 @@ public async Task UpdateTask(int taskId, [FromBody] UpdateTaskDto /// The ID of the user to assign the task to. /// The updated task. /// Returns the updated task. + /// If the task is already assigned or cannot be assigned. /// If the task is not found. /// If an internal server error occurs. [HttpPost("{taskId:int}/assign/{userId}")] [ProducesResponseType(typeof(TaskDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -261,7 +395,12 @@ public async Task AssignTask(int taskId, string userId) catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, "Unauthorized task assignment attempt by user {AssigningUserId} for task {TaskId} to user {UserId}.", assigningUserId, taskId, userId); - return Forbid(ex.Message); + return StatusCode(403, ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid task assignment attempt by user {AssigningUserId} for task {TaskId} to user {UserId}.", assigningUserId, taskId, userId); + return BadRequest(ex.Message); } catch (Exception ex) { @@ -276,11 +415,13 @@ public async Task AssignTask(int taskId, string userId) /// The ID of the task to assign to current user. /// The updated task. /// Returns the updated task. + /// If the task is already assigned or cannot be assigned. /// If the user is not authenticated. /// If the task is not found. /// If an internal server error occurs. [HttpPost("{taskId:int}/assign-to-me")] [ProducesResponseType(typeof(TaskDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] @@ -306,7 +447,12 @@ public async Task AssignTaskToCurrentUser(int taskId) catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, "Unauthorized self-assignment attempt by user {UserId} for task {TaskId}.", userId, taskId); - return Forbid(ex.Message); + return StatusCode(403, ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid self-assignment attempt by user {UserId} for task {TaskId}.", userId, taskId); + return BadRequest(ex.Message); } catch (Exception ex) { diff --git a/server/Server/Data/Configurations/TaskConfiguration.cs b/server/Server/Data/Configurations/TaskConfiguration.cs index 2ebbc50d..435142e5 100644 --- a/server/Server/Data/Configurations/TaskConfiguration.cs +++ b/server/Server/Data/Configurations/TaskConfiguration.cs @@ -43,11 +43,6 @@ public void Configure(EntityTypeBuilder entity) entity.Property(t => t.WorkflowStageId).HasColumnName("workflow_stage_id"); entity.Property(t => t.AssignedToUserId).HasColumnName("assigned_to_user_id").IsRequired(false); entity.Property(t => t.LastWorkedOnByUserId).HasColumnName("last_worked_on_by_user_id").IsRequired(false); - - // Task Locking Configuration - entity.Property(t => t.LockedByUserId).HasColumnName("locked_by_user_id").IsRequired(false); - entity.Property(t => t.LockedAt).HasColumnName("locked_at").IsRequired(false); - entity.Property(t => t.LockExpiresAt).HasColumnName("lock_expires_at").IsRequired(false); // Row version for optimistic concurrency entity.Property(t => t.RowVersion) @@ -89,10 +84,6 @@ public void Configure(EntityTypeBuilder entity) .HasForeignKey(t => t.LastWorkedOnByUserId) .OnDelete(DeleteBehavior.SetNull); - entity.HasOne(t => t.LockedByUser) - .WithMany() - .HasForeignKey(t => t.LockedByUserId) - .OnDelete(DeleteBehavior.SetNull); // One-to-Many from Task to its child entities entity.HasMany(t => t.Annotations) diff --git a/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.Designer.cs b/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.Designer.cs new file mode 100644 index 00000000..2a960c6d --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.Designer.cs @@ -0,0 +1,1883 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using server.Data; +using server.Models.Domain.Enums; + +#nullable disable + +namespace server.Data.Migrations.Laberis +{ + [DbContext(typeof(LaberisDbContext))] + [Migration("20250907153011_RemoveTaskLockingFields")] + partial class RemoveTaskLockingFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "annotation_type_enum", new[] { "bounding_box", "polygon", "polyline", "point", "text", "line" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "asset_status_enum", new[] { "pending_import", "imported", "import_error", "pending_processing", "processing", "processing_error", "exported", "archived" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "data_source_status_enum", new[] { "active", "inactive", "syncing", "error", "archived" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "data_source_type_enum", new[] { "minio_bucket", "s3_bucket", "gsc_bucket", "azure_blob_storage", "local_directory", "database", "api", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "issue_status_enum", new[] { "open", "in_progress", "resolved", "closed", "reopened", "canceled" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "issue_type_enum", new[] { "incorrect_annotation", "missing_annotation", "ambiguous_task", "asset_quality_issue", "guideline_inquiry", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "project_role_enum", new[] { "manager", "reviewer", "annotator", "viewer" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "project_status_enum", new[] { "active", "archived", "read_only", "pending_deletion" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "project_type_enum", new[] { "image_classification", "object_detection", "image_segmentation", "video_annotation", "text_annotation", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "task_event_type_enum", new[] { "task_created", "task_assigned", "task_unassigned", "stage_changed", "status_changed", "comment_added", "annotation_created", "annotation_updated", "annotation_deleted", "review_submitted", "issue_raised", "priority_changed", "due_date_changed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "task_status_enum", new[] { "not_started", "in_progress", "completed", "archived", "suspended", "deferred", "ready_for_annotation", "ready_for_review", "ready_for_completion", "changes_required", "vetoed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "public", "workflow_stage_type_enum", new[] { "annotation", "revision", "completion" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("server.Models.Domain.Annotation", b => + { + b.Property("AnnotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("annotation_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AnnotationId")); + + b.Property("AnnotationType") + .HasColumnType("public.annotation_type_enum") + .HasColumnName("annotation_type"); + + b.Property("AnnotatorUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("annotator_user_id"); + + b.Property("AssetId") + .HasColumnType("integer") + .HasColumnName("asset_id"); + + b.Property("ConfidenceScore") + .HasColumnType("double precision") + .HasColumnName("confidence_score"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("data"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsGroundTruth") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_ground_truth"); + + b.Property("IsPrediction") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_prediction"); + + b.Property("LabelId") + .HasColumnType("integer") + .HasColumnName("label_id"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("ParentAnnotationId") + .HasColumnType("bigint") + .HasColumnName("parent_annotation_id"); + + b.Property("TaskId") + .HasColumnType("integer") + .HasColumnName("task_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("AnnotationId"); + + b.HasIndex("AnnotatorUserId"); + + b.HasIndex("AssetId"); + + b.HasIndex("LabelId"); + + b.HasIndex("ParentAnnotationId"); + + b.HasIndex("TaskId"); + + b.ToTable("annotations", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Asset", b => + { + b.Property("AssetId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("asset_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AssetId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("external_id"); + + b.Property("Filename") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("filename"); + + b.Property("Height") + .HasColumnType("integer") + .HasColumnName("height"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("MimeType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("mime_type"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Status") + .HasColumnType("public.asset_status_enum") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Width") + .HasColumnType("integer") + .HasColumnName("width"); + + b.HasKey("AssetId"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("ProjectId", "ExternalId") + .IsUnique(); + + b.ToTable("assets", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.DashboardConfiguration", b => + { + b.Property("DashboardConfigurationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("dashboard_configuration_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DashboardConfigurationId")); + + b.Property("ConfigurationData") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("configuration_data"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("DashboardConfigurationId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique() + .HasDatabaseName("idx_dashboard_configurations_user_project"); + + b.ToTable("dashboard_configurations", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.DataSource", b => + { + b.Property("DataSourceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DataSourceId")); + + b.Property("ConnectionDetails") + .HasColumnType("jsonb") + .HasColumnName("connection_details"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_default"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("SourceType") + .HasColumnType("public.data_source_type_enum") + .HasColumnName("source_type"); + + b.Property("Status") + .HasColumnType("public.data_source_status_enum") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("DataSourceId"); + + b.HasIndex("ProjectId"); + + b.ToTable("data_sources", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_used"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("token"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsUsed"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Issue", b => + { + b.Property("IssueId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("issue_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("IssueId")); + + b.Property("AnnotationId") + .HasColumnType("bigint") + .HasColumnName("annotation_id"); + + b.Property("AssetId") + .HasColumnType("integer") + .HasColumnName("asset_id"); + + b.Property("AssignedToUserId") + .HasColumnType("text") + .HasColumnName("assigned_to_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IssueType") + .HasColumnType("public.issue_type_enum") + .HasColumnName("issue_type"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("priority"); + + b.Property("ReportedByUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reported_by_user_id"); + + b.Property("ResolutionDetails") + .HasColumnType("text") + .HasColumnName("resolution_details"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Status") + .HasColumnType("public.issue_status_enum") + .HasColumnName("status"); + + b.Property("TaskId") + .HasColumnType("integer") + .HasColumnName("task_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("IssueId"); + + b.HasIndex("AnnotationId"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("ReportedByUserId"); + + b.HasIndex("TaskId"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Label", b => + { + b.Property("LabelId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("label_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LabelId")); + + b.Property("Color") + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LabelSchemeId") + .HasColumnType("integer") + .HasColumnName("label_scheme_id"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("OriginalName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("original_name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("LabelId"); + + b.HasIndex("LabelSchemeId", "Name", "IsActive") + .IsUnique() + .HasFilter("is_active = true"); + + b.ToTable("labels", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.LabelScheme", b => + { + b.Property("LabelSchemeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("label_scheme_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LabelSchemeId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_default"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("LabelSchemeId"); + + b.HasIndex("ProjectId", "Name", "IsActive") + .IsUnique() + .HasFilter("is_active = true"); + + b.ToTable("label_schemes", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Project", b => + { + b.Property("ProjectId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("project_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectId")); + + b.Property("AnnotationGuidelinesUrl") + .HasColumnType("text") + .HasColumnName("annotation_guidelines_url"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("ProjectType") + .HasColumnType("public.project_type_enum") + .HasColumnName("project_type"); + + b.Property("Status") + .HasColumnType("public.project_status_enum") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("ProjectId"); + + b.HasIndex("OwnerId"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.ProjectInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitationToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("invitation_token"); + + b.Property("InvitedByUserId") + .HasColumnType("text") + .HasColumnName("invited_by_user_id"); + + b.Property("IsAccepted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_accepted"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("Role") + .HasColumnType("public.project_role_enum") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("InvitationToken") + .IsUnique(); + + b.HasIndex("InvitedByUserId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Email", "ProjectId"); + + b.ToTable("project_invitations", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.ProjectMember", b => + { + b.Property("ProjectMemberId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("project_member_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectMemberId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("InvitedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("invited_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("Role") + .HasColumnType("public.project_role_enum") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("ProjectMemberId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProjectId", "UserId") + .IsUnique(); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Task", b => + { + b.Property("TaskId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("task_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TaskId")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("AssetId") + .HasColumnType("integer") + .HasColumnName("asset_id"); + + b.Property("AssignedToUserId") + .HasColumnType("text") + .HasColumnName("assigned_to_user_id"); + + b.Property("ChangesRequiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("changes_required_at"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeferredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deferred_at"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("due_date"); + + b.Property("LastWorkedOnByUserId") + .HasColumnType("text") + .HasColumnName("last_worked_on_by_user_id"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("priority"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("status"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suspended_at"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("VetoedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("vetoed_at"); + + b.Property("WorkflowId") + .HasColumnType("integer") + .HasColumnName("workflow_id"); + + b.Property("WorkflowStageId") + .HasColumnType("integer") + .HasColumnName("workflow_stage_id"); + + b.Property("WorkingTimeMs") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("working_time_ms"); + + b.HasKey("TaskId"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("LastWorkedOnByUserId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowStageId"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.TaskEvent", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("event_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Details") + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EventType") + .HasColumnType("public.task_event_type_enum") + .HasColumnName("event_type"); + + b.Property("FromWorkflowStageId") + .HasColumnType("integer") + .HasColumnName("from_workflow_stage_id"); + + b.Property("TaskId") + .HasColumnType("integer") + .HasColumnName("task_id"); + + b.Property("ToWorkflowStageId") + .HasColumnType("integer") + .HasColumnName("to_workflow_stage_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("EventId"); + + b.HasIndex("FromWorkflowStageId"); + + b.HasIndex("TaskId"); + + b.HasIndex("ToWorkflowStageId"); + + b.HasIndex("UserId"); + + b.ToTable("task_events", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.Workflow", b => + { + b.Property("WorkflowId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("workflow_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkflowId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("LabelSchemeId") + .HasColumnType("integer") + .HasColumnName("label_scheme_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("WorkflowId"); + + b.HasIndex("LabelSchemeId"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("workflows", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStage", b => + { + b.Property("WorkflowStageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("workflow_stage_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkflowStageId")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("InputDataSourceId") + .HasColumnType("integer") + .HasColumnName("input_data_source_id"); + + b.Property("IsFinalStage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_final_stage"); + + b.Property("IsInitialStage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_initial_stage"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("StageOrder") + .HasColumnType("integer") + .HasColumnName("stage_order"); + + b.Property("StageType") + .HasColumnType("public.workflow_stage_type_enum") + .HasColumnName("stage_type"); + + b.Property("TargetDataSourceId") + .HasColumnType("integer") + .HasColumnName("target_data_source_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkflowId") + .HasColumnType("integer") + .HasColumnName("workflow_id"); + + b.HasKey("WorkflowStageId"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("InputDataSourceId"); + + b.HasIndex("TargetDataSourceId"); + + b.HasIndex("WorkflowId", "Name") + .IsUnique(); + + b.HasIndex("WorkflowId", "StageOrder") + .IsUnique(); + + b.ToTable("workflow_stages", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStageAssignment", b => + { + b.Property("WorkflowStageAssignmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("workflow_stage_assignment_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkflowStageAssignmentId")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ProjectMemberId") + .HasColumnType("integer") + .HasColumnName("project_member_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkflowStageId") + .HasColumnType("integer") + .HasColumnName("workflow_stage_id"); + + b.HasKey("WorkflowStageAssignmentId"); + + b.HasIndex("ProjectMemberId"); + + b.HasIndex("WorkflowStageId", "ProjectMemberId") + .IsUnique(); + + b.ToTable("workflow_stage_assignments", (string)null); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStageConnection", b => + { + b.Property("WorkflowStageConnectionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("workflow_stage_connection_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkflowStageConnectionId")); + + b.Property("Condition") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FromStageId") + .HasColumnType("integer") + .HasColumnName("from_stage_id"); + + b.Property("ToStageId") + .HasColumnType("integer") + .HasColumnName("to_stage_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("WorkflowStageConnectionId"); + + b.HasIndex("ToStageId"); + + b.HasIndex("FromStageId", "ToStageId", "Condition") + .IsUnique(); + + b.ToTable("workflow_stage_connections", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("server.Models.Domain.Annotation", b => + { + b.HasOne("ApplicationUser", "AnnotatorUser") + .WithMany() + .HasForeignKey("AnnotatorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("server.Models.Domain.Asset", "Asset") + .WithMany("Annotations") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.Label", "Label") + .WithMany("Annotations") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("server.Models.Domain.Annotation", "ParentAnnotation") + .WithMany("ChildAnnotations") + .HasForeignKey("ParentAnnotationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Task", "Task") + .WithMany("Annotations") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnnotatorUser"); + + b.Navigation("Asset"); + + b.Navigation("Label"); + + b.Navigation("ParentAnnotation"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("server.Models.Domain.Asset", b => + { + b.HasOne("server.Models.Domain.DataSource", "DataSource") + .WithMany("Assets") + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany("Assets") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DataSource"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("server.Models.Domain.DashboardConfiguration", b => + { + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("server.Models.Domain.DataSource", b => + { + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany("DataSources") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("server.Models.Domain.EmailVerificationToken", b => + { + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("server.Models.Domain.Issue", b => + { + b.HasOne("server.Models.Domain.Annotation", "Annotation") + .WithMany("Issues") + .HasForeignKey("AnnotationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Asset", "Asset") + .WithMany("Issues") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", "AssignedToUser") + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ApplicationUser", "ReportedByUser") + .WithMany() + .HasForeignKey("ReportedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("server.Models.Domain.Task", "Task") + .WithMany("Issues") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Annotation"); + + b.Navigation("Asset"); + + b.Navigation("AssignedToUser"); + + b.Navigation("ReportedByUser"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("server.Models.Domain.Label", b => + { + b.HasOne("server.Models.Domain.LabelScheme", "LabelScheme") + .WithMany("Labels") + .HasForeignKey("LabelSchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LabelScheme"); + }); + + modelBuilder.Entity("server.Models.Domain.LabelScheme", b => + { + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany("LabelSchemes") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("server.Models.Domain.Project", b => + { + b.HasOne("ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("server.Models.Domain.ProjectInvitation", b => + { + b.HasOne("ApplicationUser", "InvitedByUser") + .WithMany() + .HasForeignKey("InvitedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InvitedByUser"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("server.Models.Domain.ProjectMember", b => + { + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany("ProjectMembers") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("server.Models.Domain.Task", b => + { + b.HasOne("server.Models.Domain.Asset", "Asset") + .WithMany("Tasks") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", "AssignedToUser") + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ApplicationUser", "LastWorkedOnByUser") + .WithMany() + .HasForeignKey("LastWorkedOnByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("server.Models.Domain.WorkflowStage", "WorkflowStage") + .WithMany("TasksAtThisStage") + .HasForeignKey("WorkflowStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Asset"); + + b.Navigation("AssignedToUser"); + + b.Navigation("LastWorkedOnByUser"); + + b.Navigation("Project"); + + b.Navigation("Workflow"); + + b.Navigation("WorkflowStage"); + }); + + modelBuilder.Entity("server.Models.Domain.TaskEvent", b => + { + b.HasOne("server.Models.Domain.WorkflowStage", "FromWorkflowStage") + .WithMany() + .HasForeignKey("FromWorkflowStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Task", "Task") + .WithMany("TaskEvents") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.WorkflowStage", "ToWorkflowStage") + .WithMany() + .HasForeignKey("ToWorkflowStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FromWorkflowStage"); + + b.Navigation("Task"); + + b.Navigation("ToWorkflowStage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("server.Models.Domain.Workflow", b => + { + b.HasOne("server.Models.Domain.LabelScheme", "LabelScheme") + .WithMany() + .HasForeignKey("LabelSchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("server.Models.Domain.Project", "Project") + .WithMany("Workflows") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LabelScheme"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStage", b => + { + b.HasOne("ApplicationUser", null) + .WithMany("WorkflowStages") + .HasForeignKey("ApplicationUserId"); + + b.HasOne("server.Models.Domain.DataSource", "InputDataSource") + .WithMany() + .HasForeignKey("InputDataSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.DataSource", "TargetDataSource") + .WithMany() + .HasForeignKey("TargetDataSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("server.Models.Domain.Workflow", "Workflow") + .WithMany("WorkflowStages") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InputDataSource"); + + b.Navigation("TargetDataSource"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStageAssignment", b => + { + b.HasOne("server.Models.Domain.ProjectMember", "ProjectMember") + .WithMany("WorkflowStageAssignments") + .HasForeignKey("ProjectMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.WorkflowStage", "WorkflowStage") + .WithMany("StageAssignments") + .HasForeignKey("WorkflowStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectMember"); + + b.Navigation("WorkflowStage"); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStageConnection", b => + { + b.HasOne("server.Models.Domain.WorkflowStage", "FromStage") + .WithMany("OutgoingConnections") + .HasForeignKey("FromStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("server.Models.Domain.WorkflowStage", "ToStage") + .WithMany("IncomingConnections") + .HasForeignKey("ToStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromStage"); + + b.Navigation("ToStage"); + }); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Navigation("WorkflowStages"); + }); + + modelBuilder.Entity("server.Models.Domain.Annotation", b => + { + b.Navigation("ChildAnnotations"); + + b.Navigation("Issues"); + }); + + modelBuilder.Entity("server.Models.Domain.Asset", b => + { + b.Navigation("Annotations"); + + b.Navigation("Issues"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("server.Models.Domain.DataSource", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("server.Models.Domain.Label", b => + { + b.Navigation("Annotations"); + }); + + modelBuilder.Entity("server.Models.Domain.LabelScheme", b => + { + b.Navigation("Labels"); + }); + + modelBuilder.Entity("server.Models.Domain.Project", b => + { + b.Navigation("Assets"); + + b.Navigation("DataSources"); + + b.Navigation("LabelSchemes"); + + b.Navigation("ProjectMembers"); + + b.Navigation("Workflows"); + }); + + modelBuilder.Entity("server.Models.Domain.ProjectMember", b => + { + b.Navigation("WorkflowStageAssignments"); + }); + + modelBuilder.Entity("server.Models.Domain.Task", b => + { + b.Navigation("Annotations"); + + b.Navigation("Issues"); + + b.Navigation("TaskEvents"); + }); + + modelBuilder.Entity("server.Models.Domain.Workflow", b => + { + b.Navigation("Tasks"); + + b.Navigation("WorkflowStages"); + }); + + modelBuilder.Entity("server.Models.Domain.WorkflowStage", b => + { + b.Navigation("IncomingConnections"); + + b.Navigation("OutgoingConnections"); + + b.Navigation("StageAssignments"); + + b.Navigation("TasksAtThisStage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.cs b/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.cs new file mode 100644 index 00000000..d666a8f4 --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250907153011_RemoveTaskLockingFields.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace server.Data.Migrations.Laberis +{ + /// + public partial class RemoveTaskLockingFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_tasks_AspNetUsers_locked_by_user_id", + table: "tasks"); + + migrationBuilder.DropIndex( + name: "IX_tasks_locked_by_user_id", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "lock_expires_at", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "locked_at", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "locked_by_user_id", + table: "tasks"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "lock_expires_at", + table: "tasks", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "locked_at", + table: "tasks", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "locked_by_user_id", + table: "tasks", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_tasks_locked_by_user_id", + table: "tasks", + column: "locked_by_user_id"); + + migrationBuilder.AddForeignKey( + name: "FK_tasks_AspNetUsers_locked_by_user_id", + table: "tasks", + column: "locked_by_user_id", + principalSchema: "identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs b/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs index d263b702..041de649 100644 --- a/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs +++ b/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs @@ -1026,18 +1026,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("last_worked_on_by_user_id"); - b.Property("LockExpiresAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("lock_expires_at"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("locked_at"); - - b.Property("LockedByUserId") - .HasColumnType("text") - .HasColumnName("locked_by_user_id"); - b.Property("Metadata") .HasColumnType("jsonb") .HasColumnName("metadata"); @@ -1101,8 +1089,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("LastWorkedOnByUserId"); - b.HasIndex("LockedByUserId"); - b.HasIndex("ProjectId"); b.HasIndex("WorkflowId"); @@ -1659,11 +1645,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("LastWorkedOnByUserId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("ApplicationUser", "LockedByUser") - .WithMany() - .HasForeignKey("LockedByUserId") - .OnDelete(DeleteBehavior.SetNull); - b.HasOne("server.Models.Domain.Project", "Project") .WithMany() .HasForeignKey("ProjectId") @@ -1688,8 +1669,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("LastWorkedOnByUser"); - b.Navigation("LockedByUser"); - b.Navigation("Project"); b.Navigation("Workflow"); diff --git a/server/Server/Extensions/ServiceCollectionExtensions.cs b/server/Server/Extensions/ServiceCollectionExtensions.cs index 7226c8bd..3d02bc92 100644 --- a/server/Server/Extensions/ServiceCollectionExtensions.cs +++ b/server/Server/Extensions/ServiceCollectionExtensions.cs @@ -198,7 +198,6 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -213,8 +212,6 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); - // Add background services - services.AddHostedService(); return services; } diff --git a/server/Server/Models/DTOs/Task/TaskDto.cs b/server/Server/Models/DTOs/Task/TaskDto.cs index fd82d366..7f532f1a 100644 --- a/server/Server/Models/DTOs/Task/TaskDto.cs +++ b/server/Server/Models/DTOs/Task/TaskDto.cs @@ -24,7 +24,7 @@ public record class TaskDto public string? AssignedToEmail { get; init; } public string? LastWorkedOnByEmail { get; init; } - // Lock status fields for UI feedback - public bool IsLocked { get; set; } - public bool IsLockedByCurrentUser { get; set; } + // Assignment status fields for UI feedback + public bool IsAssigned { get; set; } + public bool IsAssignedToCurrentUser { get; set; } } diff --git a/server/Server/Models/DTOs/Task/TaskNavigationDto.cs b/server/Server/Models/DTOs/Task/TaskNavigationDto.cs index 3c34b550..6a0e3c5e 100644 --- a/server/Server/Models/DTOs/Task/TaskNavigationDto.cs +++ b/server/Server/Models/DTOs/Task/TaskNavigationDto.cs @@ -39,4 +39,19 @@ public class TaskNavigationDto /// Optional message for user feedback /// public string? Message { get; set; } + + /// + /// Number of available tasks (unassigned and can be worked on) + /// + public int AvailableTasks { get; set; } + + /// + /// Number of completed tasks by the current user + /// + public int CompletedTasks { get; set; } + + /// + /// Number of assigned tasks (being worked on by others) + /// + public int AssignedTasks { get; set; } } \ No newline at end of file diff --git a/server/Server/Models/Domain/Task.cs b/server/Server/Models/Domain/Task.cs index 719142e5..7e458d1e 100644 --- a/server/Server/Models/Domain/Task.cs +++ b/server/Server/Models/Domain/Task.cs @@ -27,11 +27,6 @@ public class Task public int WorkflowStageId { get; set; } public string? AssignedToUserId { get; set; } public string? LastWorkedOnByUserId { get; set; } - - // Task Locking Fields - public string? LockedByUserId { get; set; } - public DateTime? LockedAt { get; set; } - public DateTime? LockExpiresAt { get; set; } // Concurrency Token [Timestamp] @@ -44,7 +39,6 @@ public class Task public virtual WorkflowStage WorkflowStage { get; set; } = null!; public virtual ApplicationUser? AssignedToUser { get; set; } public virtual ApplicationUser? LastWorkedOnByUser { get; set; } - public virtual ApplicationUser? LockedByUser { get; set; } public virtual ICollection Annotations { get; set; } = []; public virtual ICollection TaskEvents { get; set; } = []; diff --git a/server/Server/Program.cs b/server/Server/Program.cs index 0f6f0707..2cd977ac 100644 --- a/server/Server/Program.cs +++ b/server/Server/Program.cs @@ -74,8 +74,6 @@ public async static Task Main(string[] args) // Register pipeline services and workflow orchestration builder.Services.AddPipelineServices(); - // Register background services - builder.Services.AddHostedService(); // Add framework services builder.Services.AddControllers() diff --git a/server/Server/Repositories/TaskRepository.cs b/server/Server/Repositories/TaskRepository.cs index c5369131..54e0c9aa 100644 --- a/server/Server/Repositories/TaskRepository.cs +++ b/server/Server/Repositories/TaskRepository.cs @@ -27,7 +27,6 @@ public TaskRepository(LaberisDbContext context, ILogger logger) .Include(t => t.WorkflowStage) .Include(t => t.AssignedToUser) .Include(t => t.LastWorkedOnByUser) - .Include(t => t.LockedByUser) .FirstOrDefaultAsync(t => t.TaskId == (int)id); } @@ -37,8 +36,7 @@ protected override IQueryable ApplyIncludes(IQueryable return query .Include(t => t.WorkflowStage) .Include(t => t.AssignedToUser) - .Include(t => t.LastWorkedOnByUser) - .Include(t => t.LockedByUser); + .Include(t => t.LastWorkedOnByUser); } protected override IQueryable ApplyFilter(IQueryable query, string? filterOn, string? filterQuery) @@ -100,7 +98,7 @@ protected override IQueryable ApplyFilter(IQueryable q } break; case "is_completed": - var isCompleted = trimmedFilterQuery.ToLowerInvariant() == "true"; + var isCompleted = trimmedFilterQuery.Equals("true", StringComparison.InvariantCultureIgnoreCase); if (isCompleted) { query = query.Where(t => t.CompletedAt != null); @@ -110,8 +108,18 @@ protected override IQueryable ApplyFilter(IQueryable q query = query.Where(t => t.CompletedAt == null); } break; + case "asset_id": + if (int.TryParse(trimmedFilterQuery, out var assetId)) + { + query = query.Where(t => t.AssetId == assetId); + } + else + { + _logger.LogWarning("Failed to parse asset ID: {TrimmedFilterQuery}", trimmedFilterQuery); + } + break; case "is_archived": - var isArchived = trimmedFilterQuery.ToLowerInvariant() == "true"; + var isArchived = trimmedFilterQuery.Equals("true", StringComparison.InvariantCultureIgnoreCase); if (isArchived) { query = query.Where(t => t.ArchivedAt != null); @@ -211,20 +219,13 @@ public async Task UpdateTaskStatusAsync(LaberisTask task, Models.Do public async Task> GetNavigableTasksAsync(string userId, int projectId, int? workflowStageId = null) { - var now = DateTime.UtcNow; - var query = _dbSet .Where(t => t.ProjectId == projectId) // Include tasks assigned to user OR unassigned tasks (available for assignment) .Where(t => t.AssignedToUserId == userId || t.AssignedToUserId == null) .Where(t => t.Status != Models.Domain.Enums.TaskStatus.COMPLETED && t.Status != Models.Domain.Enums.TaskStatus.ARCHIVED && - t.Status != Models.Domain.Enums.TaskStatus.VETOED) - // Exclude tasks locked by other users (but include tasks locked by this user or unlocked tasks) - .Where(t => t.LockedByUserId == null || - t.LockedByUserId == userId || - t.LockExpiresAt == null || - t.LockExpiresAt <= now); + t.Status != Models.Domain.Enums.TaskStatus.VETOED); if (workflowStageId.HasValue) { diff --git a/server/Server/Services/ExpiredLockCleanupService.cs b/server/Server/Services/ExpiredLockCleanupService.cs deleted file mode 100644 index 9238bae8..00000000 --- a/server/Server/Services/ExpiredLockCleanupService.cs +++ /dev/null @@ -1,42 +0,0 @@ -using server.Services.Interfaces; - -namespace server.Services; - -public class ExpiredLockCleanupService : BackgroundService -{ - private readonly ILogger _logger; - private readonly IServiceProvider _serviceProvider; - private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5); - - public ExpiredLockCleanupService(ILogger logger, IServiceProvider serviceProvider) - { - _logger = logger; - _serviceProvider = serviceProvider; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Expired Lock Cleanup Service is starting."); - - while (!stoppingToken.IsCancellationRequested) - { - _logger.LogInformation("Expired Lock Cleanup Service is running."); - - try - { - using var scope = _serviceProvider.CreateScope(); - var taskLockingService = scope.ServiceProvider.GetRequiredService(); - var cleanedLocksCount = await taskLockingService.CleanupExpiredLocksAsync(); - _logger.LogInformation("Cleaned up {Count} expired task locks.", cleanedLocksCount); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while cleaning up expired locks."); - } - - await Task.Delay(_cleanupInterval, stoppingToken); - } - - _logger.LogInformation("Expired Lock Cleanup Service is stopping."); - } -} diff --git a/server/Server/Services/Interfaces/ITaskLockingService.cs b/server/Server/Services/Interfaces/ITaskLockingService.cs deleted file mode 100644 index 556e0445..00000000 --- a/server/Server/Services/Interfaces/ITaskLockingService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using LaberisTask = server.Models.Domain.Task; - -namespace server.Services.Interfaces; - -/// -/// Service for managing task locking to prevent concurrent access by multiple users. -/// -public interface ITaskLockingService -{ - /// - /// Attempts to lock a task for a specific user. - /// - /// The ID of the task to lock. - /// The ID of the user requesting the lock. - /// Duration of the lock in minutes (default: 30). - /// True if the lock was acquired, false if the task is already locked by another user. - Task TryLockTaskAsync(int taskId, string userId, int lockDurationMinutes = 30); - - /// - /// Releases a task lock if it's owned by the specified user. - /// - /// The ID of the task to unlock. - /// The ID of the user requesting to unlock. - /// True if the lock was released, false if the user doesn't own the lock. - Task ReleaseLockAsync(int taskId, string userId); - - /// - /// Checks if a task is currently locked by any user. - /// - /// The ID of the task to check. - /// True if the task is locked, false otherwise. - Task IsTaskLockedAsync(int taskId); - - /// - /// Checks if a task is locked by a specific user. - /// - /// The ID of the task to check. - /// The ID of the user to check. - /// True if the task is locked by the specified user, false otherwise. - Task IsTaskLockedByUserAsync(int taskId, string userId); - - /// - /// Extends the lock duration for a task if it's owned by the user. - /// - /// The ID of the task. - /// The ID of the user. - /// Additional minutes to extend the lock (default: 30). - /// True if the lock was extended, false if the user doesn't own the lock. - Task ExtendLockAsync(int taskId, string userId, int extensionMinutes = 30); - - /// - /// Cleans up expired locks from the system. - /// - /// Number of expired locks that were cleaned up. - Task CleanupExpiredLocksAsync(); - - /// - /// Gets all tasks currently locked by a specific user. - /// - /// The ID of the user. - /// List of tasks locked by the user. - Task> GetTasksLockedByUserAsync(string userId); - - /// - /// Releases all locks held by a specific user (used when user logs out or disconnects). - /// - /// The ID of the user. - /// Number of locks that were released. - Task ReleaseAllUserLocksAsync(string userId); -} \ No newline at end of file diff --git a/server/Server/Services/Interfaces/ITaskService.cs b/server/Server/Services/Interfaces/ITaskService.cs index ce9dcde9..d7fa40d5 100644 --- a/server/Server/Services/Interfaces/ITaskService.cs +++ b/server/Server/Services/Interfaces/ITaskService.cs @@ -112,4 +112,21 @@ Task> GetTasksForWorkflowStageAsync( /// The ID of the user performing the action. /// A task that represents the asynchronous operation, containing the updated TaskDto if successful, otherwise null. Task ChangeTaskStatusAsync(int taskId, Models.Domain.Enums.TaskStatus targetStatus, string userId); + + /// + /// Retrieves tasks that are either assigned to a specific user or are unassigned (available for pickup). + /// + /// The ID of the user to retrieve assigned or available tasks for. + /// The field to filter on (e.g., "priority", "project_id", "current_workflow_stage_id", "is_completed"). + /// The query string to filter by. + /// The field to sort by (e.g., "priority", "due_date", "created_at", "completed_at"). + /// True for ascending order, false for descending. + /// The page number for pagination (1-based index). + /// The number of items per page. + /// A task that represents the asynchronous operation, containing a collection of TaskDto. + Task> GetTasksForUserOrUnassignedAsync( + string userId, + string? filterOn = null, string? filterQuery = null, string? sortBy = null, + bool isAscending = true, int pageNumber = 1, int pageSize = 25 + ); } diff --git a/server/Server/Services/TaskLockingService.cs b/server/Server/Services/TaskLockingService.cs deleted file mode 100644 index 384e7e25..00000000 --- a/server/Server/Services/TaskLockingService.cs +++ /dev/null @@ -1,276 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using server.Repositories.Interfaces; -using server.Services.Interfaces; -using LaberisTask = server.Models.Domain.Task; - -namespace server.Services; - -/// -/// Service for managing task locking to prevent concurrent access by multiple users. -/// -public class TaskLockingService : ITaskLockingService -{ - private readonly ITaskRepository _taskRepository; - private readonly ILogger _logger; - - public TaskLockingService(ITaskRepository taskRepository, ILogger logger) - { - _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task TryLockTaskAsync(int taskId, string userId, int lockDurationMinutes = 30) - { - _logger.LogInformation("Attempting to lock task {TaskId} for user {UserId} for {Duration} minutes", - taskId, userId, lockDurationMinutes); - - var task = await _taskRepository.GetByIdAsync(taskId); - if (task == null) - { - _logger.LogWarning("Task {TaskId} not found", taskId); - return false; - } - - var now = DateTime.UtcNow; - - // Check if task is already locked by another user - if (task.LockedByUserId != null && task.LockExpiresAt.HasValue && task.LockExpiresAt > now) - { - if (task.LockedByUserId != userId) - { - _logger.LogInformation("Task {TaskId} is already locked by user {LockedUserId}, expires at {ExpiresAt}", - taskId, task.LockedByUserId, task.LockExpiresAt); - return false; - } - - // User already owns the lock, extend it - _logger.LogInformation("User {UserId} already owns lock for task {TaskId}, extending lock", userId, taskId); - } - - // Acquire or extend the lock - task.LockedByUserId = userId; - task.LockedAt = now; - task.LockExpiresAt = now.AddMinutes(lockDurationMinutes); - - _taskRepository.Update(task); - - try - { - await _taskRepository.SaveChangesAsync(); - _logger.LogInformation("Successfully locked task {TaskId} for user {UserId}, expires at {ExpiresAt}", - taskId, userId, task.LockExpiresAt); - return true; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogWarning(ex, "Concurrency conflict when trying to lock task {TaskId}. Lock failed.", taskId); - return false; - } - } - - public async Task ReleaseLockAsync(int taskId, string userId) - { - _logger.LogInformation("Attempting to release lock for task {TaskId} by user {UserId}", taskId, userId); - - var task = await _taskRepository.GetByIdAsync(taskId); - if (task == null) - { - _logger.LogWarning("Task {TaskId} not found", taskId); - return false; // Task doesn't exist, so no lock to release. - } - - // Check if the user owns the lock - if (task.LockedByUserId != userId) - { - _logger.LogWarning("User {UserId} attempted to release lock for task {TaskId} but lock is owned by {LockedUserId}", - userId, taskId, task.LockedByUserId); - return false; - } - - // Release the lock - task.LockedByUserId = null; - task.LockedAt = null; - task.LockExpiresAt = null; - - _taskRepository.Update(task); - - try - { - await _taskRepository.SaveChangesAsync(); - _logger.LogInformation("Successfully released lock for task {TaskId} by user {UserId}", taskId, userId); - return true; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogWarning(ex, "Concurrency conflict when trying to release lock for task {TaskId}. Release failed.", taskId); - return false; - } - } - - public async Task IsTaskLockedAsync(int taskId) - { - var task = await _taskRepository.GetByIdAsync(taskId); - if (task == null) - { - return false; - } - - var now = DateTime.UtcNow; - return task.LockedByUserId != null && task.LockExpiresAt.HasValue && task.LockExpiresAt > now; - } - - public async Task IsTaskLockedByUserAsync(int taskId, string userId) - { - var task = await _taskRepository.GetByIdAsync(taskId); - if (task == null) - { - return false; - } - - var now = DateTime.UtcNow; - return task.LockedByUserId == userId && task.LockExpiresAt.HasValue && task.LockExpiresAt > now; - } - - public async Task ExtendLockAsync(int taskId, string userId, int extensionMinutes = 30) - { - _logger.LogInformation("Attempting to extend lock for task {TaskId} by user {UserId} for {Extension} minutes", - taskId, userId, extensionMinutes); - - var task = await _taskRepository.GetByIdAsync(taskId); - if (task == null) - { - _logger.LogWarning("Task {TaskId} not found", taskId); - return false; - } - - var now = DateTime.UtcNow; - - // Check if the user owns an active lock - if (task.LockedByUserId != userId || task.LockExpiresAt == null || task.LockExpiresAt <= now) - { - _logger.LogWarning("User {UserId} attempted to extend lock for task {TaskId} but doesn't own an active lock", - userId, taskId); - return false; - } - - // Extend the lock - task.LockExpiresAt = now.AddMinutes(extensionMinutes); - - _taskRepository.Update(task); - - try - { - await _taskRepository.SaveChangesAsync(); - _logger.LogInformation("Successfully extended lock for task {TaskId} by user {UserId}, new expiration: {ExpiresAt}", - taskId, userId, task.LockExpiresAt); - return true; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogWarning(ex, "Concurrency conflict when trying to extend lock for task {TaskId}. Extension failed.", taskId); - return false; - } - } - - public async Task CleanupExpiredLocksAsync() - { - _logger.LogInformation("Starting cleanup of expired task locks"); - - var now = DateTime.UtcNow; - - // Find all tasks with expired locks - var expiredTasks = await _taskRepository.GetAllAsync( - filter: t => t.LockedByUserId != null && t.LockExpiresAt.HasValue && t.LockExpiresAt <= now - ); - - if (!expiredTasks.Any()) - { - _logger.LogInformation("No expired locks found"); - return 0; - } - - var expiredCount = expiredTasks.Count(); - _logger.LogInformation("Found {Count} expired locks to clean up", expiredCount); - - // Clear the lock fields for expired tasks - foreach (var task in expiredTasks) - { - _logger.LogDebug("Cleaning up expired lock for task {TaskId}, was locked by user {UserId}", - task.TaskId, task.LockedByUserId); - - task.LockedByUserId = null; - task.LockedAt = null; - task.LockExpiresAt = null; - - _taskRepository.Update(task); - } - - try - { - await _taskRepository.SaveChangesAsync(); - _logger.LogInformation("Successfully cleaned up {Count} expired task locks", expiredCount); - return expiredCount; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogError(ex, "A concurrency conflict occurred while cleaning up expired locks. Not all expired locks may have been cleaned up."); - return 0; - } - } - - public async Task> GetTasksLockedByUserAsync(string userId) - { - _logger.LogInformation("Getting all tasks locked by user {UserId}", userId); - - var now = DateTime.UtcNow; - - var lockedTasks = await _taskRepository.GetAllAsync( - filter: t => t.LockedByUserId == userId && t.LockExpiresAt.HasValue && t.LockExpiresAt > now - ); - - _logger.LogInformation("Found {Count} active locks for user {UserId}", lockedTasks.Count(), userId); - return [.. lockedTasks]; - } - - public async Task ReleaseAllUserLocksAsync(string userId) - { - _logger.LogInformation("Releasing all locks for user {UserId}", userId); - - var userTasks = await _taskRepository.GetAllAsync( - filter: t => t.LockedByUserId == userId - ); - - if (!userTasks.Any()) - { - _logger.LogInformation("No locks found for user {UserId}", userId); - return 0; - } - - var lockCount = userTasks.Count(); - _logger.LogInformation("Found {Count} locks for user {UserId} to release", lockCount, userId); - - // Clear all locks for this user - foreach (var task in userTasks) - { - _logger.LogDebug("Releasing lock for task {TaskId} from user {UserId}", task.TaskId, userId); - - task.LockedByUserId = null; - task.LockedAt = null; - task.LockExpiresAt = null; - - _taskRepository.Update(task); - } - - try - { - await _taskRepository.SaveChangesAsync(); - _logger.LogInformation("Successfully released {Count} locks for user {UserId}", lockCount, userId); - return lockCount; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogError(ex, "A concurrency conflict occurred while releasing all locks for user {UserId}.", userId); - return 0; - } - } -} \ No newline at end of file diff --git a/server/Server/Services/TaskNavigationService.cs b/server/Server/Services/TaskNavigationService.cs index f9b3f505..86a25fd2 100644 --- a/server/Server/Services/TaskNavigationService.cs +++ b/server/Server/Services/TaskNavigationService.cs @@ -1,6 +1,7 @@ using server.Models.DTOs.Task; using server.Services.Interfaces; using server.Repositories.Interfaces; +using System.Linq.Expressions; namespace server.Services; @@ -10,13 +11,11 @@ namespace server.Services; public class TaskNavigationService : ITaskNavigationService { private readonly ITaskRepository _taskRepository; - private readonly ITaskLockingService _taskLockingService; private readonly ILogger _logger; - public TaskNavigationService(ITaskRepository taskRepository, ITaskLockingService taskLockingService, ILogger logger) + public TaskNavigationService(ITaskRepository taskRepository, ILogger logger) { _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); - _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -54,43 +53,70 @@ public async Task GetNextTaskAsync(string userId, int current // Calculate if the next task has a task after it var nextTaskHasNext = hasNext && (nextIndex < navigableTasks.Count - 1); + // Auto-assign next task if it's unassigned + if (hasNext && nextTask != null && nextTask.AssignedToUserId == null) + { + _logger.LogInformation("Auto-assigning next task {NextTaskId} to user {UserId}", nextTask.TaskId, userId); + + try + { + nextTask.AssignedToUserId = userId; + _taskRepository.Update(nextTask); + await _taskRepository.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to auto-assign task {TaskId} to user {UserId} - task may have been assigned to another user", + nextTask.TaskId, userId); + + // Re-fetch navigable tasks and try to find next available task + var updatedNavigableTasks = await _taskRepository.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId); + var nextAvailableTask = updatedNavigableTasks.FirstOrDefault(t => + updatedNavigableTasks.IndexOf(t) > currentIndex && + (t.AssignedToUserId == userId || t.AssignedToUserId == null)); + + if (nextAvailableTask != null && nextAvailableTask.AssignedToUserId == null) + { + try + { + nextAvailableTask.AssignedToUserId = userId; + _taskRepository.Update(nextAvailableTask); + await _taskRepository.SaveChangesAsync(); + nextTask = nextAvailableTask; + _logger.LogInformation("Successfully auto-assigned alternative task {TaskId} to user {UserId}", + nextAvailableTask.TaskId, userId); + } + catch + { + _logger.LogWarning("No more tasks available for assignment to user {UserId}", userId); + return new TaskNavigationDto + { + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + HasNext = false, + HasPrevious = currentIndex > 0, + Message = "No more tasks available for assignment" + }; + } + } + else + { + nextTask = nextAvailableTask; + } + } + } + var result = new TaskNavigationDto { TaskId = nextTask?.TaskId, AssetId = nextTask?.AssetId, - HasNext = nextTaskHasNext, // Use calculated value for the returned task - HasPrevious = nextIndex > 0, // Use nextIndex instead of currentIndex + HasNext = nextTaskHasNext, + HasPrevious = nextIndex > 0, CurrentPosition = currentIndex + 1, TotalTasks = navigableTasks.Count, Message = hasNext ? null : "No more tasks available" }; - // Release lock on current task and attempt to lock the next task if one is found - if (hasNext && nextTask != null) - { - _logger.LogInformation("Releasing lock for current task {CurrentTaskId} and attempting to lock next task {NextTaskId} for user {UserId}", - currentTaskId, nextTask.TaskId, userId); - - // Release current task lock - await _taskLockingService.ReleaseLockAsync(currentTaskId, userId); - - // Acquire lock for next task - this must succeed for navigation to be allowed - var lockAcquired = await _taskLockingService.TryLockTaskAsync(nextTask.TaskId, userId); - - if (!lockAcquired) - { - _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - navigation denied", nextTask.TaskId, userId); - return new TaskNavigationDto - { - CurrentPosition = currentIndex + 1, - TotalTasks = navigableTasks.Count, - HasNext = false, - HasPrevious = currentIndex > 0, - Message = "Cannot navigate to next task - it is currently locked by another user" - }; - } - } - _logger.LogInformation("Next task navigation result: TaskId={TaskId}, HasNext={HasNext}, Position={Position}/{Total}", result.TaskId, result.HasNext, result.CurrentPosition, result.TotalTasks); @@ -131,43 +157,70 @@ public async Task GetPreviousTaskAsync(string userId, int cur // Calculate if the previous task has a task before it var previousTaskHasPrevious = previousIndex > 0; + // Auto-assign previous task if it's unassigned + if (hasPrevious && previousTask != null && previousTask.AssignedToUserId == null) + { + _logger.LogInformation("Auto-assigning previous task {PreviousTaskId} to user {UserId}", previousTask.TaskId, userId); + + try + { + previousTask.AssignedToUserId = userId; + _taskRepository.Update(previousTask); + await _taskRepository.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to auto-assign task {TaskId} to user {UserId} - task may have been assigned to another user", + previousTask.TaskId, userId); + + // Re-fetch navigable tasks and try to find previous available task + var updatedNavigableTasks = await _taskRepository.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId); + var prevAvailableTask = updatedNavigableTasks.LastOrDefault(t => + updatedNavigableTasks.IndexOf(t) < currentIndex && + (t.AssignedToUserId == userId || t.AssignedToUserId == null)); + + if (prevAvailableTask != null && prevAvailableTask.AssignedToUserId == null) + { + try + { + prevAvailableTask.AssignedToUserId = userId; + _taskRepository.Update(prevAvailableTask); + await _taskRepository.SaveChangesAsync(); + previousTask = prevAvailableTask; + _logger.LogInformation("Successfully auto-assigned alternative previous task {TaskId} to user {UserId}", + prevAvailableTask.TaskId, userId); + } + catch + { + _logger.LogWarning("No more previous tasks available for assignment to user {UserId}", userId); + return new TaskNavigationDto + { + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + HasNext = currentIndex < navigableTasks.Count - 1, + HasPrevious = false, + Message = "No previous tasks available for assignment" + }; + } + } + else + { + previousTask = prevAvailableTask; + } + } + } + var result = new TaskNavigationDto { TaskId = previousTask?.TaskId, AssetId = previousTask?.AssetId, - HasNext = previousIndex < navigableTasks.Count - 1, // Use previousIndex instead of currentIndex - HasPrevious = previousTaskHasPrevious, // Use calculated value for the returned task + HasNext = previousIndex < navigableTasks.Count - 1, + HasPrevious = previousTaskHasPrevious, CurrentPosition = currentIndex + 1, TotalTasks = navigableTasks.Count, Message = hasPrevious ? null : "No previous tasks available" }; - // Release lock on current task and attempt to lock the previous task if one is found - if (hasPrevious && previousTask != null) - { - _logger.LogInformation("Releasing lock for current task {CurrentTaskId} and attempting to lock previous task {PreviousTaskId} for user {UserId}", - currentTaskId, previousTask.TaskId, userId); - - // Release current task lock - await _taskLockingService.ReleaseLockAsync(currentTaskId, userId); - - // Acquire lock for previous task - this must succeed for navigation to be allowed - var lockAcquired = await _taskLockingService.TryLockTaskAsync(previousTask.TaskId, userId); - - if (!lockAcquired) - { - _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - navigation denied", previousTask.TaskId, userId); - return new TaskNavigationDto - { - CurrentPosition = currentIndex + 1, - TotalTasks = navigableTasks.Count, - HasNext = currentIndex < navigableTasks.Count - 1, - HasPrevious = false, - Message = "Cannot navigate to previous task - it is currently locked by another user" - }; - } - } - _logger.LogInformation("Previous task navigation result: TaskId={TaskId}, HasPrevious={HasPrevious}, Position={Position}/{Total}", result.TaskId, result.HasPrevious, result.CurrentPosition, result.TotalTasks); @@ -206,6 +259,9 @@ public async Task GetNavigationContextAsync(string userId, in }; } + // Calculate task availability counts + var taskCounts = await GetTaskAvailabilityCountsAsync(userId, currentTask.ProjectId, workflowStageId); + var result = new TaskNavigationDto { TaskId = currentTaskId, @@ -213,13 +269,84 @@ public async Task GetNavigationContextAsync(string userId, in HasNext = currentIndex < navigableTasks.Count - 1, HasPrevious = currentIndex > 0, CurrentPosition = currentIndex + 1, - TotalTasks = navigableTasks.Count + TotalTasks = navigableTasks.Count, + AvailableTasks = taskCounts.AvailableTasks, + CompletedTasks = taskCounts.CompletedTasks, + AssignedTasks = taskCounts.AssignedTasks }; - _logger.LogInformation("Navigation context: Position={Position}/{Total}, HasNext={HasNext}, HasPrevious={HasPrevious}", - result.CurrentPosition, result.TotalTasks, result.HasNext, result.HasPrevious); + _logger.LogInformation("Navigation context: Position={Position}/{Total}, HasNext={HasNext}, HasPrevious={HasPrevious}, Available={Available}, Completed={Completed}, Assigned={Assigned}", + result.CurrentPosition, result.TotalTasks, result.HasNext, result.HasPrevious, result.AvailableTasks, result.CompletedTasks, result.AssignedTasks); return result; } + + /// + /// Get task availability counts for a project and optional workflow stage + /// + /// The user ID + /// The project ID + /// Optional workflow stage ID + /// Task availability counts + private async Task GetTaskAvailabilityCountsAsync(string userId, int projectId, int? workflowStageId = null) + { + // Get all tasks for the project/stage (not just navigable ones) + var filter = workflowStageId.HasValue + ? (Expression>)(t => t.ProjectId == projectId && t.WorkflowStageId == workflowStageId.Value) + : t => t.ProjectId == projectId; + + var allTasks = await _taskRepository.GetAllAsync(filter, pageSize: int.MaxValue); + + var availableTasks = 0; + var completedTasks = 0; + var assignedTasks = 0; + + foreach (var task in allTasks) + { + // Skip archived and vetoed tasks from counts + if (task.Status == Models.Domain.Enums.TaskStatus.ARCHIVED || + task.Status == Models.Domain.Enums.TaskStatus.VETOED) + continue; + + if (task.Status == Models.Domain.Enums.TaskStatus.COMPLETED) + { + // Count completed tasks by this user + if (task.AssignedToUserId == userId || task.LastWorkedOnByUserId == userId) + completedTasks++; + } + else if (string.IsNullOrEmpty(task.AssignedToUserId)) + { + // Unassigned tasks are available + availableTasks++; + } + else if (task.AssignedToUserId != userId) + { + // Tasks assigned to other users + assignedTasks++; + } + else + { + // Tasks assigned to current user are available to them + availableTasks++; + } + } + + return new TaskAvailabilityCounts + { + AvailableTasks = availableTasks, + CompletedTasks = completedTasks, + AssignedTasks = assignedTasks + }; + } + + /// + /// Helper class for task availability counts + /// + private class TaskAvailabilityCounts + { + public int AvailableTasks { get; set; } + public int CompletedTasks { get; set; } + public int AssignedTasks { get; set; } + } } \ No newline at end of file diff --git a/server/Server/Services/TaskService.cs b/server/Server/Services/TaskService.cs index 466424b9..62d4ba2a 100644 --- a/server/Server/Services/TaskService.cs +++ b/server/Server/Services/TaskService.cs @@ -22,7 +22,6 @@ public class TaskService : ITaskService private readonly IWorkflowStageRepository _workflowStageRepository; private readonly UserManager _userManager; private readonly IProjectMembershipService _projectMembershipService; - private readonly ITaskLockingService _taskLockingService; private readonly ILogger _logger; public TaskService( @@ -33,7 +32,6 @@ public TaskService( IWorkflowStageRepository workflowStageRepository, UserManager userManager, IProjectMembershipService projectMembershipService, - ITaskLockingService taskLockingService, ILogger logger) { _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); @@ -43,7 +41,6 @@ public TaskService( _workflowStageRepository = workflowStageRepository ?? throw new ArgumentNullException(nameof(workflowStageRepository)); _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); _projectMembershipService = projectMembershipService ?? throw new ArgumentNullException(nameof(projectMembershipService)); - _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -111,9 +108,9 @@ public async Task> GetTasksForUserAsync( }; } - public async Task GetTaskByIdAsync(int taskId, string? userId = null, bool acquireLock = false) + public async Task GetTaskByIdAsync(int taskId, string? userId = null, bool autoAssign = false) { - _logger.LogInformation("Fetching task with ID: {TaskId} for user: {UserId}, acquireLock: {AcquireLock}", taskId, userId, acquireLock); + _logger.LogInformation("Fetching task with ID: {TaskId} for user: {UserId}, autoAssign: {AutoAssign}", taskId, userId, autoAssign); var task = await _taskRepository.GetByIdAsync(taskId); @@ -123,33 +120,39 @@ public async Task> GetTasksForUserAsync( return null; } - // Automatically acquire lock if user is provided and acquireLock is true - if (!string.IsNullOrEmpty(userId) && acquireLock) + // Auto-assign task if it's unassigned and autoAssign is true + if (!string.IsNullOrEmpty(userId) && task.AssignedToUserId == null && autoAssign) { - _logger.LogInformation("Attempting to acquire lock for task {TaskId} for user {UserId}", taskId, userId); - var lockAcquired = await _taskLockingService.TryLockTaskAsync(taskId, userId); + _logger.LogInformation("Auto-assigning task {TaskId} to user {UserId}", taskId, userId); - if (!lockAcquired) + try { - _logger.LogWarning("Failed to acquire lock for task {TaskId} for user {UserId} - task is locked by another user", taskId, userId); - // Return the task but indicate it's locked by another user - var lockedTaskDto = MapToDto(task); - lockedTaskDto.IsLockedByCurrentUser = false; - lockedTaskDto.IsLocked = true; - return lockedTaskDto; + task.AssignedToUserId = userId; + _taskRepository.Update(task); + await _taskRepository.SaveChangesAsync(); + _logger.LogInformation("Successfully auto-assigned task {TaskId} to user {UserId}", taskId, userId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to auto-assign task {TaskId} to user {UserId} - task may have been assigned to another user", + taskId, userId); + // Re-fetch the task to get updated assignment + task = await _taskRepository.GetByIdAsync(taskId); + if (task == null) + { + return null; + } } - - _logger.LogInformation("Successfully acquired lock for task {TaskId} for user {UserId}", taskId, userId); } _logger.LogInformation("Successfully fetched task with ID: {TaskId}", taskId); var taskDto = MapToDto(task); - // Set lock status if user is provided + // Set assignment status if user is provided if (!string.IsNullOrEmpty(userId)) { - taskDto.IsLockedByCurrentUser = await _taskLockingService.IsTaskLockedByUserAsync(taskId, userId); - taskDto.IsLocked = await _taskLockingService.IsTaskLockedAsync(taskId); + taskDto.IsAssignedToCurrentUser = task.AssignedToUserId == userId; + taskDto.IsAssigned = !string.IsNullOrEmpty(task.AssignedToUserId); } return taskDto; @@ -228,10 +231,10 @@ public async Task CreateTaskAsync(int projectId, CreateTaskDto createDt return null; } - // Validate lock ownership before allowing modifications + // Validate task assignment before allowing modifications if (!string.IsNullOrEmpty(updatingUserId)) { - await ValidateTaskLockOwnershipAsync(taskId, updatingUserId, "UPDATE"); + await ValidateTaskAssignmentAsync(taskId, updatingUserId, "UPDATE"); } // Update the task properties @@ -327,7 +330,14 @@ public async Task CreateTaskAsync(int projectId, CreateTaskDto createDt await ValidateTaskAssignmentPermissionAsync(existingTask.ProjectId, assigningUserId, userId); } - // Prevent assignment of deferred tasks + // Prevent assignment of already assigned tasks (unless reassigning to the same user) + if (!string.IsNullOrEmpty(existingTask.AssignedToUserId) && existingTask.AssignedToUserId != userId) + { + _logger.LogWarning("Attempted to assign task {TaskId} that is already assigned to user {CurrentAssignee}.", taskId, existingTask.AssignedToUserId); + throw new InvalidOperationException($"Task {taskId} is already assigned to another user. Unassign first or use task update instead."); + } + + // Prevent assignment of deferred tasks or completed tasks if (existingTask.Status == TaskStatus.DEFERRED) { _logger.LogWarning("Attempted to assign deferred task {TaskId}. Deferred tasks cannot be assigned.", taskId); @@ -424,8 +434,22 @@ public async Task CreateTasksForWorkflowStageAsync(int projectId, int workf return null; } - // Validate lock ownership before allowing status changes - await ValidateTaskLockOwnershipAsync(taskId, userId, "CHANGE STATUS"); + // Auto-assign unassigned tasks to the current user before status change + if (string.IsNullOrEmpty(existingTask.AssignedToUserId)) + { + _logger.LogInformation("Task {TaskId} is unassigned, auto-assigning to user {UserId}", taskId, userId); + await AssignTaskAsync(taskId, userId, userId); + // Refresh the task after assignment + existingTask = await _taskRepository.GetByIdAsync(taskId); + if (existingTask == null) + { + _logger.LogError("Task {TaskId} not found after assignment", taskId); + return null; + } + } + + // Validate task assignment before allowing status changes + await ValidateTaskAssignmentAsync(taskId, userId, "CHANGE STATUS"); var currentStatus = existingTask.Status; @@ -459,6 +483,38 @@ public async Task CreateTasksForWorkflowStageAsync(int projectId, int workf return MapToDto(existingTask); } + public async Task> GetTasksForUserOrUnassignedAsync( + string userId, + string? filterOn = null, string? filterQuery = null, string? sortBy = null, + bool isAscending = true, int pageNumber = 1, int pageSize = 25) + { + _logger.LogInformation("Fetching tasks assigned to user {UserId} or unassigned tasks", userId); + + var (tasks, totalCount) = await _taskRepository.GetAllWithCountAsync( + filter: t => t.AssignedToUserId == userId || t.AssignedToUserId == null, + filterOn: filterOn, + filterQuery: filterQuery, + sortBy: sortBy ?? "created_at", + isAscending: isAscending, + pageNumber: pageNumber, + pageSize: pageSize + ); + + _logger.LogInformation("Fetched {Count} tasks assigned to user {UserId} or unassigned", tasks.Count(), userId); + + var taskDtos = tasks.Select(MapToDto).ToArray(); + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + + return new PaginatedResponse + { + Data = taskDtos, + PageSize = pageSize, + CurrentPage = pageNumber, + TotalPages = totalPages, + TotalItems = totalCount + }; + } + #region Private Methods /// @@ -560,41 +616,47 @@ private static TaskDto MapToDto(LaberisTask task) }; } /// - /// Validates that the user owns the lock for the task before allowing modifications + /// Validates that the user is assigned to the task before allowing modifications /// - /// The task ID to validate lock for + /// The task ID to validate assignment for /// The user attempting the modification /// The operation being attempted (for logging) - /// True if user owns the lock, false otherwise - /// Thrown when user doesn't own the lock - private async Task ValidateTaskLockOwnershipAsync(int taskId, string userId, string operation) + /// True if user is assigned to the task, false otherwise + /// Thrown when user is not assigned to the task + private async Task ValidateTaskAssignmentAsync(int taskId, string userId, string operation) { if (string.IsNullOrEmpty(userId)) { - _logger.LogWarning("Cannot validate lock ownership for task {TaskId} - no user ID provided", taskId); + _logger.LogWarning("Cannot validate task assignment for task {TaskId} - no user ID provided", taskId); + return false; + } + + var task = await _taskRepository.GetByIdAsync(taskId); + if (task == null) + { + _logger.LogWarning("Task {TaskId} not found for {Operation} validation", taskId, operation); return false; } - var isLockedByUser = await _taskLockingService.IsTaskLockedByUserAsync(taskId, userId); + var isAssignedToUser = task.AssignedToUserId == userId; - if (!isLockedByUser) + if (!isAssignedToUser) { - var isLocked = await _taskLockingService.IsTaskLockedAsync(taskId); - if (isLocked) + if (!string.IsNullOrEmpty(task.AssignedToUserId)) { - _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but task is locked by another user", + _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but task is assigned to another user", userId, operation, taskId); - throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - it is currently locked by another user."); + throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - it is currently assigned to another user."); } else { - _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but doesn't own a lock", + _logger.LogWarning("User {UserId} attempted {Operation} on task {TaskId} but task is not assigned to them", userId, operation, taskId); - throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - you must acquire a lock first by navigating to the task."); + throw new UnauthorizedAccessException($"Cannot {operation.ToLower()} task {taskId} - task must be assigned to you first."); } } - _logger.LogDebug("Lock ownership validated for user {UserId} on task {TaskId} for {Operation}", userId, taskId, operation); + _logger.LogDebug("Task assignment validated for user {UserId} on task {TaskId} for {Operation}", userId, taskId, operation); return true; } From bb92b908a2f39da5573f45d0920b1b0ca73b5aab Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 18:21:46 +0200 Subject: [PATCH 8/9] feat: Add task availability and redirection handling for task management --- .../src/composables/useTaskAvailability.ts | 115 ++++++++++++ frontend/src/composables/useTaskRedirect.ts | 42 +++++ .../core/workspace/loader/workspaceLoader.ts | 26 ++- .../src/core/workspace/task/taskManager.ts | 48 ++++- .../project/task/taskNavigation.types.ts | 9 + .../src/services/project/task/taskService.ts | 167 ++++++++++++++++-- frontend/src/stores/workspaceStore.ts | 70 ++++++-- frontend/src/stores/workspaceStore.types.ts | 6 + frontend/src/views/AnnotationWorkspace.vue | 67 ++++++- frontend/src/views/project/TasksView.vue | 35 +++- 10 files changed, 549 insertions(+), 36 deletions(-) create mode 100644 frontend/src/composables/useTaskAvailability.ts create mode 100644 frontend/src/composables/useTaskRedirect.ts diff --git a/frontend/src/composables/useTaskAvailability.ts b/frontend/src/composables/useTaskAvailability.ts new file mode 100644 index 00000000..cfc74a9e --- /dev/null +++ b/frontend/src/composables/useTaskAvailability.ts @@ -0,0 +1,115 @@ +import { computed } from "vue"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; + +/** + * Composable for accessing task availability information and formatted counters + * Uses the enhanced navigation context to provide user-friendly task status information + */ +export function useTaskAvailability() { + const workspaceStore = useWorkspaceStore(); + + /** + * Current navigation context with availability information + */ + const navigationContext = computed(() => workspaceStore.navigationContext); + + /** + * Formatted task counter for "X of Y tasks" display + */ + const taskPositionText = computed(() => { + const context = navigationContext.value; + if (!context) return "Loading..."; + + return `${context.currentPosition} of ${context.totalTasks}`; + }); + + /** + * Detailed task availability summary + */ + const availabilitySummary = computed(() => { + const context = navigationContext.value; + if (!context) return "Loading task information..."; + + const parts = []; + + if (context.availableTasks > 0) { + parts.push(`${context.availableTasks} available`); + } + + if (context.completedTasks > 0) { + parts.push(`${context.completedTasks} completed`); + } + + if (context.assignedTasks > 0) { + parts.push(`${context.assignedTasks} assigned to others`); + } + + return parts.length > 0 ? parts.join(", ") : "No tasks available"; + }); + + /** + * Status indicator for current task state + */ + const currentTaskStatus = computed(() => { + const context = navigationContext.value; + if (!context) return { text: "Loading...", color: "gray" }; + + if (context.availableTasks === 0) { + return { + text: "All tasks completed or assigned to others", + color: "orange", + }; + } + + return { + text: `${context.availableTasks} tasks available to work on`, + color: "green", + }; + }); + + /** + * Progress percentage based on completed tasks + */ + const completionPercentage = computed(() => { + const context = navigationContext.value; + if (!context || context.totalTasks === 0) return 0; + + return Math.round((context.completedTasks / context.totalTasks) * 100); + }); + + /** + * Whether there are tasks available for the user to work on + */ + const hasAvailableTasks = computed(() => { + const context = navigationContext.value; + return context ? context.availableTasks > 0 : false; + }); + + /** + * Whether all tasks are either completed or assigned to others + */ + const allTasksUnavailable = computed(() => { + const context = navigationContext.value; + if (!context) return false; + + return ( + context.availableTasks === 0 && + (context.completedTasks > 0 || context.assignedTasks > 0) + ); + }); + + return { + // Raw data + navigationContext, + + // Formatted strings + taskPositionText, + availabilitySummary, + currentTaskStatus, + + // Computed values + completionPercentage, + hasAvailableTasks, + allTasksUnavailable, + }; +} diff --git a/frontend/src/composables/useTaskRedirect.ts b/frontend/src/composables/useTaskRedirect.ts new file mode 100644 index 00000000..4f149366 --- /dev/null +++ b/frontend/src/composables/useTaskRedirect.ts @@ -0,0 +1,42 @@ +import { useRouter } from "vue-router"; +import { useToast } from "@/composables/useToast"; + +/** + * Composable for handling task loading with automatic redirect when no tasks available + * Usage example for components that need to load tasks and handle "no tasks available" gracefully + */ +export function useTaskRedirect() { + const router = useRouter(); + const toast = useToast(); + + /** + * Handle task operation result with automatic redirect and toast for "no tasks available" + * @param result - The task operation result from TaskManager + * @param fallbackRoute - Route to redirect to when no tasks available (default: task view) + */ + const handleTaskResult = async ( + result: import("@/core/workspace/task/taskManager").TaskOperationResult, + fallbackRoute: string = "/tasks" + ) => { + if (!result.success) { + // Check if we should redirect to task view with toast + if (result.shouldRedirectToTaskView && result.toastMessage) { + // Show informative toast message + toast.showInfo("No Tasks Available", result.toastMessage); + + // Redirect back to task view + await router.push(fallbackRoute); + return { redirected: true }; + } else { + // Regular error - let the component handle it + return { redirected: false, error: result.error }; + } + } + + return { redirected: false, data: result.data }; + }; + + return { + handleTaskResult, + }; +} diff --git a/frontend/src/core/workspace/loader/workspaceLoader.ts b/frontend/src/core/workspace/loader/workspaceLoader.ts index a428a804..432cfae6 100644 --- a/frontend/src/core/workspace/loader/workspaceLoader.ts +++ b/frontend/src/core/workspace/loader/workspaceLoader.ts @@ -150,9 +150,33 @@ export class WorkspaceLoader { workflowStageId = currentTask.workflowStageId; logger.info(`Loaded specific task ${initialTaskId} for stage ${workflowStageId}`); } else { + // Check if this is an access restriction error that should redirect to alternative task + if (taskResult.shouldRedirectToTask) { + logger.warn(`Task ${initialTaskId} assigned to another user, should redirect to task ${taskResult.shouldRedirectToTask}`); + throw { + shouldRedirectToTask: taskResult.shouldRedirectToTask, + alternativeTask: taskResult.alternativeTask, + error: taskResult.error, + toastMessage: taskResult.toastMessage + }; + } + + // Check if this is an access restriction error that should redirect to task view + if (taskResult.shouldRedirectToTaskView) { + logger.warn(`Task ${initialTaskId} access denied, should redirect to task view`); + throw { + shouldRedirectToTaskView: true, + error: taskResult.error, + toastMessage: taskResult.toastMessage + }; + } logger.warn(`Failed to load task ${initialTaskId}, falling back to asset tasks:`, taskResult.error); } - } catch (taskByIdError) { + } catch (taskByIdError: any) { + // Re-throw access restriction errors + if (taskByIdError?.shouldRedirectToTask || taskByIdError?.shouldRedirectToTaskView) { + throw taskByIdError; + } logger.warn(`Failed to load task ${initialTaskId}, falling back to asset tasks:`, taskByIdError); } } diff --git a/frontend/src/core/workspace/task/taskManager.ts b/frontend/src/core/workspace/task/taskManager.ts index fd580f6c..e201be36 100644 --- a/frontend/src/core/workspace/task/taskManager.ts +++ b/frontend/src/core/workspace/task/taskManager.ts @@ -24,6 +24,10 @@ export type TaskOperationResult = { } | { success: false; error: string; + shouldRedirectToTaskView?: boolean; + shouldRedirectToTask?: number; + alternativeTask?: Task; + toastMessage?: string; }; /** @@ -37,6 +41,12 @@ export interface TaskNavigationResult { currentPosition: number; totalTasks: number; message?: string | null; + /** Number of available tasks (unlocked and can be worked on) */ + availableTasks: number; + /** Number of completed tasks by the current user */ + completedTasks: number; + /** Number of assigned tasks (being worked on by others) */ + assignedTasks: number; } /** @@ -62,6 +72,29 @@ export class TaskManager { const task = await taskService.getTaskById(projectId, taskId); return { success: true, data: task }; } catch (error) { + // Check if this is a redirect to alternative task error + if (error instanceof Error && (error as any).redirectToTask) { + logger.info(`Task assigned to another user, should redirect to alternative task ${(error as any).redirectToTask}`); + return { + success: false, + error: error.message, + shouldRedirectToTask: (error as any).redirectToTask, + alternativeTask: (error as any).alternativeTask, + toastMessage: (error as any).toastMessage + }; + } + + // Check if this is a redirect to task view error + if (error instanceof Error && (error as any).redirectToTaskView) { + logger.info(`No tasks available, should redirect to task view with toast`); + return { + success: false, + error: error.message, + shouldRedirectToTaskView: true, + toastMessage: (error as any).toastMessage + }; + } + const errorMessage = error instanceof Error ? error.message : 'Failed to load task'; logger.error(`Failed to load task ${taskId}:`, error); return { success: false, error: errorMessage }; @@ -119,7 +152,10 @@ export class TaskManager { hasPrevious: navigationContext.hasPrevious, currentPosition: navigationContext.currentPosition, totalTasks: navigationContext.totalTasks, - message: navigationContext.message + message: navigationContext.message, + availableTasks: navigationContext.availableTasks, + completedTasks: navigationContext.completedTasks, + assignedTasks: navigationContext.assignedTasks }; return { success: true, data: context }; @@ -149,7 +185,10 @@ export class TaskManager { hasPrevious: navigation.hasPrevious, currentPosition: navigation.currentPosition, totalTasks: navigation.totalTasks, - message: navigation.message + message: navigation.message, + availableTasks: navigation.availableTasks, + completedTasks: navigation.completedTasks, + assignedTasks: navigation.assignedTasks }; return { success: true, data: result }; @@ -179,7 +218,10 @@ export class TaskManager { hasPrevious: navigation.hasPrevious, currentPosition: navigation.currentPosition, totalTasks: navigation.totalTasks, - message: navigation.message + message: navigation.message, + availableTasks: navigation.availableTasks, + completedTasks: navigation.completedTasks, + assignedTasks: navigation.assignedTasks }; return { success: true, data: result }; diff --git a/frontend/src/services/project/task/taskNavigation.types.ts b/frontend/src/services/project/task/taskNavigation.types.ts index 0eb99154..36f4c619 100644 --- a/frontend/src/services/project/task/taskNavigation.types.ts +++ b/frontend/src/services/project/task/taskNavigation.types.ts @@ -22,6 +22,15 @@ export interface TaskNavigationResponse { /** Optional message for user feedback */ message?: string | null; + + /** Number of available tasks (unlocked and can be worked on) */ + availableTasks: number; + + /** Number of completed tasks by the current user */ + completedTasks: number; + + /** Number of assigned tasks (being worked on by others) */ + assignedTasks: number; } /** diff --git a/frontend/src/services/project/task/taskService.ts b/frontend/src/services/project/task/taskService.ts index b18f9db8..5799b31a 100644 --- a/frontend/src/services/project/task/taskService.ts +++ b/frontend/src/services/project/task/taskService.ts @@ -204,6 +204,117 @@ class TaskService extends BaseProjectService { return this.getMyTasks(projectId, stageParams); } + /** + * Get tasks that are either assigned to current user or unassigned (available for pickup) + */ + async getMyTasksOrUnassigned(projectId: number, params: TasksQueryParams = {}): Promise { + this.logger.info('Fetching current user tasks or unassigned tasks', params); + + const url = this.buildProjectUrl(projectId, 'tasks/my-tasks-or-unassigned'); + const paginatedResponse = await this.getPaginated(url, params); + + const tasks: Task[] = paginatedResponse.data.map((dto: any) => this.transformTaskDto(dto)); + + this.logger.info(`Fetched ${tasks.length} user tasks or unassigned tasks`); + + return { + tasks, + totalCount: paginatedResponse.totalItems, + currentPage: paginatedResponse.currentPage, + pageSize: paginatedResponse.pageSize, + totalPages: paginatedResponse.totalPages + }; + } + + /** + * Get current user's tasks or unassigned tasks for a specific workflow stage + */ + async getMyTasksOrUnassignedForStage(projectId: number, stageId: number, params: TasksQueryParams = {}): Promise { + this.logger.info(`Fetching current user tasks or unassigned tasks for workflow stage ${stageId}`, params); + + const stageParams = { + ...params, + filterOn: 'current_workflow_stage_id', + filterQuery: stageId.toString() + }; + + return this.getMyTasksOrUnassigned(projectId, stageParams); + } + + /** + * Get enriched current user's tasks or unassigned tasks for a workflow stage with asset names and stage info + */ + async getEnrichedMyTasksOrUnassignedForStage( + projectId: number, + workflowId: number, + stageId: number, + params: TasksQueryParams = {} + ): Promise<{ + tasks: TaskTableRow[]; + stageName: string; + stageDescription?: string; + totalCount: number; + currentPage: number; + pageSize: number; + totalPages: number; + }> { + this.logger.info(`Fetching enriched user tasks or unassigned tasks for stage ${stageId} in workflow ${workflowId}, project ${projectId}`, params); + + // Get tasks for the current user or unassigned for this stage + const tasksResponse = await this.getMyTasksOrUnassignedForStage(projectId, stageId, params); + + // Get stage information + let stageName = 'Unknown Stage'; + let stageDescription = ''; + + try { + const stageData = await workflowStageService.getWorkflowStageById(projectId, workflowId, stageId); + stageName = stageData.name; + stageDescription = stageData.description || ''; + } catch (error) { + this.logger.warn('Failed to fetch stage information:', error); + } + + // Enrich tasks with asset names and format for table display + const enrichedTasks: TaskTableRow[] = await Promise.all( + tasksResponse.tasks.map(async (task) => { + let assetName = `Asset ${task.assetId}`; + + try { + const asset = await assetService.getAssetById(projectId, task.assetId); + assetName = asset.filename || `Asset ${task.assetId}`; + } catch (error) { + this.logger.warn(`Failed to fetch asset ${task.assetId}:`, error); + } + + return { + id: task.id, + assetId: task.assetId, + assetName, + priority: task.priority, + status: task.status || TaskStatus.NOT_STARTED, + assignedTo: task.assignedToEmail || undefined, + dueDate: task.dueDate, + completedAt: task.completedAt, + createdAt: task.createdAt, + stage: stageName + }; + }) + ); + + this.logger.info(`Fetched ${enrichedTasks.length} enriched user/unassigned tasks for stage ${stageId}`); + + return { + tasks: enrichedTasks, + stageName, + stageDescription, + totalCount: tasksResponse.totalCount, + currentPage: tasksResponse.currentPage, + pageSize: tasksResponse.pageSize, + totalPages: tasksResponse.totalPages + }; + } + /** * Get tasks for a specific asset */ @@ -221,19 +332,44 @@ class TaskService extends BaseProjectService { /** * Get a single task by ID + * If task is locked by another user, automatically redirects to navigation to find available task */ async getTaskById(projectId: number, taskId: number, autoAssign: boolean = true): Promise { this.logger.info(`Fetching task ${taskId} from project ${projectId}`, { autoAssign }); - const url = this.buildProjectResourceUrl(projectId, 'tasks/{taskId}', { taskId }); - const queryParams = autoAssign ? '?autoAssign=true' : ''; - const fullUrl = url + queryParams; - const dto = await this.get(fullUrl); - - const task: TaskWithDetails = this.transformTaskDto(dto) as TaskWithDetails; - - this.logger.info(`Fetched task ${taskId} successfully`); - return task; + try { + const url = this.buildProjectResourceUrl(projectId, 'tasks/{taskId}', { taskId }); + const queryParams = autoAssign ? '?autoAssign=true' : ''; + const fullUrl = url + queryParams; + const dto = await this.get(fullUrl); + + const task: TaskWithDetails = this.transformTaskDto(dto) as TaskWithDetails; + + this.logger.info(`Fetched task ${taskId} successfully`); + return task; + } catch (error: any) { + // Handle 409 Task assigned to another user with redirect to alternative task + if (error.status === 409 && error.data?.redirectToTask) { + // Create a special error that contains redirect information + const redirectError = new Error(error.data.message || 'This task is assigned to another user.'); + (redirectError as any).redirectToTask = error.data.redirectToTask; + (redirectError as any).alternativeTask = error.data.alternativeTask; + (redirectError as any).toastMessage = error.data.toastMessage; + throw redirectError; + } + + // Handle 404 No tasks available with redirect to task view + if (error.status === 404 && error.data?.redirectToTaskView) { + // Create a special error that contains redirect information + const redirectError = new Error(error.data.message || 'No tasks available for annotation.'); + (redirectError as any).redirectToTaskView = true; + (redirectError as any).toastMessage = error.data.toastMessage; + throw redirectError; + } + + // Re-throw other errors + throw error; + } } /** @@ -600,9 +736,20 @@ class TaskService extends BaseProjectService { }); const response = await this.get(`${url}?${params}`); - this.logger.info(`Navigation context: position=${response.currentPosition}/${response.totalTasks}, hasNext=${response.hasNext}, hasPrevious=${response.hasPrevious}`); + this.logger.info(`Navigation context: position=${response.currentPosition}/${response.totalTasks}, hasNext=${response.hasNext}, hasPrevious=${response.hasPrevious}, available=${response.availableTasks}, completed=${response.completedTasks}, assigned=${response.assignedTasks}`); return response; } + + /** + * Get task availability information for a project or workflow stage + * This provides the data needed for task counter displays + */ + async getTaskAvailability(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting task availability for project ${projectId}, current task ${currentTaskId}`, { workflowStageId }); + + // Use navigation context to get availability info + return await this.getNavigationContext(projectId, currentTaskId, workflowStageId); + } } export const taskService = new TaskService(); \ No newline at end of file diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/stores/workspaceStore.ts index 090ea017..c729c0a0 100644 --- a/frontend/src/stores/workspaceStore.ts +++ b/frontend/src/stores/workspaceStore.ts @@ -240,20 +240,26 @@ export const useWorkspaceStore = defineStore("workspace", { const numericProjectId = parseInt(this.currentProjectId, 10); const numericAssetId = parseInt(this.currentAssetId, 10); - const tasksData = await workspaceLoader.loadTasksForAsset( - numericProjectId, - numericAssetId, - this.initialTaskId || undefined - ); + try { + const tasksData = await workspaceLoader.loadTasksForAsset( + numericProjectId, + numericAssetId, + this.initialTaskId || undefined + ); - // Update task state - this.currentTaskData = tasksData.currentTask; - this.currentTaskId = tasksData.currentTask?.id || null; - this.currentWorkflowStageType = tasksData.workflowStageType; - this.navigationContext = tasksData.navigationContext; + // Update task state + this.currentTaskData = tasksData.currentTask; + this.currentTaskId = tasksData.currentTask?.id || null; + this.currentWorkflowStageType = tasksData.workflowStageType; + this.navigationContext = tasksData.navigationContext; - if (tasksData.currentTask) { - logger.info(`Loaded task ${tasksData.currentTask.id}`); + if (tasksData.currentTask) { + logger.info(`Loaded task ${tasksData.currentTask.id}`); + } + } catch (error) { + // Check if this is a task access restriction error from workspaceLoader + logger.error('Failed to load tasks:', error); + throw error; // Re-throw the error so it can be caught by loadAsset } }, @@ -328,11 +334,35 @@ export const useWorkspaceStore = defineStore("workspace", { } // Step 3: Load all workspace data in parallel - await Promise.allSettled([ + const results = await Promise.allSettled([ this.loadAnnotations(), this.loadTasks() ]); + // Check if task loading failed due to access restrictions + const taskLoadResult = results[1]; + if (taskLoadResult.status === 'rejected') { + const error = taskLoadResult.reason; + // Check if this is a task access restriction error that should redirect to alternative task + if (error?.shouldRedirectToTask) { + const redirectError = new Error(error.error || 'Task assigned to another user'); + (redirectError as any).redirectToTask = error.shouldRedirectToTask; + (redirectError as any).alternativeTask = error.alternativeTask; + (redirectError as any).toastMessage = error.toastMessage || 'This task was assigned to another user. We found you an alternative task.'; + throw redirectError; + } + + // Check if this is a task access restriction error that should redirect to task view + if (error?.shouldRedirectToTaskView) { + const redirectError = new Error(error.error || 'Task access denied'); + (redirectError as any).redirectToTaskView = true; + (redirectError as any).toastMessage = error.toastMessage || 'You cannot access this task.'; + throw redirectError; + } + // For other task loading errors, log but continue + logger.warn('Task loading failed, but continuing with workspace load:', error); + } + // Step 4: Load label scheme (depends on tasks being loaded first) await this.loadLabelScheme(); @@ -341,6 +371,10 @@ export const useWorkspaceStore = defineStore("workspace", { logger.info(`Successfully loaded asset ${assetId} for project ${projectId}`); } catch (error) { + // Check if this is a redirect error and re-throw it + if ((error as any)?.redirectToTaskView || (error as any)?.redirectToTask) { + throw error; + } this.handleWorkspaceLoadError(error, assetId); } }, @@ -771,7 +805,10 @@ export const useWorkspaceStore = defineStore("workspace", { hasPrevious: navigation.hasPrevious, currentPosition: navigation.currentPosition, totalTasks: navigation.totalTasks, - message: navigation.message + message: navigation.message, + availableTasks: navigation.availableTasks, + completedTasks: navigation.completedTasks, + assignedTasks: navigation.assignedTasks }; return { @@ -815,7 +852,10 @@ export const useWorkspaceStore = defineStore("workspace", { hasPrevious: navigation.hasPrevious, currentPosition: navigation.currentPosition, totalTasks: navigation.totalTasks, - message: navigation.message + message: navigation.message, + availableTasks: navigation.availableTasks, + completedTasks: navigation.completedTasks, + assignedTasks: navigation.assignedTasks }; return { diff --git a/frontend/src/stores/workspaceStore.types.ts b/frontend/src/stores/workspaceStore.types.ts index 275861f3..db7dd850 100644 --- a/frontend/src/stores/workspaceStore.types.ts +++ b/frontend/src/stores/workspaceStore.types.ts @@ -16,6 +16,12 @@ export interface TaskNavigationContext { currentPosition: number; totalTasks: number; message?: string | null; + /** Number of available tasks (unlocked and can be worked on) */ + availableTasks: number; + /** Number of completed tasks by the current user */ + completedTasks: number; + /** Number of assigned tasks (being worked on by others) */ + assignedTasks: number; } /** diff --git a/frontend/src/views/AnnotationWorkspace.vue b/frontend/src/views/AnnotationWorkspace.vue index 8009f093..705057a1 100644 --- a/frontend/src/views/AnnotationWorkspace.vue +++ b/frontend/src/views/AnnotationWorkspace.vue @@ -415,7 +415,72 @@ const handleVisibilityChange = () => { onMounted(async () => { const taskId = route.query.taskId as string | undefined; - await workspaceStore.loadAsset(props.projectId, props.assetId, taskId); + + try { + await workspaceStore.loadAsset(props.projectId, props.assetId, taskId); + } catch (error: any) { + // Handle redirect to alternative task + if (error?.redirectToTask) { + showToast( + 'Task Reassigned', + error.toastMessage || 'This task was assigned to another user. We found you an available task to work on.', + 'info', + { duration: 5000 } + ); + + // Redirect to the alternative task + const alternativeTaskId = error.redirectToTask; + const alternativeTask = error.alternativeTask; + + // Navigate to the alternative task + router.replace({ + name: 'AnnotationWorkspace', + params: { + projectId: props.projectId, + assetId: alternativeTask?.assetId?.toString() || props.assetId + }, + query: { + taskId: alternativeTaskId.toString() + } + }); + return; + } + + // Handle task access errors and redirect to task view + if (error?.redirectToTaskView) { + showToast( + 'Access Denied', + error.toastMessage || 'You cannot access this task.', + 'error', + { duration: 5000 } + ); + + // Redirect back to task view + const currentTask = workspaceStore.getCurrentTask; + if (currentTask) { + router.push({ + name: 'StageTasks', + params: { + projectId: props.projectId, + workflowId: currentTask.workflowId.toString(), + stageId: currentTask.workflowStageId.toString() + } + }); + } else { + // Fallback to project dashboard if no current task context + router.push({ + name: 'ProjectDashboard', + params: { + projectId: props.projectId + } + }); + } + return; + } + + // Handle other errors normally + console.error('Failed to load workspace:', error); + } // Add multiple event listeners for better reliability window.addEventListener('beforeunload', handleBeforeUnload); diff --git a/frontend/src/views/project/TasksView.vue b/frontend/src/views/project/TasksView.vue index d3ec8947..b08e9445 100644 --- a/frontend/src/views/project/TasksView.vue +++ b/frontend/src/views/project/TasksView.vue @@ -223,6 +223,7 @@ import { usePermissions } from '@/composables/usePermissions'; import { useAssetPreview } from '@/composables/useAssetPreview'; import { PERMISSIONS } from '@/services/auth/permissions.types'; import { useProjectStore } from '@/stores/projectStore'; +import { useAuthStore } from '@/stores/authStore'; import { taskService, workflowStageService, assetService, exportService } from '@/services/project'; import { useTaskSelection } from '@/composables/useTaskSelection'; import { taskBulkOperations } from '@/services/project'; @@ -298,6 +299,15 @@ const canReviewTasks = computed(() => { return hasProjectPermission(PERMISSIONS.ANNOTATION.REVIEW); }); +const isUserManager = computed(() => { + return canManageTasks.value; +}); + +const currentUserEmail = computed(() => { + const authStore = useAuthStore(); + return authStore.user?.email; +}); + const isTaskClickable = (task: TaskTableRow): boolean => { // Deferred tasks can only be opened by managers if (task.status === TaskStatus.DEFERRED && !canManageTasks.value) { @@ -319,6 +329,11 @@ const isTaskClickable = (task: TaskTableRow): boolean => { return false; } + // Nobody should be able to click on tasks assigned to someone else + if (task.assignedTo && task.assignedTo !== currentUserEmail.value) { + return false; + } + return true; }; @@ -452,12 +467,20 @@ const loadTasks = async () => { pageSize: pageSize.value }; - const response = await taskService.getEnrichedTasksForStage( - projectId.value, - workflowId.value, - stageId.value, - params - ); + // Managers can see all tasks, but other roles should only see tasks assigned to them or unassigned tasks + const response = isUserManager.value + ? await taskService.getEnrichedTasksForStage( + projectId.value, + workflowId.value, + stageId.value, + params + ) + : await taskService.getEnrichedMyTasksOrUnassignedForStage( + projectId.value, + workflowId.value, + stageId.value, + params + ); tasks.value = response.tasks; totalItems.value = response.totalCount; From 331e709bf77631f3a3305592906c3f18442d4b65 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 18:21:51 +0200 Subject: [PATCH 9/9] ref: Remove task locking service dependencies from AuthControllerTests, TaskNavigationServiceTests, TaskServiceTests, and TaskServiceUnifiedStatusTests --- .../Controllers/AuthControllerTests.cs | 10 +- .../Services/TaskLockingServiceTests.cs | 225 ------------------ .../Services/TaskNavigationServiceTests.cs | 28 +-- .../Server.Tests/Services/TaskServiceTests.cs | 3 - .../Services/TaskServiceUnifiedStatusTests.cs | 33 +-- 5 files changed, 16 insertions(+), 283 deletions(-) delete mode 100644 server/Server.Tests/Services/TaskLockingServiceTests.cs diff --git a/server/Server.Tests/Controllers/AuthControllerTests.cs b/server/Server.Tests/Controllers/AuthControllerTests.cs index 117dd375..54cbb68d 100644 --- a/server/Server.Tests/Controllers/AuthControllerTests.cs +++ b/server/Server.Tests/Controllers/AuthControllerTests.cs @@ -22,7 +22,6 @@ public class AuthControllerTests private readonly Mock _mockAuthService; private readonly Mock> _mockLogger; private readonly Mock _mockEnvironment; - private readonly Mock _mockTaskLocking; private readonly AuthController _controller; public AuthControllerTests() @@ -30,8 +29,7 @@ public AuthControllerTests() _mockAuthService = new Mock(); _mockLogger = new Mock>(); _mockEnvironment = new Mock(); - _mockTaskLocking = new Mock(); - _controller = new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, _mockLogger.Object, _mockEnvironment.Object); + _controller = new AuthController(_mockAuthService.Object, _mockLogger.Object, _mockEnvironment.Object); // Setup HttpContext with mocked Response and Cookies var mockHttpContext = new Mock(); @@ -54,7 +52,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenAuthServiceIsNull( { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(null!, _mockTaskLocking.Object, _mockLogger.Object, _mockEnvironment.Object)); + new AuthController(null!, _mockLogger.Object, _mockEnvironment.Object)); } [Fact] @@ -62,7 +60,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenLoggerIsNull() { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, null!, _mockEnvironment.Object)); + new AuthController(_mockAuthService.Object, null!, _mockEnvironment.Object)); } [Fact] @@ -70,7 +68,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenEnvironmentIsNull( { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, _mockLogger.Object, null!)); + new AuthController(_mockAuthService.Object, _mockLogger.Object, null!)); } #endregion diff --git a/server/Server.Tests/Services/TaskLockingServiceTests.cs b/server/Server.Tests/Services/TaskLockingServiceTests.cs deleted file mode 100644 index cf5bc7fe..00000000 --- a/server/Server.Tests/Services/TaskLockingServiceTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using server.Models.Domain; -using server.Repositories.Interfaces; -using server.Services; -using System.Linq.Expressions; -using Xunit; -using LaberisTask = server.Models.Domain.Task; - -namespace Server.Tests.Services; - -public class TaskLockingServiceTests -{ - private readonly Mock _mockTaskRepository; - private readonly Mock> _mockLogger; - private readonly TaskLockingService _service; - - public TaskLockingServiceTests() - { - _mockTaskRepository = new Mock(); - _mockLogger = new Mock>(); - _service = new TaskLockingService(_mockTaskRepository.Object, _mockLogger.Object); - } - - [Fact] - public async System.Threading.Tasks.Task TryLockTaskAsync_WithUnlockedTask_ShouldLockSuccessfully() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var task = new LaberisTask - { - TaskId = taskId, - LockedByUserId = null, - RowVersion = [] - }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)) - .ReturnsAsync(task); - - // Act - var result = await _service.TryLockTaskAsync(taskId, userId); - - // Assert - Assert.True(result); - Assert.Equal(userId, task.LockedByUserId); - Assert.NotNull(task.LockedAt); - Assert.NotNull(task.LockExpiresAt); - _mockTaskRepository.Verify(x => x.Update(task), Times.Once); - _mockTaskRepository.Verify(x => x.SaveChangesAsync(), Times.Once); - } - - [Fact] - public async System.Threading.Tasks.Task TryLockTaskAsync_WithTaskLockedByOtherUser_ShouldFail() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var otherUserId = "user456"; - var task = new LaberisTask - { - TaskId = taskId, - LockedByUserId = otherUserId, - LockExpiresAt = DateTime.UtcNow.AddMinutes(25), - RowVersion = [] - }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)) - .ReturnsAsync(task); - - // Act - var result = await _service.TryLockTaskAsync(taskId, userId); - - // Assert - Assert.False(result); - _mockTaskRepository.Verify(x => x.Update(It.IsAny()), Times.Never); - } - - [Fact] - public async System.Threading.Tasks.Task TryLockTaskAsync_WithConcurrencyConflict_ShouldFail() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var task = new LaberisTask { TaskId = taskId, LockedByUserId = null, RowVersion = new byte[0] }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)).ReturnsAsync(task); - _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ThrowsAsync(new DbUpdateConcurrencyException()); - - // Act - var result = await _service.TryLockTaskAsync(taskId, userId); - - // Assert - Assert.False(result); - } - - [Fact] - public async System.Threading.Tasks.Task ReleaseLockAsync_WithOwnedLock_ShouldReleaseSuccessfully() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var task = new LaberisTask - { - TaskId = taskId, - LockedByUserId = userId, - LockExpiresAt = DateTime.UtcNow.AddMinutes(25), - RowVersion = [] - }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)) - .ReturnsAsync(task); - - // Act - var result = await _service.ReleaseLockAsync(taskId, userId); - - // Assert - Assert.True(result); - Assert.Null(task.LockedByUserId); - Assert.Null(task.LockedAt); - Assert.Null(task.LockExpiresAt); - _mockTaskRepository.Verify(x => x.Update(task), Times.Once); - _mockTaskRepository.Verify(x => x.SaveChangesAsync(), Times.Once); - } - - [Fact] - public async System.Threading.Tasks.Task ReleaseLockAsync_WithConcurrencyConflict_ShouldFail() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var task = new LaberisTask { TaskId = taskId, LockedByUserId = userId, RowVersion = new byte[0] }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)).ReturnsAsync(task); - _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ThrowsAsync(new DbUpdateConcurrencyException()); - - // Act - var result = await _service.ReleaseLockAsync(taskId, userId); - - // Assert - Assert.False(result); - } - - [Fact] - public async System.Threading.Tasks.Task ExtendLockAsync_WithConcurrencyConflict_ShouldFail() - { - // Arrange - var taskId = 1; - var userId = "user123"; - var task = new LaberisTask - { - TaskId = taskId, - LockedByUserId = userId, - LockExpiresAt = DateTime.UtcNow.AddMinutes(10), - RowVersion = new byte[0] - }; - - _mockTaskRepository.Setup(x => x.GetByIdAsync(taskId)).ReturnsAsync(task); - _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ThrowsAsync(new DbUpdateConcurrencyException()); - - // Act - var result = await _service.ExtendLockAsync(taskId, userId); - - // Assert - Assert.False(result); - } - - [Fact] - public async System.Threading.Tasks.Task CleanupExpiredLocksAsync_WithConcurrencyConflict_ShouldReturnZero() - { - // Arrange - var expiredTasks = new List - { - new() { TaskId = 1, LockedByUserId = "user1", LockExpiresAt = DateTime.UtcNow.AddMinutes(-10), RowVersion = [] }, - }; - - _mockTaskRepository.Setup(x => x.GetAllAsync( - It.IsAny>>(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )).ReturnsAsync(expiredTasks); - - _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ThrowsAsync(new DbUpdateConcurrencyException()); - - // Act - var result = await _service.CleanupExpiredLocksAsync(); - - // Assert - Assert.Equal(0, result); - } - - [Fact] - public async System.Threading.Tasks.Task ReleaseAllUserLocksAsync_WithConcurrencyConflict_ShouldReturnZero() - { - // Arrange - var userId = "user123"; - var userTasks = new List - { - new() { TaskId = 1, LockedByUserId = userId, RowVersion = [] } - }; - - _mockTaskRepository.Setup(x => x.GetAllAsync( - It.IsAny>>(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - )).ReturnsAsync(userTasks); - - _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ThrowsAsync(new DbUpdateConcurrencyException()); - - // Act - var result = await _service.ReleaseAllUserLocksAsync(userId); - - // Assert - Assert.Equal(0, result); - } -} diff --git a/server/Server.Tests/Services/TaskNavigationServiceTests.cs b/server/Server.Tests/Services/TaskNavigationServiceTests.cs index f1643ce8..ff25bf1f 100644 --- a/server/Server.Tests/Services/TaskNavigationServiceTests.cs +++ b/server/Server.Tests/Services/TaskNavigationServiceTests.cs @@ -13,16 +13,14 @@ namespace Server.Tests.Services; public class TaskNavigationServiceTests { private readonly Mock _mockTaskRepository; - private readonly Mock _mockTaskLockingService; private readonly Mock> _mockLogger; private readonly TaskNavigationService _service; public TaskNavigationServiceTests() { _mockTaskRepository = new Mock(); - _mockTaskLockingService = new Mock(); _mockLogger = new Mock>(); - _service = new TaskNavigationService(_mockTaskRepository.Object, _mockTaskLockingService.Object, _mockLogger.Object); + _service = new TaskNavigationService(_mockTaskRepository.Object, _mockLogger.Object); } #region Helper Methods @@ -83,11 +81,9 @@ public async System.Threading.Tasks.Task GetNextTaskAsync_WithValidCurrentTask_R _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) .ReturnsAsync(tasks); - // Set up task locking service to simulate successful lock acquisition - _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) - .ReturnsAsync(true); - _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(2, userId, It.IsAny())) // Next task ID is 2 - .ReturnsAsync(true); + // Set up task repository to allow auto-assignment updates + _mockTaskRepository.Setup(x => x.Update(It.IsAny())); + _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ReturnsAsync(1); // Act var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId); @@ -194,11 +190,9 @@ public async System.Threading.Tasks.Task GetPreviousTaskAsync_WithValidCurrentTa _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) .ReturnsAsync(tasks); - // Set up task locking service to simulate successful lock acquisition - _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) - .ReturnsAsync(true); - _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(1, userId, It.IsAny())) // Previous task ID is 1 - .ReturnsAsync(true); + // Set up task repository to allow auto-assignment updates + _mockTaskRepository.Setup(x => x.Update(It.IsAny())); + _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ReturnsAsync(1); // Act var result = await _service.GetPreviousTaskAsync(userId, currentTaskId, workflowStageId); @@ -382,11 +376,9 @@ public async System.Threading.Tasks.Task GetNextTaskAsync_WithWorkflowStageFilte _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId20)) .ReturnsAsync(tasksStage20); - // Set up task locking service to simulate successful lock acquisition - _mockTaskLockingService.Setup(x => x.ReleaseLockAsync(currentTaskId, userId)) - .ReturnsAsync(true); - _mockTaskLockingService.Setup(x => x.TryLockTaskAsync(2, userId, It.IsAny())) // Next task ID is 2 - .ReturnsAsync(true); + // Set up task repository to allow auto-assignment updates + _mockTaskRepository.Setup(x => x.Update(It.IsAny())); + _mockTaskRepository.Setup(x => x.SaveChangesAsync()).ReturnsAsync(1); // Act - Get next task from stage 10 only var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId10); diff --git a/server/Server.Tests/Services/TaskServiceTests.cs b/server/Server.Tests/Services/TaskServiceTests.cs index c1d8b281..d5a3eaec 100644 --- a/server/Server.Tests/Services/TaskServiceTests.cs +++ b/server/Server.Tests/Services/TaskServiceTests.cs @@ -19,7 +19,6 @@ public class TaskServiceTests private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; - private readonly Mock _mockTaskLockingService; private readonly Mock> _mockLogger; private readonly TaskService _taskService; @@ -33,7 +32,6 @@ public TaskServiceTests() _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); - _mockTaskLockingService = new Mock(); _mockLogger = new Mock>(); _taskService = new TaskService( @@ -44,7 +42,6 @@ public TaskServiceTests() _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, - _mockTaskLockingService.Object, _mockLogger.Object ); } diff --git a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs index 43692c32..fc1c98d3 100644 --- a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs +++ b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs @@ -21,7 +21,6 @@ public class TaskServiceUnifiedStatusTests private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; - private readonly Mock _mockTaskLockingService; private readonly Mock> _mockLogger; private readonly TaskService _taskService; @@ -34,12 +33,8 @@ public TaskServiceUnifiedStatusTests() _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); - _mockTaskLockingService = new Mock(); _mockLogger = new Mock>(); - // Set up default mock behavior for task locking service - SetupTaskLockingServiceDefaults(); - _taskService = new TaskService( _mockTaskRepository.Object, _mockAssetRepository.Object, @@ -48,36 +43,10 @@ public TaskServiceUnifiedStatusTests() _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, - _mockTaskLockingService.Object, _mockLogger.Object ); } - /// - /// Sets up default mock behavior for TaskLockingService to simulate successful lock ownership - /// - private void SetupTaskLockingServiceDefaults() - { - // By default, assume the user owns the lock for any task - _mockTaskLockingService - .Setup(x => x.IsTaskLockedByUserAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); - - // By default, assume tasks are not locked by other users - _mockTaskLockingService - .Setup(x => x.IsTaskLockedAsync(It.IsAny())) - .ReturnsAsync(false); - - // By default, assume lock acquisition succeeds - _mockTaskLockingService - .Setup(x => x.TryLockTaskAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(true); - - // By default, assume lock release succeeds - _mockTaskLockingService - .Setup(x => x.ReleaseLockAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); - } [Fact] public async System.Threading.Tasks.Task ChangeTaskStatusAsync_TaskNotFound_ShouldReturnNull() @@ -287,6 +256,7 @@ public async System.Threading.Tasks.Task ChangeTaskStatusAsync_ArchiveCompleted_ const int taskId = 1; const string userId = "manager123"; var existingTask = CreateTestTask(taskId, WorkflowStageType.COMPLETION); + existingTask.AssignedToUserId = userId; // Assign to the manager for this test existingTask.Status = TaskStatus.COMPLETED; // Set status explicitly existingTask.CompletedAt = DateTime.UtcNow.AddHours(-1); // Previously completed @@ -425,6 +395,7 @@ private static LaberisTask CreateTestTask(int taskId, WorkflowStageType stageTyp AssetId = 1, Priority = 1, WorkflowStageId = 1, + AssignedToUserId = "user123", // Assign task to test user to pass assignment validation CreatedAt = DateTime.UtcNow.AddDays(-1), UpdatedAt = DateTime.UtcNow.AddHours(-1), RowVersion = [],