From 0b8d2bb8b0274e849371f422203188a7091ee0b5 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 5 Sep 2025 19:23:25 +0200 Subject: [PATCH 01/23] refactor: Remove unused Mock from TaskServiceTests and TaskServiceUnifiedStatusTests --- server/Server.Tests/Services/TaskServiceTests.cs | 3 --- server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs | 3 --- 2 files changed, 6 deletions(-) diff --git a/server/Server.Tests/Services/TaskServiceTests.cs b/server/Server.Tests/Services/TaskServiceTests.cs index 8d417f72..306fba8f 100644 --- a/server/Server.Tests/Services/TaskServiceTests.cs +++ b/server/Server.Tests/Services/TaskServiceTests.cs @@ -16,7 +16,6 @@ public class TaskServiceTests private readonly Mock _mockTaskEventRepository; private readonly Mock _mockTaskEventService; private readonly Mock _mockTaskStatusValidator; - private readonly Mock _mockAssetService; private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; @@ -30,7 +29,6 @@ public TaskServiceTests() _mockTaskEventRepository = new Mock(); _mockTaskEventService = new Mock(); _mockTaskStatusValidator = new Mock(); - _mockAssetService = new Mock(); _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); @@ -41,7 +39,6 @@ public TaskServiceTests() _mockAssetRepository.Object, _mockTaskEventService.Object, _mockTaskStatusValidator.Object, - _mockAssetService.Object, _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, diff --git a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs index 7cc06317..8a31eb5f 100644 --- a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs +++ b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs @@ -18,7 +18,6 @@ public class TaskServiceUnifiedStatusTests private readonly Mock _mockAssetRepository; private readonly Mock _mockTaskEventService; private readonly Mock _mockTaskStatusValidator; - private readonly Mock _mockAssetService; private readonly Mock _mockWorkflowStageRepository; private readonly Mock> _mockUserManager; private readonly Mock _mockProjectMembershipService; @@ -31,7 +30,6 @@ public TaskServiceUnifiedStatusTests() _mockAssetRepository = new Mock(); _mockTaskEventService = new Mock(); _mockTaskStatusValidator = new Mock(); - _mockAssetService = new Mock(); _mockWorkflowStageRepository = new Mock(); _mockUserManager = MockUserManager(); _mockProjectMembershipService = new Mock(); @@ -43,7 +41,6 @@ public TaskServiceUnifiedStatusTests() _mockAssetRepository.Object, _mockTaskEventService.Object, _mockTaskStatusValidator.Object, - _mockAssetService.Object, _mockWorkflowStageRepository.Object, _mockUserManager.Object, _mockProjectMembershipService.Object, From c30b7772e0619036d048fa88adf4af799f79bfbd Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 5 Sep 2025 19:23:31 +0200 Subject: [PATCH 02/23] refactor: Remove unused IAssetService references from TaskService and IAssetService interface --- server/Server/Services/Interfaces/IAssetService.cs | 3 --- server/Server/Services/TaskService.cs | 13 ++++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/Server/Services/Interfaces/IAssetService.cs b/server/Server/Services/Interfaces/IAssetService.cs index 7907a6fa..4fef9809 100644 --- a/server/Server/Services/Interfaces/IAssetService.cs +++ b/server/Server/Services/Interfaces/IAssetService.cs @@ -1,9 +1,6 @@ using server.Models.Common; using server.Models.DTOs.Asset; using server.Models.Domain; -using server.Models.Domain.Enums; -using TaskStatus = server.Models.Domain.Enums.TaskStatus; -using server.Models.Internal; namespace server.Services.Interfaces; diff --git a/server/Server/Services/TaskService.cs b/server/Server/Services/TaskService.cs index 98bffe2e..cba36620 100644 --- a/server/Server/Services/TaskService.cs +++ b/server/Server/Services/TaskService.cs @@ -1,5 +1,4 @@ using server.Core; -using server.Models.Domain; using server.Models.DTOs.Task; using server.Models.Domain.Enums; using server.Models.Common; @@ -20,7 +19,6 @@ public class TaskService : ITaskService private readonly IAssetRepository _assetRepository; private readonly ITaskEventService _taskEventService; private readonly ITaskStatusValidator _taskStatusValidator; - private readonly IAssetService _assetService; private readonly IWorkflowStageRepository _workflowStageRepository; private readonly UserManager _userManager; private readonly IProjectMembershipService _projectMembershipService; @@ -31,7 +29,6 @@ public TaskService( IAssetRepository assetRepository, ITaskEventService taskEventService, ITaskStatusValidator taskStatusValidator, - IAssetService assetService, IWorkflowStageRepository workflowStageRepository, UserManager userManager, IProjectMembershipService projectMembershipService, @@ -41,7 +38,6 @@ public TaskService( _assetRepository = assetRepository ?? throw new ArgumentNullException(nameof(assetRepository)); _taskEventService = taskEventService ?? throw new ArgumentNullException(nameof(taskEventService)); _taskStatusValidator = taskStatusValidator ?? throw new ArgumentNullException(nameof(taskStatusValidator)); - _assetService = assetService ?? throw new ArgumentNullException(nameof(assetService)); _workflowStageRepository = workflowStageRepository ?? throw new ArgumentNullException(nameof(workflowStageRepository)); _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); _projectMembershipService = projectMembershipService ?? throw new ArgumentNullException(nameof(projectMembershipService)); @@ -448,7 +444,7 @@ private static TaskStatus GetInitialTaskStatusForStage(WorkflowStageType stageTy /// The user performing the assignment /// The user being assigned the task (null for unassignment) /// Thrown when the assignment is not permitted - private async System.Threading.Tasks.Task ValidateTaskAssignmentPermissionAsync(int projectId, string assigningUserId, string? targetUserId) + private async Task ValidateTaskAssignmentPermissionAsync(int projectId, string assigningUserId, string? targetUserId) { var assigningUserMembership = await _projectMembershipService.GetProjectMembershipAsync(assigningUserId, projectId); if (assigningUserMembership == null) @@ -457,6 +453,13 @@ private async System.Threading.Tasks.Task ValidateTaskAssignmentPermissionAsync( throw new UnauthorizedAccessException("User is not a member of this project"); } + // Allow self-assignment for all roles + if (assigningUserId == targetUserId && !string.IsNullOrEmpty(targetUserId)) + { + _logger.LogDebug("User {UserId} is assigning task to themselves", assigningUserId); + return; + } + // MANAGER role can assign tasks to anyone or unassign tasks if (assigningUserMembership.Role == ProjectRole.MANAGER) { From 034f5449d035e698f229269a0535b9833987c6c7 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 5 Sep 2025 19:23:36 +0200 Subject: [PATCH 03/23] refactor: Update import paths for asset-related types and errors in AssetService --- frontend/src/services/project/asset/assetService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/services/project/asset/assetService.ts b/frontend/src/services/project/asset/assetService.ts index 86a31334..45a04418 100644 --- a/frontend/src/services/project/asset/assetService.ts +++ b/frontend/src/services/project/asset/assetService.ts @@ -1,8 +1,8 @@ import { BaseProjectService } from '../baseProjectService'; import { buildQueryParams } from '@/services/base/requests'; -import type { AssetListParams } from '@/services/project/asset'; -import type { UploadResult, BulkUploadResult } from '@/services/project/asset'; -import { NoFilesProvidedError } from '@/services/project/asset'; +import type { AssetListParams } from './requests'; +import type { UploadResult, BulkUploadResult } from './upload.types'; +import { NoFilesProvidedError } from './uploadErrors'; import apiClient from '../../apiClient'; import { transformApiError, isValidApiResponse, isValidPaginatedResponse } from '@/services/interceptors'; import type { PaginatedResponse } from '@/services/base/paginatedResponse'; From 0873021ba1212f21b95925b3029942bbf2bf5a43 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:53:07 +0200 Subject: [PATCH 04/23] Add RowVersion token and locking mechanism to tasks table - Introduced a new migration to add a RowVersion column for concurrency control in the tasks table. - Added properties for locking mechanism: LockExpiresAt, LockedAt, and LockedByUserId. - Updated the LaberisDbContextModelSnapshot to reflect the new schema changes. --- ...906185602_AddTaskLockingFields.Designer.cs | 1897 ++++++++++++++++ .../20250906185602_AddTaskLockingFields.cs | 71 + ...20250906192039_RowVersionToken.Designer.cs | 1904 +++++++++++++++++ .../Laberis/20250906192039_RowVersionToken.cs | 30 + .../Laberis/LaberisDbContextModelSnapshot.cs | 28 + 5 files changed, 3930 insertions(+) create mode 100644 server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.Designer.cs create mode 100644 server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.cs create mode 100644 server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.Designer.cs create mode 100644 server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.cs diff --git a/server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.Designer.cs b/server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.Designer.cs new file mode 100644 index 00000000..e57d4faf --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.Designer.cs @@ -0,0 +1,1897 @@ +// +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("20250906185602_AddTaskLockingFields")] + partial class AddTaskLockingFields + { + /// + 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("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"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("priority"); + + b.Property("ProjectId") + .HasColumnType("integer") + .HasColumnName("project_id"); + + 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("LockedByUserId"); + + 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("ApplicationUser", "LockedByUser") + .WithMany() + .HasForeignKey("LockedByUserId") + .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("LockedByUser"); + + 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/20250906185602_AddTaskLockingFields.cs b/server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.cs new file mode 100644 index 00000000..a8be8dba --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250906185602_AddTaskLockingFields.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace server.Data.Migrations.Laberis +{ + /// + public partial class AddTaskLockingFields : Migration + { + /// + protected override void Up(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); + } + + /// + protected override void Down(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"); + } + } +} diff --git a/server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.Designer.cs b/server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.Designer.cs new file mode 100644 index 00000000..33f7ac77 --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.Designer.cs @@ -0,0 +1,1904 @@ +// +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("20250906192039_RowVersionToken")] + partial class RowVersionToken + { + /// + 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("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"); + + 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("LockedByUserId"); + + 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("ApplicationUser", "LockedByUser") + .WithMany() + .HasForeignKey("LockedByUserId") + .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("LockedByUser"); + + 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/20250906192039_RowVersionToken.cs b/server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.cs new file mode 100644 index 00000000..2ea83246 --- /dev/null +++ b/server/Server/Data/Migrations/Laberis/20250906192039_RowVersionToken.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace server.Data.Migrations.Laberis +{ + /// + public partial class RowVersionToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "row_version", + table: "tasks", + type: "bytea", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "row_version", + table: "tasks"); + } + } +} diff --git a/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs b/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs index 9fa2b711..d263b702 100644 --- a/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs +++ b/server/Server/Data/Migrations/Laberis/LaberisDbContextModelSnapshot.cs @@ -1026,6 +1026,18 @@ 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"); @@ -1040,6 +1052,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("project_id"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + b.Property("Status") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -1082,6 +1101,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("LastWorkedOnByUserId"); + b.HasIndex("LockedByUserId"); + b.HasIndex("ProjectId"); b.HasIndex("WorkflowId"); @@ -1638,6 +1659,11 @@ 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") @@ -1662,6 +1688,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("LastWorkedOnByUser"); + b.Navigation("LockedByUser"); + b.Navigation("Project"); b.Navigation("Workflow"); From a02df43649cd8e87dbb5d8517d6e938e767807a9 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:53:20 +0200 Subject: [PATCH 05/23] feat: Add TaskNavigationDto and enhance Task model with locking mechanism and concurrency token --- .../Models/DTOs/Task/TaskNavigationDto.cs | 42 +++++++++++++++++++ server/Server/Models/Domain/Task.cs | 12 ++++++ 2 files changed, 54 insertions(+) create mode 100644 server/Server/Models/DTOs/Task/TaskNavigationDto.cs diff --git a/server/Server/Models/DTOs/Task/TaskNavigationDto.cs b/server/Server/Models/DTOs/Task/TaskNavigationDto.cs new file mode 100644 index 00000000..3c34b550 --- /dev/null +++ b/server/Server/Models/DTOs/Task/TaskNavigationDto.cs @@ -0,0 +1,42 @@ +namespace server.Models.DTOs.Task; + +/// +/// Response DTO for task navigation operations +/// +public class TaskNavigationDto +{ + /// + /// ID of the next/previous task, null if none available + /// + public int? TaskId { get; set; } + + /// + /// Asset ID of the next/previous task, null if none available + /// + public int? AssetId { get; set; } + + /// + /// Whether there is a next task available + /// + public bool HasNext { get; set; } + + /// + /// Whether there is a previous task available + /// + public bool HasPrevious { get; set; } + + /// + /// Current position in the navigation sequence (1-based) + /// + public int CurrentPosition { get; set; } + + /// + /// Total number of navigable tasks + /// + public int TotalTasks { get; set; } + + /// + /// Optional message for user feedback + /// + public string? Message { 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 67c38c96..719142e5 100644 --- a/server/Server/Models/Domain/Task.cs +++ b/server/Server/Models/Domain/Task.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace server.Models.Domain; public class Task @@ -25,6 +27,15 @@ 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] + public required byte[] RowVersion { get; set; } // Navigation Properties public virtual Asset Asset { get; set; } = null!; @@ -33,6 +44,7 @@ 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; } = []; From dd475ad4a82658b2ec21f1514dfa5e6f447f4604 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:53:44 +0200 Subject: [PATCH 06/23] feat: Add ITaskLockingService and ITaskNavigationService interfaces for task management --- .../Interfaces/ITaskLockingService.cs | 70 +++++++++++++++++++ .../Interfaces/ITaskNavigationService.cs | 36 ++++++++++ 2 files changed, 106 insertions(+) create mode 100644 server/Server/Services/Interfaces/ITaskLockingService.cs create mode 100644 server/Server/Services/Interfaces/ITaskNavigationService.cs diff --git a/server/Server/Services/Interfaces/ITaskLockingService.cs b/server/Server/Services/Interfaces/ITaskLockingService.cs new file mode 100644 index 00000000..556e0445 --- /dev/null +++ b/server/Server/Services/Interfaces/ITaskLockingService.cs @@ -0,0 +1,70 @@ +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/ITaskNavigationService.cs b/server/Server/Services/Interfaces/ITaskNavigationService.cs new file mode 100644 index 00000000..d87be6cb --- /dev/null +++ b/server/Server/Services/Interfaces/ITaskNavigationService.cs @@ -0,0 +1,36 @@ +using server.Models.DTOs.Task; + +namespace server.Services.Interfaces; + +/// +/// Service for handling task navigation operations +/// +public interface ITaskNavigationService +{ + /// + /// Gets the next available task for the specified user + /// + /// The ID of the user + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Task navigation information + Task GetNextTaskAsync(string userId, int currentTaskId, int? workflowStageId = null); + + /// + /// Gets the previous available task for the specified user + /// + /// The ID of the user + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Task navigation information + Task GetPreviousTaskAsync(string userId, int currentTaskId, int? workflowStageId = null); + + /// + /// Gets the navigation context for the current task + /// + /// The ID of the user + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Task navigation context information + Task GetNavigationContextAsync(string userId, int currentTaskId, int? workflowStageId = null); +} \ No newline at end of file From af5fc30f0fd147e78e4dca4c1fc8189dfa5731cd Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:53:51 +0200 Subject: [PATCH 07/23] feat: Add GetNavigableTasksAsync method to retrieve user-specific tasks with filtering and ordering --- .../Interfaces/ITaskRepository.cs | 11 +++++++ server/Server/Repositories/TaskRepository.cs | 33 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/server/Server/Repositories/Interfaces/ITaskRepository.cs b/server/Server/Repositories/Interfaces/ITaskRepository.cs index 5004e3c7..489fe00d 100644 --- a/server/Server/Repositories/Interfaces/ITaskRepository.cs +++ b/server/Server/Repositories/Interfaces/ITaskRepository.cs @@ -31,4 +31,15 @@ public interface ITaskRepository : IGenericRepository /// The ID of the user making the change. /// The updated task. Task UpdateTaskStatusAsync(LaberisTask task, Models.Domain.Enums.TaskStatus newStatus, string userId); + + /// + /// Gets navigable tasks for a user in a project, ordered by creation date and task ID. + /// Includes tasks assigned to the user and unassigned tasks (available for assignment). + /// Excludes completed, archived, and vetoed tasks. + /// + /// The ID of the user. + /// The ID of the project. + /// Optional workflow stage ID to filter by. + /// List of navigable tasks ordered by CreatedAt then TaskId. + Task> GetNavigableTasksAsync(string userId, int projectId, int? workflowStageId = null); } diff --git a/server/Server/Repositories/TaskRepository.cs b/server/Server/Repositories/TaskRepository.cs index 5ca4c573..c5369131 100644 --- a/server/Server/Repositories/TaskRepository.cs +++ b/server/Server/Repositories/TaskRepository.cs @@ -27,6 +27,7 @@ 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); } @@ -36,7 +37,8 @@ protected override IQueryable ApplyIncludes(IQueryable return query .Include(t => t.WorkflowStage) .Include(t => t.AssignedToUser) - .Include(t => t.LastWorkedOnByUser); + .Include(t => t.LastWorkedOnByUser) + .Include(t => t.LockedByUser); } protected override IQueryable ApplyFilter(IQueryable query, string? filterOn, string? filterQuery) @@ -206,4 +208,33 @@ public async Task UpdateTaskStatusAsync(LaberisTask task, Models.Do await SaveChangesAsync(); return task; } + + 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); + + if (workflowStageId.HasValue) + { + query = query.Where(t => t.WorkflowStageId == workflowStageId.Value); + } + + return await query + .OrderByDescending(t => t.Priority) + .ThenByDescending(t => t.CreatedAt) + .ThenBy(t => t.TaskId) + .ToListAsync(); + } } From 5242b49d3b713667e2198f9e05e09c832bfb39ae Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:07 +0200 Subject: [PATCH 08/23] feat: Implement TaskLockingService for managing task locks and preventing concurrent access --- server/Server/Services/TaskLockingService.cs | 276 +++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 server/Server/Services/TaskLockingService.cs diff --git a/server/Server/Services/TaskLockingService.cs b/server/Server/Services/TaskLockingService.cs new file mode 100644 index 00000000..384e7e25 --- /dev/null +++ b/server/Server/Services/TaskLockingService.cs @@ -0,0 +1,276 @@ +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 From 617f32947f9acd1afba530bb27acec21fd723287 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:19 +0200 Subject: [PATCH 09/23] feat: Add ExpiredLockCleanupService for automatic cleanup of expired task locks --- .../Services/ExpiredLockCleanupService.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 server/Server/Services/ExpiredLockCleanupService.cs diff --git a/server/Server/Services/ExpiredLockCleanupService.cs b/server/Server/Services/ExpiredLockCleanupService.cs new file mode 100644 index 00000000..9238bae8 --- /dev/null +++ b/server/Server/Services/ExpiredLockCleanupService.cs @@ -0,0 +1,42 @@ +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."); + } +} From 0958a8851a949db46f5a4a232fb1c97270730c14 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:26 +0200 Subject: [PATCH 10/23] feat: Implement TaskNavigationService for managing task navigation and locking tasks --- .../Server/Services/TaskNavigationService.cs | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 server/Server/Services/TaskNavigationService.cs diff --git a/server/Server/Services/TaskNavigationService.cs b/server/Server/Services/TaskNavigationService.cs new file mode 100644 index 00000000..c02434b3 --- /dev/null +++ b/server/Server/Services/TaskNavigationService.cs @@ -0,0 +1,201 @@ +using server.Models.DTOs.Task; +using server.Services.Interfaces; +using server.Repositories.Interfaces; + +namespace server.Services; + +/// +/// Service for handling task navigation operations +/// +public class TaskNavigationService : ITaskNavigationService +{ + private readonly ITaskRepository _taskRepository; + private readonly ITaskLockingService _taskLockingService; + private readonly ILogger _logger; + + public TaskNavigationService(ITaskRepository taskRepository, ITaskLockingService taskLockingService, ILogger logger) + { + _taskRepository = taskRepository ?? throw new ArgumentNullException(nameof(taskRepository)); + _taskLockingService = taskLockingService ?? throw new ArgumentNullException(nameof(taskLockingService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetNextTaskAsync(string userId, int currentTaskId, int? workflowStageId = null) + { + _logger.LogInformation("Getting next task for user {UserId} from current task {CurrentTaskId} in stage {WorkflowStageId}", + userId, currentTaskId, workflowStageId); + + var currentTask = await _taskRepository.GetByIdAsync(currentTaskId); + if (currentTask == null) + { + _logger.LogWarning("Current task {CurrentTaskId} not found", currentTaskId); + return new TaskNavigationDto(); + } + + // Get navigable tasks for the user + var navigableTasks = await _taskRepository.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId); + + var currentIndex = navigableTasks.FindIndex(t => t.TaskId == currentTaskId); + if (currentIndex == -1) + { + _logger.LogWarning("Current task {CurrentTaskId} not found in user's navigable tasks", currentTaskId); + return new TaskNavigationDto + { + CurrentPosition = 0, + TotalTasks = navigableTasks.Count, + Message = "Current task is not in your assigned task list" + }; + } + + var nextIndex = currentIndex + 1; + var hasNext = nextIndex < navigableTasks.Count; + var nextTask = hasNext ? navigableTasks[nextIndex] : null; + + // Calculate if the next task has a task after it + var nextTaskHasNext = hasNext && (nextIndex < navigableTasks.Count - 1); + + 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 + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + Message = hasNext ? null : "No more tasks available" + }; + + // 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); + 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.LogInformation("Next task navigation result: TaskId={TaskId}, HasNext={HasNext}, Position={Position}/{Total}", + result.TaskId, result.HasNext, result.CurrentPosition, result.TotalTasks); + + return result; + } + + public async Task GetPreviousTaskAsync(string userId, int currentTaskId, int? workflowStageId = null) + { + _logger.LogInformation("Getting previous task for user {UserId} from current task {CurrentTaskId} in stage {WorkflowStageId}", + userId, currentTaskId, workflowStageId); + + var currentTask = await _taskRepository.GetByIdAsync(currentTaskId); + if (currentTask == null) + { + _logger.LogWarning("Current task {CurrentTaskId} not found", currentTaskId); + return new TaskNavigationDto(); + } + + // Get navigable tasks for the user + var navigableTasks = await _taskRepository.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId); + + var currentIndex = navigableTasks.FindIndex(t => t.TaskId == currentTaskId); + if (currentIndex == -1) + { + _logger.LogWarning("Current task {CurrentTaskId} not found in user's navigable tasks", currentTaskId); + return new TaskNavigationDto + { + CurrentPosition = 0, + TotalTasks = navigableTasks.Count, + Message = "Current task is not in your assigned task list" + }; + } + + var previousIndex = currentIndex - 1; + var hasPrevious = previousIndex >= 0; + var previousTask = hasPrevious ? navigableTasks[previousIndex] : null; + + // Calculate if the previous task has a task before it + var previousTaskHasPrevious = previousIndex > 0; + + 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 + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count, + Message = hasPrevious ? null : "No previous tasks available" + }; + + // 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); + 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.LogInformation("Previous task navigation result: TaskId={TaskId}, HasPrevious={HasPrevious}, Position={Position}/{Total}", + result.TaskId, result.HasPrevious, result.CurrentPosition, result.TotalTasks); + + return result; + } + + public async Task GetNavigationContextAsync(string userId, int currentTaskId, int? workflowStageId = null) + { + _logger.LogInformation("Getting navigation context for user {UserId} and task {CurrentTaskId} in stage {WorkflowStageId}", + userId, currentTaskId, workflowStageId); + + var currentTask = await _taskRepository.GetByIdAsync(currentTaskId); + if (currentTask == null) + { + _logger.LogWarning("Current task {CurrentTaskId} not found", currentTaskId); + return new TaskNavigationDto(); + } + + // Get navigable tasks for the user + var navigableTasks = await _taskRepository.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId); + + var currentIndex = navigableTasks.FindIndex(t => t.TaskId == currentTaskId); + + // If current task is not in navigable list (e.g., completed task being viewed), + // still provide context about available navigation + if (currentIndex == -1) + { + _logger.LogInformation("Current task {CurrentTaskId} not in navigable list (likely completed/preview mode)", currentTaskId); + return new TaskNavigationDto + { + CurrentPosition = 0, + TotalTasks = navigableTasks.Count, + HasNext = navigableTasks.Count > 0, + HasPrevious = false, + Message = "Viewing task in preview mode" + }; + } + + var result = new TaskNavigationDto + { + TaskId = currentTaskId, + AssetId = currentTask.AssetId, + HasNext = currentIndex < navigableTasks.Count - 1, + HasPrevious = currentIndex > 0, + CurrentPosition = currentIndex + 1, + TotalTasks = navigableTasks.Count + }; + + _logger.LogInformation("Navigation context: Position={Position}/{Total}, HasNext={HasNext}, HasPrevious={HasPrevious}", + result.CurrentPosition, result.TotalTasks, result.HasNext, result.HasPrevious); + + return result; + } + +} \ No newline at end of file From 01e683375b9fc6590bfbb4008acd4644d84d63f8 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:37 +0200 Subject: [PATCH 11/23] feat: Register TaskNavigationService and TaskLockingService in the service collection --- server/Server/Extensions/ServiceCollectionExtensions.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/Server/Extensions/ServiceCollectionExtensions.cs b/server/Server/Extensions/ServiceCollectionExtensions.cs index 0f301c18..861bb190 100644 --- a/server/Server/Extensions/ServiceCollectionExtensions.cs +++ b/server/Server/Extensions/ServiceCollectionExtensions.cs @@ -197,6 +197,8 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); From a2d9628bf59be46e88c7b09dcf25ecc6042cafb3 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:44 +0200 Subject: [PATCH 12/23] feat: Integrate ITaskLockingService into AuthController for user lock management --- server/Server/Controllers/AuthController.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/Server/Controllers/AuthController.cs b/server/Server/Controllers/AuthController.cs index 6702c049..538d1cbb 100644 --- a/server/Server/Controllers/AuthController.cs +++ b/server/Server/Controllers/AuthController.cs @@ -15,12 +15,14 @@ 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, ILogger logger, IWebHostEnvironment environment) + public AuthController(IAuthService authManager, ITaskLockingService taskLockingService, 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)); } @@ -147,6 +149,9 @@ public async Task Logout() return Unauthorized(); } + // Release all task locks held by the user + await _taskLockingService.ReleaseAllUserLocksAsync(userId); + await _authManager.RevokeRefreshTokenAsync(userId); // Clear refresh token cookie From d7f0ec87271ddd26eda3005090300f146076a327 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:48 +0200 Subject: [PATCH 13/23] feat: Add TaskLockController for managing task locks and user lock operations --- .../Server/Controllers/TaskLockController.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 server/Server/Controllers/TaskLockController.cs diff --git a/server/Server/Controllers/TaskLockController.cs b/server/Server/Controllers/TaskLockController.cs new file mode 100644 index 00000000..778e3269 --- /dev/null +++ b/server/Server/Controllers/TaskLockController.cs @@ -0,0 +1,166 @@ +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 From 11230bea7e755abcc9e85e981a2fdc94a2dc5012 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:54:52 +0200 Subject: [PATCH 14/23] feat: Implement TaskNavigationController for managing task navigation operations --- .../Controllers/TaskNavigationController.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 server/Server/Controllers/TaskNavigationController.cs diff --git a/server/Server/Controllers/TaskNavigationController.cs b/server/Server/Controllers/TaskNavigationController.cs new file mode 100644 index 00000000..14f5816f --- /dev/null +++ b/server/Server/Controllers/TaskNavigationController.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using server.Authentication; +using server.Models.DTOs.Task; +using server.Services.Interfaces; +using System.Security.Claims; + +namespace server.Controllers; + +/// +/// Controller for task navigation operations +/// +[Route("api/projects/{projectId:int}/tasks/navigation")] +[ApiController] +[Authorize] +[ProjectAccess] +[EnableRateLimiting("project")] +public class TaskNavigationController : ControllerBase +{ + private readonly ITaskNavigationService _taskNavigationService; + private readonly ILogger _logger; + + public TaskNavigationController( + ITaskNavigationService taskNavigationService, + ILogger logger) + { + _taskNavigationService = taskNavigationService ?? throw new ArgumentNullException(nameof(taskNavigationService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets the next available task for the current user + /// + /// The project ID + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Navigation information for the next task + /// Returns the navigation information + /// If the user is not authenticated + /// If the user doesn't have access to the project + /// If an unexpected error occurs + [HttpGet("next")] + [ProducesResponseType(typeof(TaskNavigationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetNextTask( + int projectId, + [FromQuery] int currentTaskId, + [FromQuery] int? workflowStageId = null) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized("User ID claim not found in token."); + } + + try + { + _logger.LogInformation("Getting next task for user {UserId} in project {ProjectId}, current task {CurrentTaskId}, stage {WorkflowStageId}", + userId, projectId, currentTaskId, workflowStageId); + + var result = await _taskNavigationService.GetNextTaskAsync(userId, currentTaskId, workflowStageId); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting next task for user {UserId} in project {ProjectId}", userId, projectId); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while getting next task."); + } + } + + /// + /// Gets the previous available task for the current user + /// + /// The project ID + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Navigation information for the previous task + /// Returns the navigation information + /// If the user is not authenticated + /// If the user doesn't have access to the project + /// If an unexpected error occurs + [HttpGet("previous")] + [ProducesResponseType(typeof(TaskNavigationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetPreviousTask( + int projectId, + [FromQuery] int currentTaskId, + [FromQuery] int? workflowStageId = null) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized("User ID claim not found in token."); + } + + try + { + _logger.LogInformation("Getting previous task for user {UserId} in project {ProjectId}, current task {CurrentTaskId}, stage {WorkflowStageId}", + userId, projectId, currentTaskId, workflowStageId); + + var result = await _taskNavigationService.GetPreviousTaskAsync(userId, currentTaskId, workflowStageId); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting previous task for user {UserId} in project {ProjectId}", userId, projectId); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while getting previous task."); + } + } + + /// + /// Gets the navigation context for the current task + /// + /// The project ID + /// The ID of the current task + /// Optional workflow stage ID to filter by + /// Navigation context information + /// Returns the navigation context + /// If the user is not authenticated + /// If the user doesn't have access to the project + /// If an unexpected error occurs + [HttpGet("context")] + [ProducesResponseType(typeof(TaskNavigationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetNavigationContext( + int projectId, + [FromQuery] int currentTaskId, + [FromQuery] int? workflowStageId = null) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized("User ID claim not found in token."); + } + + try + { + _logger.LogInformation("Getting navigation context for user {UserId} in project {ProjectId}, current task {CurrentTaskId}, stage {WorkflowStageId}", + userId, projectId, currentTaskId, workflowStageId); + + var result = await _taskNavigationService.GetNavigationContextAsync(userId, currentTaskId, workflowStageId); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting navigation context for user {UserId} in project {ProjectId}", userId, projectId); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while getting navigation context."); + } + } +} \ No newline at end of file From 9dcf1e646d7eaae8ad303eebb12ef05882498263 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:55:16 +0200 Subject: [PATCH 15/23] feat: Add RowVersion property for optimistic concurrency control in task management --- .../Core/Handlers/AssetImportedEventHandler.cs | 3 ++- .../Core/Workflow/Steps/TaskManagementStep.cs | 6 ++++-- .../Data/Configurations/TaskConfiguration.cs | 16 ++++++++++++++++ server/Server/Program.cs | 4 ++++ server/Server/Services/TaskService.cs | 3 ++- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/server/Server/Core/Handlers/AssetImportedEventHandler.cs b/server/Server/Core/Handlers/AssetImportedEventHandler.cs index e63ca8fc..4dd055e4 100644 --- a/server/Server/Core/Handlers/AssetImportedEventHandler.cs +++ b/server/Server/Core/Handlers/AssetImportedEventHandler.cs @@ -78,7 +78,8 @@ private async System.Threading.Tasks.Task CreateTaskForAssetInStage(Asset asset, WorkflowId = stage.WorkflowId, WorkflowStageId = stage.WorkflowStageId, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; await _taskRepository.AddAsync(task); diff --git a/server/Server/Core/Workflow/Steps/TaskManagementStep.cs b/server/Server/Core/Workflow/Steps/TaskManagementStep.cs index 3b87c6f2..18397679 100644 --- a/server/Server/Core/Workflow/Steps/TaskManagementStep.cs +++ b/server/Server/Core/Workflow/Steps/TaskManagementStep.cs @@ -91,7 +91,8 @@ public async Task CreateOrUpdateTaskForTargetStageAsync( Status = readyStatus, AssignedToUserId = null, // Will be assigned later based on workflow stage assignments CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; await _taskRepository.AddAsync(newTask); @@ -176,7 +177,8 @@ public async Task UpdateAnnotationTaskForChangesAsync( Status = TaskStatus.CHANGES_REQUIRED, AssignedToUserId = null, // Will be assigned later based on workflow stage assignments CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; await _taskRepository.AddAsync(newTask); diff --git a/server/Server/Data/Configurations/TaskConfiguration.cs b/server/Server/Data/Configurations/TaskConfiguration.cs index ee3456f9..2ebbc50d 100644 --- a/server/Server/Data/Configurations/TaskConfiguration.cs +++ b/server/Server/Data/Configurations/TaskConfiguration.cs @@ -43,6 +43,17 @@ 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) + .HasColumnName("row_version") + .IsRowVersion() + .ValueGeneratedOnAddOrUpdate(); // Soft delete filter entity.HasQueryFilter(t => t.ArchivedAt == null); @@ -78,6 +89,11 @@ 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) .WithOne(a => a.Task) diff --git a/server/Server/Program.cs b/server/Server/Program.cs index 5b5dd171..5cd4c222 100644 --- a/server/Server/Program.cs +++ b/server/Server/Program.cs @@ -5,6 +5,7 @@ using server.Configs; using server.Extensions; using server.Models.Internal; +using server.Services; using server.Services.EventHandlers; using System.Text.Json; @@ -76,6 +77,9 @@ 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() .AddJsonOptions(options => diff --git a/server/Server/Services/TaskService.cs b/server/Server/Services/TaskService.cs index cba36620..993e9fa8 100644 --- a/server/Server/Services/TaskService.cs +++ b/server/Server/Services/TaskService.cs @@ -174,7 +174,8 @@ public async Task CreateTaskAsync(int projectId, CreateTaskDto createDt DueDate = createDto.DueDate, Metadata = createDto.Metadata, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; await _taskRepository.AddAsync(task); From f55adc310b8539689bb539b7eb86af668a7abbc9 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:55:29 +0200 Subject: [PATCH 16/23] Refactor AuthControllerTests to include ITaskLockingService and update constructor tests Add TaskNavigationControllerTests for task navigation functionality Update AssetTransferStepTests, TaskManagementStepTests, TaskStatusUpdateStepTests, TaskCompletionPipelineTests, TaskVetoPipelineTests to include RowVersion property Implement TaskLockingServiceTests for task locking functionality Create TaskNavigationServiceTests for task navigation logic Enhance TaskServiceTests and TaskServiceUnifiedStatusTests with RowVersion property Refactor TaskStatusValidatorTests to include RowVersion property Clean up Program.cs by removing unused imports --- .../Controllers/AuthControllerTests.cs | 12 +- .../TaskNavigationControllerTests.cs | 304 +++++++++++ .../Workflow/Steps/AssetTransferStepTests.cs | 3 +- .../Workflow/Steps/TaskManagementStepTests.cs | 13 +- .../Steps/TaskStatusUpdateStepTests.cs | 3 +- .../Workflow/TaskCompletionPipelineTests.cs | 3 +- .../Core/Workflow/TaskVetoPipelineTests.cs | 3 +- .../Services/ExportServiceTests.cs | 6 +- .../Services/TaskLockingServiceTests.cs | 225 ++++++++ .../Services/TaskNavigationServiceTests.cs | 507 ++++++++++++++++++ .../Server.Tests/Services/TaskServiceTests.cs | 3 +- .../Services/TaskServiceUnifiedStatusTests.cs | 1 + .../Services/TaskStatusValidatorTests.cs | 1 + server/Server/Program.cs | 3 - 14 files changed, 1066 insertions(+), 21 deletions(-) create mode 100644 server/Server.Tests/Controllers/TaskNavigationControllerTests.cs create mode 100644 server/Server.Tests/Services/TaskLockingServiceTests.cs create mode 100644 server/Server.Tests/Services/TaskNavigationServiceTests.cs diff --git a/server/Server.Tests/Controllers/AuthControllerTests.cs b/server/Server.Tests/Controllers/AuthControllerTests.cs index fe854b1e..117dd375 100644 --- a/server/Server.Tests/Controllers/AuthControllerTests.cs +++ b/server/Server.Tests/Controllers/AuthControllerTests.cs @@ -22,6 +22,7 @@ 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() @@ -29,8 +30,9 @@ public AuthControllerTests() _mockAuthService = new Mock(); _mockLogger = new Mock>(); _mockEnvironment = new Mock(); - _controller = new AuthController(_mockAuthService.Object, _mockLogger.Object, _mockEnvironment.Object); - + _mockTaskLocking = new Mock(); + _controller = new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, _mockLogger.Object, _mockEnvironment.Object); + // Setup HttpContext with mocked Response and Cookies var mockHttpContext = new Mock(); var mockResponse = new Mock(); @@ -52,7 +54,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenAuthServiceIsNull( { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(null!, _mockLogger.Object, _mockEnvironment.Object)); + new AuthController(null!, _mockTaskLocking.Object, _mockLogger.Object, _mockEnvironment.Object)); } [Fact] @@ -60,7 +62,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenLoggerIsNull() { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(_mockAuthService.Object, null!, _mockEnvironment.Object)); + new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, null!, _mockEnvironment.Object)); } [Fact] @@ -68,7 +70,7 @@ public void Constructor_Should_ThrowArgumentNullException_WhenEnvironmentIsNull( { // Arrange & Act & Assert Assert.Throws(() => - new AuthController(_mockAuthService.Object, _mockLogger.Object, null!)); + new AuthController(_mockAuthService.Object, _mockTaskLocking.Object, _mockLogger.Object, null!)); } #endregion diff --git a/server/Server.Tests/Controllers/TaskNavigationControllerTests.cs b/server/Server.Tests/Controllers/TaskNavigationControllerTests.cs new file mode 100644 index 00000000..1427a7e1 --- /dev/null +++ b/server/Server.Tests/Controllers/TaskNavigationControllerTests.cs @@ -0,0 +1,304 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using server.Controllers; +using server.Models.DTOs.Task; +using server.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Server.Tests.Controllers; + +public class TaskNavigationControllerTests +{ + private readonly Mock _mockTaskNavigationService; + private readonly Mock> _mockLogger; + private readonly TaskNavigationController _controller; + + public TaskNavigationControllerTests() + { + _mockTaskNavigationService = new Mock(); + _mockLogger = new Mock>(); + _controller = new TaskNavigationController(_mockTaskNavigationService.Object, _mockLogger.Object); + + // Setup user claims + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, "test-user-id") + }; + var identity = new ClaimsIdentity(claims, "TestAuthType"); + var principal = new ClaimsPrincipal(identity); + + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = principal + } + }; + } + + #region GetNextTask Tests + + [Fact] + public async Task GetNextTask_WithValidParameters_ReturnsOkWithNavigationResult() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + int workflowStageId = 10; + + var expectedResult = new TaskNavigationDto + { + TaskId = 101, + AssetId = 200, + HasNext = true, + HasPrevious = true, + CurrentPosition = 2, + TotalTasks = 5, + Message = null + }; + + _mockTaskNavigationService + .Setup(x => x.GetNextTaskAsync("test-user-id", currentTaskId, workflowStageId)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetNextTask(projectId, currentTaskId, workflowStageId); + + // Assert + var okResult = Assert.IsType(result); + var navigationResult = Assert.IsType(okResult.Value); + + Assert.Equal(expectedResult.TaskId, navigationResult.TaskId); + Assert.Equal(expectedResult.AssetId, navigationResult.AssetId); + Assert.Equal(expectedResult.HasNext, navigationResult.HasNext); + Assert.Equal(expectedResult.HasPrevious, navigationResult.HasPrevious); + Assert.Equal(expectedResult.CurrentPosition, navigationResult.CurrentPosition); + Assert.Equal(expectedResult.TotalTasks, navigationResult.TotalTasks); + + _mockTaskNavigationService.Verify(x => x.GetNextTaskAsync("test-user-id", currentTaskId, workflowStageId), Times.Once); + } + + [Fact] + public async Task GetNextTask_WithoutWorkflowStageId_ReturnsOkWithNavigationResult() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + + var expectedResult = new TaskNavigationDto + { + TaskId = null, + AssetId = null, + HasNext = false, + HasPrevious = true, + CurrentPosition = 3, + TotalTasks = 3, + Message = "No more tasks available" + }; + + _mockTaskNavigationService + .Setup(x => x.GetNextTaskAsync("test-user-id", currentTaskId, null)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetNextTask(projectId, currentTaskId); + + // Assert + var okResult = Assert.IsType(result); + var navigationResult = Assert.IsType(okResult.Value); + + Assert.Null(navigationResult.TaskId); + Assert.False(navigationResult.HasNext); + Assert.Equal("No more tasks available", navigationResult.Message); + + _mockTaskNavigationService.Verify(x => x.GetNextTaskAsync("test-user-id", currentTaskId, null), Times.Once); + } + + [Fact] + public async Task GetNextTask_WithoutUserClaim_ReturnsUnauthorized() + { + // Arrange + _controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); + + // Act + var result = await _controller.GetNextTask(1, 100); + + // Assert + var unauthorizedResult = Assert.IsType(result); + Assert.Equal("User ID claim not found in token.", unauthorizedResult.Value); + } + + [Fact] + public async Task GetNextTask_ServiceThrowsException_ReturnsInternalServerError() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + + _mockTaskNavigationService + .Setup(x => x.GetNextTaskAsync("test-user-id", currentTaskId, null)) + .ThrowsAsync(new Exception("Database connection failed")); + + // Act + var result = await _controller.GetNextTask(projectId, currentTaskId); + + // Assert + var statusCodeResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status500InternalServerError, statusCodeResult.StatusCode); + Assert.Equal("An unexpected error occurred while getting next task.", statusCodeResult.Value); + } + + #endregion + + #region GetPreviousTask Tests + + [Fact] + public async Task GetPreviousTask_WithValidParameters_ReturnsOkWithNavigationResult() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + int workflowStageId = 10; + + var expectedResult = new TaskNavigationDto + { + TaskId = 99, + AssetId = 199, + HasNext = true, + HasPrevious = true, + CurrentPosition = 1, + TotalTasks = 5, + Message = null + }; + + _mockTaskNavigationService + .Setup(x => x.GetPreviousTaskAsync("test-user-id", currentTaskId, workflowStageId)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetPreviousTask(projectId, currentTaskId, workflowStageId); + + // Assert + var okResult = Assert.IsType(result); + var navigationResult = Assert.IsType(okResult.Value); + + Assert.Equal(expectedResult.TaskId, navigationResult.TaskId); + Assert.Equal(expectedResult.HasPrevious, navigationResult.HasPrevious); + + _mockTaskNavigationService.Verify(x => x.GetPreviousTaskAsync("test-user-id", currentTaskId, workflowStageId), Times.Once); + } + + [Fact] + public async Task GetPreviousTask_NoMoreTasks_ReturnsOkWithMessage() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + + var expectedResult = new TaskNavigationDto + { + TaskId = null, + AssetId = null, + HasNext = false, + HasPrevious = false, + CurrentPosition = 1, + TotalTasks = 1, + Message = "No previous tasks available" + }; + + _mockTaskNavigationService + .Setup(x => x.GetPreviousTaskAsync("test-user-id", currentTaskId, null)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetPreviousTask(projectId, currentTaskId); + + // Assert + var okResult = Assert.IsType(result); + var navigationResult = Assert.IsType(okResult.Value); + + Assert.Null(navigationResult.TaskId); + Assert.False(navigationResult.HasPrevious); + Assert.Equal("No previous tasks available", navigationResult.Message); + } + + #endregion + + #region GetNavigationContext Tests + + [Fact] + public async Task GetNavigationContext_WithValidParameters_ReturnsOkWithContextResult() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + int workflowStageId = 10; + + var expectedResult = new TaskNavigationDto + { + TaskId = 100, + AssetId = 200, + HasNext = true, + HasPrevious = true, + CurrentPosition = 3, + TotalTasks = 7, + Message = null + }; + + _mockTaskNavigationService + .Setup(x => x.GetNavigationContextAsync("test-user-id", currentTaskId, workflowStageId)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetNavigationContext(projectId, currentTaskId, workflowStageId); + + // Assert + var okResult = Assert.IsType(result); + var contextResult = Assert.IsType(okResult.Value); + + Assert.Equal(expectedResult.CurrentPosition, contextResult.CurrentPosition); + Assert.Equal(expectedResult.TotalTasks, contextResult.TotalTasks); + Assert.True(contextResult.HasNext); + Assert.True(contextResult.HasPrevious); + + _mockTaskNavigationService.Verify(x => x.GetNavigationContextAsync("test-user-id", currentTaskId, workflowStageId), Times.Once); + } + + [Fact] + public async Task GetNavigationContext_CompletedTaskPreviewMode_ReturnsOkWithPreviewContext() + { + // Arrange + int projectId = 1; + int currentTaskId = 100; + + var expectedResult = new TaskNavigationDto + { + TaskId = null, + AssetId = null, + HasNext = true, + HasPrevious = false, + CurrentPosition = 0, + TotalTasks = 3, + Message = "Viewing task in preview mode" + }; + + _mockTaskNavigationService + .Setup(x => x.GetNavigationContextAsync("test-user-id", currentTaskId, null)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetNavigationContext(projectId, currentTaskId); + + // Assert + var okResult = Assert.IsType(result); + var contextResult = Assert.IsType(okResult.Value); + + Assert.Equal(0, contextResult.CurrentPosition); + Assert.Equal("Viewing task in preview mode", contextResult.Message); + } + + #endregion +} \ No newline at end of file diff --git a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs index e8a2e033..039b858b 100644 --- a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs +++ b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs @@ -294,7 +294,8 @@ private static LaberisTask CreateTestTask(int taskId, TaskStatus status) Status = status, AssignedToUserId = "user123", CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; } diff --git a/server/Server.Tests/Core/Workflow/Steps/TaskManagementStepTests.cs b/server/Server.Tests/Core/Workflow/Steps/TaskManagementStepTests.cs index 1b019ced..6fb946e9 100644 --- a/server/Server.Tests/Core/Workflow/Steps/TaskManagementStepTests.cs +++ b/server/Server.Tests/Core/Workflow/Steps/TaskManagementStepTests.cs @@ -161,18 +161,18 @@ public async System.Threading.Tasks.Task UpdateAnnotationTaskForChangesAsync_Wit // Mock finding the first annotation stage var annotationStage = CreateTestWorkflowStage(1, WorkflowStageType.ANNOTATION, 1); _mockWorkflowStageResolver.Setup(x => x.GetFirstAnnotationStageAsync(currentStage.WorkflowId, default)) - .ReturnsAsync(annotationStage); + .ReturnsAsync(annotationStage); // Mock finding the annotation task var annotationTask = CreateTestTask(3, TaskStatus.COMPLETED); annotationTask.WorkflowStageId = annotationStage.WorkflowStageId; _mockTaskRepository.Setup(x => x.FindByAssetAndStageAsync(asset.AssetId, annotationStage.WorkflowStageId)) - .ReturnsAsync(annotationTask); + .ReturnsAsync(annotationTask); // Mock updating the task status var updatedTask = CreateTestTask(3, TaskStatus.CHANGES_REQUIRED); _mockTaskRepository.Setup(x => x.UpdateTaskStatusAsync(annotationTask, TaskStatus.CHANGES_REQUIRED, "reviewer123")) - .ReturnsAsync(updatedTask); + .ReturnsAsync(updatedTask); var step = CreateStep(); @@ -195,8 +195,8 @@ public async System.Threading.Tasks.Task UpdateAnnotationTaskForChangesAsync_Wit var currentStage = CreateTestWorkflowStage(2, WorkflowStageType.REVISION, 2); var context = new PipelineContext(task, asset, currentStage, "reviewer123"); - _mockWorkflowStageResolver.Setup(x => x.GetFirstAnnotationStageAsync(currentStage.WorkflowId, default)) - .ReturnsAsync((WorkflowStage?)null); + _mockWorkflowStageResolver.Setup(x => x.GetFirstAnnotationStageAsync(currentStage.WorkflowId, It.IsAny())) + .ReturnsAsync((WorkflowStage)null!); var step = CreateStep(); @@ -549,7 +549,8 @@ private static LaberisTask CreateTestTask(int taskId, TaskStatus status) Status = status, AssignedToUserId = "user123", CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; } diff --git a/server/Server.Tests/Core/Workflow/Steps/TaskStatusUpdateStepTests.cs b/server/Server.Tests/Core/Workflow/Steps/TaskStatusUpdateStepTests.cs index c3b7df9d..59a5dba9 100644 --- a/server/Server.Tests/Core/Workflow/Steps/TaskStatusUpdateStepTests.cs +++ b/server/Server.Tests/Core/Workflow/Steps/TaskStatusUpdateStepTests.cs @@ -225,7 +225,8 @@ private static LaberisTask CreateTestTask(int taskId, TaskStatus status) Status = status, AssignedToUserId = "user123", CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; } diff --git a/server/Server.Tests/Core/Workflow/TaskCompletionPipelineTests.cs b/server/Server.Tests/Core/Workflow/TaskCompletionPipelineTests.cs index 2d1e72d1..f13cc93c 100644 --- a/server/Server.Tests/Core/Workflow/TaskCompletionPipelineTests.cs +++ b/server/Server.Tests/Core/Workflow/TaskCompletionPipelineTests.cs @@ -242,7 +242,8 @@ private static LaberisTask CreateTestTask(int taskId, TaskStatus status, int wor Status = status, AssignedToUserId = assignedUserId, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; } diff --git a/server/Server.Tests/Core/Workflow/TaskVetoPipelineTests.cs b/server/Server.Tests/Core/Workflow/TaskVetoPipelineTests.cs index c7e6dcc5..24d09b72 100644 --- a/server/Server.Tests/Core/Workflow/TaskVetoPipelineTests.cs +++ b/server/Server.Tests/Core/Workflow/TaskVetoPipelineTests.cs @@ -300,7 +300,8 @@ private LaberisTask CreateTestTask(int taskId, TaskStatus status, WorkflowStageT Status = status, AssignedToUserId = assignedUserId, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; } diff --git a/server/Server.Tests/Services/ExportServiceTests.cs b/server/Server.Tests/Services/ExportServiceTests.cs index 563e0cbb..a293a077 100644 --- a/server/Server.Tests/Services/ExportServiceTests.cs +++ b/server/Server.Tests/Services/ExportServiceTests.cs @@ -346,7 +346,8 @@ public async TaskAsync ExportCocoFormatAsync_WithOnlyInProgressTasks_ReturnsEmpt Status = server.Models.Domain.Enums.TaskStatus.COMPLETED, CompletedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; var task2 = new LaberisTask { @@ -357,7 +358,8 @@ public async TaskAsync ExportCocoFormatAsync_WithOnlyInProgressTasks_ReturnsEmpt Status = server.Models.Domain.Enums.TaskStatus.COMPLETED, CompletedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; context.Tasks.AddRange(task1, task2); await context.SaveChangesAsync(); diff --git a/server/Server.Tests/Services/TaskLockingServiceTests.cs b/server/Server.Tests/Services/TaskLockingServiceTests.cs new file mode 100644 index 00000000..cf5bc7fe --- /dev/null +++ b/server/Server.Tests/Services/TaskLockingServiceTests.cs @@ -0,0 +1,225 @@ +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 new file mode 100644 index 00000000..fc16bdc2 --- /dev/null +++ b/server/Server.Tests/Services/TaskNavigationServiceTests.cs @@ -0,0 +1,507 @@ +using Microsoft.Extensions.Logging; +using Moq; +using server.Models.Domain; +using server.Services; +using server.Services.Interfaces; +using server.Repositories.Interfaces; +using Xunit; +using DomainTask = server.Models.Domain.Task; +using DomainTaskStatus = server.Models.Domain.Enums.TaskStatus; + +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); + } + + #region Helper Methods + + private List GetTestNavigableTasks(string userId = "test-user", int projectId = 1, int workflowStageId = 10) + { + return + [ + new() { + TaskId = 1, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 101, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow.AddHours(-3), + RowVersion = [] + }, + new() { + TaskId = 2, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 102, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.IN_PROGRESS, + CreatedAt = DateTime.UtcNow.AddHours(-2), + RowVersion = [] + }, + new() { + TaskId = 3, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 103, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow.AddHours(-1), + RowVersion = [] + } + ]; + } + + #endregion + + #region GetNextTaskAsync Tests + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_WithValidCurrentTask_ReturnsNextTask() + { + // Arrange + var tasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 1; // First task + int workflowStageId = 10; + + var currentTask = tasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Equal(2, result.TaskId); // Next task ID + Assert.Equal(102, result.AssetId); // Next asset ID + Assert.True(result.HasNext); // Task 3 is still available + Assert.True(result.HasPrevious); // Task 1 is behind + Assert.Equal(1, result.CurrentPosition); // Position of task 1 in navigable list (1-based) + Assert.Equal(3, result.TotalTasks); // Only tasks 1, 2, 3 are navigable + Assert.Null(result.Message); + } + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_FromLastTask_ReturnsNoNextTask() + { + // Arrange + var tasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 3; // Last navigable task + int workflowStageId = 10; + + var currentTask = tasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Null(result.TaskId); + Assert.Null(result.AssetId); + Assert.False(result.HasNext); + Assert.True(result.HasPrevious); + Assert.Equal(3, result.CurrentPosition); // Position of task 3 + Assert.Equal(3, result.TotalTasks); + Assert.Equal("No more tasks available", result.Message); + } + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_WithNonExistentTask_ReturnsEmptyResult() + { + // Arrange + string userId = "test-user"; + int currentTaskId = 999; // Non-existent task + + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync((DomainTask?)null); + + // Act + var result = await _service.GetNextTaskAsync(userId, currentTaskId, null); + + // Assert + Assert.Null(result.TaskId); + Assert.Null(result.AssetId); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + Assert.Equal(0, result.CurrentPosition); + Assert.Equal(0, result.TotalTasks); + } + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_UserWithNoTasks_ReturnsAppropriateMessage() + { + // Arrange + string userId = "user-with-no-tasks"; + int currentTaskId = 1; + var currentTask = new DomainTask { TaskId = currentTaskId, ProjectId = 1, RowVersion = new byte[0] }; + + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, null)) + .ReturnsAsync(new List()); + + // Act + var result = await _service.GetNextTaskAsync(userId, currentTaskId, null); + + // Assert + Assert.Null(result.TaskId); + Assert.Equal(0, result.CurrentPosition); + Assert.Equal(0, result.TotalTasks); + Assert.Equal("Current task is not in your assigned task list", result.Message); + } + + #endregion + + #region GetPreviousTaskAsync Tests + + [Fact] + public async System.Threading.Tasks.Task GetPreviousTaskAsync_WithValidCurrentTask_ReturnsPreviousTask() + { + // Arrange + var tasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 2; // Middle task + int workflowStageId = 10; + + var currentTask = tasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetPreviousTaskAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Equal(1, result.TaskId); // Previous task ID + Assert.Equal(101, result.AssetId); // Previous asset ID + Assert.True(result.HasNext); // Task 3 is still ahead + Assert.False(result.HasPrevious); // Task 1 is the first + Assert.Equal(2, result.CurrentPosition); // Position of task 2 in navigable list + Assert.Equal(3, result.TotalTasks); + Assert.Null(result.Message); + } + + [Fact] + public async System.Threading.Tasks.Task GetPreviousTaskAsync_FromFirstTask_ReturnsNoPreviousTask() + { + // Arrange + var tasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 1; // First task + int workflowStageId = 10; + + var currentTask = tasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetPreviousTaskAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Null(result.TaskId); + Assert.Null(result.AssetId); + Assert.True(result.HasNext); + Assert.False(result.HasPrevious); + Assert.Equal(1, result.CurrentPosition); + Assert.Equal(3, result.TotalTasks); + Assert.Equal("No previous tasks available", result.Message); + } + + #endregion + + #region GetNavigationContextAsync Tests + + [Fact] + public async System.Threading.Tasks.Task GetNavigationContextAsync_WithCurrentTaskInList_ReturnsCorrectContext() + { + // Arrange + var tasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 2; // Middle task + int workflowStageId = 10; + + var currentTask = tasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetNavigationContextAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Equal(currentTaskId, result.TaskId); + Assert.Equal(102, result.AssetId); // Asset ID of task 2 + Assert.True(result.HasNext); + Assert.True(result.HasPrevious); + Assert.Equal(2, result.CurrentPosition); // Task 2 is at position 2 + Assert.Equal(3, result.TotalTasks); + Assert.Null(result.Message); + } + + [Fact] + public async System.Threading.Tasks.Task GetNavigationContextAsync_WithCompletedTaskNotInList_ReturnsPreviewModeContext() + { + // Arrange + var navigableTasks = GetTestNavigableTasks(); + string userId = "test-user"; + int currentTaskId = 4; // Completed task (not in navigable list) + var currentTask = new DomainTask + { + TaskId = currentTaskId, + AssignedToUserId = userId, + ProjectId = 1, + AssetId = 104, + WorkflowStageId = 10, + Status = DomainTaskStatus.COMPLETED, + CreatedAt = DateTime.UtcNow, + RowVersion = new byte[0] + }; + + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, currentTask.ProjectId, null)) + .ReturnsAsync(navigableTasks); + + // Act + var result = await _service.GetNavigationContextAsync(userId, currentTaskId, null); + + // Assert + Assert.Equal(0, result.CurrentPosition); + Assert.Equal(3, result.TotalTasks); // Still 3 navigable tasks available + Assert.True(result.HasNext); // User has navigable tasks available + Assert.False(result.HasPrevious); + Assert.Equal("Viewing task in preview mode", result.Message); + } + + [Fact] + public async System.Threading.Tasks.Task GetNavigationContextAsync_WithSingleTask_ReturnsCorrectNavigation() + { + // Arrange + string userId = "single-user"; + int projectId = 1; + int workflowStageId = 10; + var singleTask = new DomainTask + { + TaskId = 100, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 200, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow, + RowVersion = new byte[0] + }; + var tasks = new List { singleTask }; + + _mockTaskRepository.Setup(x => x.GetByIdAsync(singleTask.TaskId)) + .ReturnsAsync(singleTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId)) + .ReturnsAsync(tasks); + + // Act + var result = await _service.GetNavigationContextAsync(userId, singleTask.TaskId, workflowStageId); + + // Assert + Assert.Equal(100, result.TaskId); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + Assert.Equal(1, result.CurrentPosition); + Assert.Equal(1, result.TotalTasks); + } + + #endregion + + #region Workflow Stage Filtering Tests + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_WithWorkflowStageFilter_OnlyReturnsTasksFromThatStage() + { + // Arrange + string userId = "test-user"; + int projectId = 1; + int currentTaskId = 1; + int workflowStageId10 = 10; + int workflowStageId20 = 20; + + var tasksStage10 = GetTestNavigableTasks(userId, projectId, workflowStageId10); + var tasksStage20 = new List + { + new DomainTask + { + TaskId = 6, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 106, + WorkflowStageId = workflowStageId20, // Different stage + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow, + RowVersion = new byte[0] + } + }; + + var currentTask = tasksStage10.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId10)) + .ReturnsAsync(tasksStage10); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId20)) + .ReturnsAsync(tasksStage20); + + // Act - Get next task from stage 10 only + var result = await _service.GetNextTaskAsync(userId, currentTaskId, workflowStageId10); + + // Assert + Assert.Equal(2, result.TaskId); // Should be task 2 from stage 10, not task 6 from stage 20 + Assert.Equal(3, result.TotalTasks); // Only 3 navigable tasks in stage 10 + } + + [Fact] + public async System.Threading.Tasks.Task GetNextTaskAsync_WithoutWorkflowStageFilter_ReturnsTasksFromAllStages() + { + // Arrange + string userId = "test-user"; + int projectId = 1; + int currentTaskId = 1; + + var tasksStage10 = GetTestNavigableTasks(userId, projectId, 10); + var tasksStage20 = new List + { + new DomainTask + { + TaskId = 6, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 106, + WorkflowStageId = 20, + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow.AddMinutes(5), + RowVersion = new byte[0] // Later creation time + } + }; + var allTasks = tasksStage10.Concat(tasksStage20).OrderBy(t => t.CreatedAt).ToList(); + + var currentTask = tasksStage10.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, null)) + .ReturnsAsync(allTasks); + + // Act - No workflow stage filter + var result = await _service.GetNextTaskAsync(userId, currentTaskId, null); + + // Assert + Assert.Equal(4, result.TotalTasks); // 4 navigable tasks across all stages + } + + #endregion + + #region Status Filtering Tests + + [Fact] + public async System.Threading.Tasks.Task GetNavigationContextAsync_ExcludesCompletedArchivedVetoedTasks() + { + // Arrange + string userId = "test-user"; + int projectId = 1; + int workflowStageId = 10; + int currentTaskId = 1; + + var allTasks = new List + { + new() { + TaskId = 1, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 101, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.READY_FOR_ANNOTATION, + CreatedAt = DateTime.UtcNow.AddHours(-4), + RowVersion = [] + }, + new() { + TaskId = 2, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 102, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.IN_PROGRESS, + CreatedAt = DateTime.UtcNow.AddHours(-3), + RowVersion = [] + }, + new() { + TaskId = 3, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 103, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.COMPLETED, + CreatedAt = DateTime.UtcNow.AddHours(-2), + RowVersion = [] + }, // Excluded + new() { + TaskId = 4, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 104, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.ARCHIVED, + CreatedAt = DateTime.UtcNow.AddHours(-1), + RowVersion = [] + }, // Excluded + new() { + TaskId = 5, + AssignedToUserId = userId, + ProjectId = projectId, + AssetId = 105, + WorkflowStageId = workflowStageId, + Status = DomainTaskStatus.VETOED, + CreatedAt = DateTime.UtcNow, + RowVersion = [] + } // Excluded + }; + + var navigableTasks = allTasks + .Where(t => t.Status == DomainTaskStatus.READY_FOR_ANNOTATION || t.Status == DomainTaskStatus.IN_PROGRESS) + .ToList(); + + var currentTask = allTasks.First(t => t.TaskId == currentTaskId); + _mockTaskRepository.Setup(x => x.GetByIdAsync(currentTaskId)) + .ReturnsAsync(currentTask); + _mockTaskRepository.Setup(x => x.GetNavigableTasksAsync(userId, projectId, workflowStageId)) + .ReturnsAsync(navigableTasks); + + // Act + var result = await _service.GetNavigationContextAsync(userId, currentTaskId, workflowStageId); + + // Assert + Assert.Equal(2, result.TotalTasks); // Only tasks 1 and 2 are navigable + Assert.Equal(1, result.CurrentPosition); + Assert.True(result.HasNext); // Task 2 is available + Assert.False(result.HasPrevious); + } + + #endregion +} \ No newline at end of file diff --git a/server/Server.Tests/Services/TaskServiceTests.cs b/server/Server.Tests/Services/TaskServiceTests.cs index 306fba8f..d5a3eaec 100644 --- a/server/Server.Tests/Services/TaskServiceTests.cs +++ b/server/Server.Tests/Services/TaskServiceTests.cs @@ -62,7 +62,8 @@ public async Task GetTaskByIdAsync_Should_ReturnTaskDto_WhenTaskExists() WorkflowStageId = 1, AssignedToUserId = "user123", CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, + RowVersion = [] }; _mockTaskRepository.Setup(r => r.GetByIdAsync(taskId)) diff --git a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs index 8a31eb5f..51d2ff52 100644 --- a/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs +++ b/server/Server.Tests/Services/TaskServiceUnifiedStatusTests.cs @@ -396,6 +396,7 @@ private static LaberisTask CreateTestTask(int taskId, WorkflowStageType stageTyp WorkflowStageId = 1, CreatedAt = DateTime.UtcNow.AddDays(-1), UpdatedAt = DateTime.UtcNow.AddHours(-1), + RowVersion = [], WorkflowStage = new WorkflowStage { WorkflowStageId = 1, diff --git a/server/Server.Tests/Services/TaskStatusValidatorTests.cs b/server/Server.Tests/Services/TaskStatusValidatorTests.cs index 33c65f2c..2aea0eef 100644 --- a/server/Server.Tests/Services/TaskStatusValidatorTests.cs +++ b/server/Server.Tests/Services/TaskStatusValidatorTests.cs @@ -191,6 +191,7 @@ private static LaberisTask CreateTestTask(WorkflowStageType stageType) WorkflowStageId = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + RowVersion = [], WorkflowStage = new WorkflowStage { WorkflowStageId = 1, diff --git a/server/Server/Program.cs b/server/Server/Program.cs index 5cd4c222..0f6f0707 100644 --- a/server/Server/Program.cs +++ b/server/Server/Program.cs @@ -1,12 +1,9 @@ using Microsoft.OpenApi.Models; using System.Text.Json.Serialization; -using Minio; using server.Data; using server.Configs; using server.Extensions; -using server.Models.Internal; using server.Services; -using server.Services.EventHandlers; using System.Text.Json; namespace server; From 1345b7a314b40034339cb6d6c1d735431c041cb7 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:55:51 +0200 Subject: [PATCH 17/23] refactor: Remove unused focus/blur event listeners in AnnotationWorkspace.vue --- .../task/__tests__/taskManager.test.ts | 536 ------------------ .../src/core/workspace/task/taskManager.ts | 293 ---------- .../core/workspace/task/taskManager.types.ts | 33 -- frontend/src/views/AnnotationWorkspace.vue | 11 - 4 files changed, 873 deletions(-) delete mode 100644 frontend/src/core/workspace/task/__tests__/taskManager.test.ts delete mode 100644 frontend/src/core/workspace/task/taskManager.ts delete mode 100644 frontend/src/core/workspace/task/taskManager.types.ts diff --git a/frontend/src/core/workspace/task/__tests__/taskManager.test.ts b/frontend/src/core/workspace/task/__tests__/taskManager.test.ts deleted file mode 100644 index 2998a7a6..00000000 --- a/frontend/src/core/workspace/task/__tests__/taskManager.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TaskManager } from '../taskManager'; -import { TaskNavigationManager } from '../taskNavigationManager'; -import type { Task } from '@/services/project/task/task.types'; -import { TaskStatus } from '@/services/project/task/task.types'; -import type { PipelineResultDto } from '@/services/project/task/task.types'; - -// Mock logger -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn() -}; - -// Mock task service -const mockTaskService = { - getTaskById: vi.fn(), - getTasksForAsset: vi.fn(), - getTasksForStage: vi.fn(), - completeTaskPipeline: vi.fn(), - vetoTaskPipeline: vi.fn(), - changeTaskStatus: vi.fn(), - updateWorkingTime: vi.fn(), - saveWorkingTimeBeforeUnload: vi.fn() -}; - -// Mock permissions -const mockPermissions = { - canUpdateProject: vi.fn() -}; - -// Mock timer -const mockTimer = { - getElapsedTime: vi.fn(() => 1000), - isRunning: vi.fn(() => true) -}; - -// Test data -const mockTask: Task = { - id: 1, - priority: 1, - workingTimeMs: 5000, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - assetId: 1, - projectId: 1, - workflowId: 1, - workflowStageId: 1, - status: TaskStatus.IN_PROGRESS, - assignedToEmail: 'test@example.com' -}; - -const mockCompletedTask: Task = { - ...mockTask, - status: TaskStatus.COMPLETED, - completedAt: '2024-01-01T01:00:00Z' -}; - -const mockSuspendedTask: Task = { - ...mockTask, - status: TaskStatus.SUSPENDED, - suspendedAt: '2024-01-01T01:00:00Z' -}; - -const mockDeferredTask: Task = { - ...mockTask, - status: TaskStatus.DEFERRED, - deferredAt: '2024-01-01T01:00:00Z' -}; - -const mockPipelineSuccess: PipelineResultDto = { - isSuccess: true, - updatedTask: mockCompletedTask, - details: 'Task completed successfully' -}; - -const mockPipelineFailure: PipelineResultDto = { - isSuccess: false, - errorMessage: 'Pipeline failed' -}; - -describe('TaskManager', () => { - let taskManager: TaskManager; - - beforeEach(() => { - vi.clearAllMocks(); - taskManager = new TaskManager( - mockTaskService as any, - mockPermissions as any, - mockTimer as any, - ); - }); - - describe('Task Completion', () => { - it('should complete a task successfully using pipeline system', async () => { - mockTaskService.completeTaskPipeline.mockResolvedValue(mockPipelineSuccess); - mockTaskService.getTaskById.mockResolvedValue(mockCompletedTask); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockCompletedTask); - expect(result.message).toContain('successfully completed'); - expect(mockTaskService.completeTaskPipeline).toHaveBeenCalledWith(123, 1); - expect(mockTaskService.getTaskById).toHaveBeenCalledWith(123, 1); - }); - - it('should handle task completion failure', async () => { - mockTaskService.completeTaskPipeline.mockResolvedValue(mockPipelineFailure); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Pipeline failed'); - }); - - it('should handle task completion with service error', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue(new Error('Service error')); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Service error'); - }); - }); - - describe('Task Suspension', () => { - it('should suspend a task successfully with working time preservation', async () => { - mockTaskService.changeTaskStatus.mockResolvedValue(mockSuspendedTask); - - const result = await taskManager.suspendTask(123, 1, 2000); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockSuspendedTask); - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.SUSPENDED } - ); - }); - - it('should handle task suspension failure', async () => { - mockTaskService.changeTaskStatus.mockRejectedValue(new Error('Suspension failed')); - - const result = await taskManager.suspendTask(123, 1, 2000); - - expect(result.success).toBe(false); - expect(result.error).toBe('Suspension failed'); - }); - - it('should properly preserve working time during suspension', async () => { - const currentWorkingTime = 5000; - const elapsedTime = 3000; - const expectedTotalTime = currentWorkingTime + elapsedTime; - - mockTimer.getElapsedTime.mockReturnValue(elapsedTime); - mockTaskService.changeTaskStatus.mockResolvedValue({ - ...mockSuspendedTask, - workingTimeMs: expectedTotalTime - }); - - const result = await taskManager.suspendTask(123, 1, currentWorkingTime); - - expect(result.success).toBe(true); - // The manager should calculate total time and pass it through - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.SUSPENDED } - ); - }); - }); - - describe('Task Deferring', () => { - it('should defer a task successfully', async () => { - mockTaskService.changeTaskStatus.mockResolvedValue(mockDeferredTask); - - const result = await taskManager.deferTask(123, 1, 2000); - - expect(result.success).toBe(true); - expect(result.task).toEqual(mockDeferredTask); - expect(mockTaskService.changeTaskStatus).toHaveBeenCalledWith( - 123, 1, { targetStatus: TaskStatus.DEFERRED } - ); - }); - - it('should handle task deferring failure', async () => { - mockTaskService.changeTaskStatus.mockRejectedValue(new Error('Defer failed')); - - const result = await taskManager.deferTask(123, 1, 2000); - - expect(result.success).toBe(false); - expect(result.error).toBe('Defer failed'); - }); - }); - - describe('Task Veto', () => { - it('should veto a task successfully with reason', async () => { - const vetoResult = { ...mockPipelineSuccess, updatedTask: { ...mockTask, status: TaskStatus.VETOED } }; - mockTaskService.vetoTaskPipeline.mockResolvedValue(vetoResult); - - const result = await taskManager.vetoTask(123, 1, 'Quality issues'); - - expect(result.success).toBe(true); - expect(result.task?.status).toBe(TaskStatus.VETOED); - expect(mockTaskService.vetoTaskPipeline).toHaveBeenCalledWith(123, 1, { - reason: 'Quality issues' - }); - }); - - it('should handle task veto failure', async () => { - mockTaskService.vetoTaskPipeline.mockResolvedValue(mockPipelineFailure); - - const result = await taskManager.vetoTask(123, 1, 'Quality issues'); - - expect(result.success).toBe(false); - expect(result.error).toBe('Pipeline failed'); - }); - - it('should use default reason when none provided', async () => { - const vetoResult = { ...mockPipelineSuccess, updatedTask: { ...mockTask, status: TaskStatus.VETOED } }; - mockTaskService.vetoTaskPipeline.mockResolvedValue(vetoResult); - - await taskManager.vetoTask(123, 1); - - expect(mockTaskService.vetoTaskPipeline).toHaveBeenCalledWith(123, 1, { - reason: 'Task returned for rework' - }); - }); - }); - - describe('Task Validation', () => { - it('should correctly identify if task can be completed', () => { - const inProgressTask = { ...mockTask, status: TaskStatus.IN_PROGRESS }; - const completedTask = { ...mockTask, status: TaskStatus.COMPLETED }; - const suspendedTask = { ...mockTask, status: TaskStatus.SUSPENDED }; - - expect(taskManager.canCompleteTask(inProgressTask)).toBe(true); - expect(taskManager.canCompleteTask(completedTask)).toBe(false); - expect(taskManager.canCompleteTask(suspendedTask)).toBe(false); - }); - - it('should validate task permissions correctly for regular tasks', async () => { - const result = await taskManager.canOpenTask(mockTask); - - expect(result).toBe(true); - // Regular tasks don't require permission checks - expect(mockPermissions.canUpdateProject).not.toHaveBeenCalled(); - }); - - it('should handle deferred tasks requiring manager permissions', async () => { - const deferredTask = { ...mockTask, status: TaskStatus.DEFERRED }; - mockPermissions.canUpdateProject.mockResolvedValue(false); - - const result = await taskManager.canOpenTask(deferredTask); - - expect(result).toBe(false); - }); - - it('should allow managers to open deferred tasks', async () => { - const deferredTask = { ...mockTask, status: TaskStatus.DEFERRED }; - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await taskManager.canOpenTask(deferredTask); - - expect(result).toBe(true); - }); - }); - - describe('Working Time Management', () => { - it('should calculate total working time correctly', () => { - const currentTime = 5000; - const elapsedTime = 3000; - mockTimer.getElapsedTime.mockReturnValue(elapsedTime); - - const totalTime = taskManager.calculateTotalWorkingTime(currentTime); - - expect(totalTime).toBe(8000); - expect(mockTimer.getElapsedTime).toHaveBeenCalled(); - }); - - it('should return current time if timer is not running', () => { - const currentTime = 5000; - mockTimer.isRunning.mockReturnValue(false); - mockTimer.getElapsedTime.mockReturnValue(0); - - const totalTime = taskManager.calculateTotalWorkingTime(currentTime); - - expect(totalTime).toBe(5000); - }); - }); - - describe('Error Handling', () => { - it('should log appropriate errors for each operation', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue(new Error('Test error')); - - await taskManager.completeTask(123, 1); - - expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to complete task via pipeline:', - expect.any(Error) - ); - }); - - it('should handle non-Error exceptions', async () => { - mockTaskService.completeTaskPipeline.mockRejectedValue('String error'); - - const result = await taskManager.completeTask(123, 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to complete task'); - }); - }); -}); - -describe('TaskNavigationManager', () => { - let navigationManager: TaskNavigationManager; - - const mockTasks: Task[] = [ - { ...mockTask, id: 1, status: TaskStatus.COMPLETED }, - { ...mockTask, id: 2, status: TaskStatus.IN_PROGRESS }, - { ...mockTask, id: 3, status: TaskStatus.NOT_STARTED }, - { ...mockTask, id: 4, status: TaskStatus.COMPLETED } - ]; - - beforeEach(() => { - vi.clearAllMocks(); - navigationManager = new TaskNavigationManager( - mockPermissions as any, - ); - }); - - describe('Next Task Navigation', () => { - it('should find next available task', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToNext( - mockTasks[1], // current task (id: 2) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '3' - }); - }); - - it('should return null when no next task available', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToNext( - mockTasks[3], // current task (id: 4) - last task - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - - it('should skip tasks that cannot be opened', async () => { - // Mock deferred task that requires manager permissions - const tasksWithDeferred = [ - ...mockTasks, - { ...mockTask, id: 5, status: TaskStatus.DEFERRED } - ]; - - // User is not a manager - mockPermissions.canUpdateProject.mockResolvedValue(false); - - const result = await navigationManager.navigateToNext( - mockTasks[1], - tasksWithDeferred, - '123' - ); - - expect(result.success).toBe(true); - // Should skip deferred task and go to id: 3 - expect(result.navigation?.taskId).toBe('3'); - }); - }); - - describe('Previous Task Navigation', () => { - it('should find previous available task', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToPrevious( - mockTasks[2], // current task (id: 3) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '2' - }); - }); - - it('should return null when no previous task available', async () => { - mockPermissions.canUpdateProject.mockResolvedValue(true); - - const result = await navigationManager.navigateToPrevious( - mockTasks[0], // first task - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); - - describe('Next Available Task (Completion Helper)', () => { - it('should find next uncompleted task', async () => { - const result = await navigationManager.getNextAvailableTask( - mockTasks[1], // current task (id: 2) - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toEqual({ - projectId: '123', - assetId: '1', - taskId: '3' - }); - }); - - it('should wrap around to beginning if needed', async () => { - const tasksWithGap = [ - { ...mockTask, id: 1, status: TaskStatus.NOT_STARTED }, - { ...mockTask, id: 2, status: TaskStatus.COMPLETED }, - { ...mockTask, id: 3, status: TaskStatus.COMPLETED } - ]; - - const result = await navigationManager.getNextAvailableTask( - tasksWithGap[2], // last task - tasksWithGap, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation?.taskId).toBe('1'); - }); - - it('should return null when all tasks are completed', async () => { - const completedTasks = mockTasks.map(task => ({ - ...task, - status: TaskStatus.COMPLETED, - completedAt: '2024-01-01T01:00:00Z' - })); - - const result = await navigationManager.getNextAvailableTask( - completedTasks[1], - completedTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); - - describe('Navigation Info', () => { - it('should provide correct navigation information', () => { - const info = navigationManager.getNavigationInfo( - mockTasks[1], - mockTasks - ); - - expect(info.currentIndex).toBe(1); - expect(info.totalTasks).toBe(4); - expect(info.hasNext).toBe(true); - expect(info.hasPrevious).toBe(true); - }); - - it('should handle edge cases correctly', () => { - // First task - const firstInfo = navigationManager.getNavigationInfo( - mockTasks[0], - mockTasks - ); - expect(firstInfo.currentIndex).toBe(0); - expect(firstInfo.hasPrevious).toBe(false); - expect(firstInfo.hasNext).toBe(true); - - // Last task - const lastInfo = navigationManager.getNavigationInfo( - mockTasks[mockTasks.length - 1], - mockTasks - ); - expect(lastInfo.currentIndex).toBe(3); - expect(lastInfo.hasPrevious).toBe(true); - expect(lastInfo.hasNext).toBe(false); - }); - - it('should handle single task scenario', () => { - const singleTask = [mockTasks[0]]; - const info = navigationManager.getNavigationInfo( - singleTask[0], - singleTask - ); - - expect(info.currentIndex).toBe(0); - expect(info.totalTasks).toBe(1); - expect(info.hasNext).toBe(false); - expect(info.hasPrevious).toBe(false); - }); - }); - - describe('Error Handling', () => { - it('should handle empty task list', async () => { - const result = await navigationManager.navigateToNext( - mockTask, - [], - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - - it('should handle missing current task', async () => { - const result = await navigationManager.navigateToNext( - { ...mockTask, id: 999 }, // not in the list - mockTasks, - '123' - ); - - expect(result.success).toBe(true); - expect(result.navigation).toBeNull(); - }); - }); -}); \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskManager.ts b/frontend/src/core/workspace/task/taskManager.ts deleted file mode 100644 index 82833505..00000000 --- a/frontend/src/core/workspace/task/taskManager.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { Task, TaskStatus } from '@/services/project/task/task.types'; -import type { TaskResult, TaskService, PermissionsService } from './taskManager.types'; -import type { TimerService } from '@/core/timeTracking'; -import { AppLogger } from '@/core/logger/logger'; - -/** - * Core business logic for task management operations. - * Handles task completion, suspension, deferring, veto operations, - * and task status validations. - */ -export class TaskManager { - private logger = AppLogger.createServiceLogger('TaskManager'); - - constructor( - private taskService: TaskService, - private permissions: PermissionsService, - private timer: TimerService - ) {} - - /** - * Complete a task using the pipeline system - * @param projectId - The project ID - * @param taskId - The task ID to complete - * @returns Promise resolving to TaskResult - */ - async completeTask(projectId: number, taskId: number): Promise { - try { - this.logger.info('Completing task using pipeline system', { taskId }); - - const result = await this.taskService.completeTaskPipeline(projectId, taskId); - - if (!result.isSuccess) { - return { - success: false, - error: result.errorMessage || 'Task completion failed' - }; - } - - // Refresh current task data - const updatedTask = await this.taskService.getTaskById(projectId, taskId); - - this.logger.info(`Successfully completed task ${taskId} via pipeline`, { details: result.details }); - - return { - success: true, - task: updatedTask, - message: `Task ${taskId} successfully completed via pipeline`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to complete task via pipeline:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to complete task' - }; - } - } - - /** - * Suspend a task with working time preservation - * @param projectId - The project ID - * @param taskId - The task ID to suspend - * @param currentWorkingTime - Current working time in milliseconds - * @returns Promise resolving to TaskResult - */ - async suspendTask(projectId: number, taskId: number, currentWorkingTime: number): Promise { - try { - // Calculate total working time including current session - const totalWorkingTime = this.calculateTotalWorkingTime(currentWorkingTime); - - this.logger.info('Suspending task with working time preservation', { - taskId, - currentTime: currentWorkingTime, - totalTime: totalWorkingTime - }); - - // Use the shared working time preservation logic - const suspendedTask = await this._saveWorkingTimeAndChangeStatus( - () => this.taskService.changeTaskStatus(projectId, taskId, { targetStatus: 'SUSPENDED' as TaskStatus }), - 'suspension', - projectId, - taskId, - totalWorkingTime - ); - - this.logger.info(`Successfully suspended task ${taskId}`); - - return { - success: true, - task: suspendedTask, - message: `Task ${taskId} successfully suspended`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to suspend task:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to suspend task' - }; - } - } - - /** - * Defer a task (skip for now) with working time preservation - * @param projectId - The project ID - * @param taskId - The task ID to defer - * @param currentWorkingTime - Current working time in milliseconds - * @returns Promise resolving to TaskResult - */ - async deferTask(projectId: number, taskId: number, currentWorkingTime: number): Promise { - try { - // Calculate total working time including current session - const totalWorkingTime = this.calculateTotalWorkingTime(currentWorkingTime); - - this.logger.info('Deferring task with working time preservation', { - taskId, - currentTime: currentWorkingTime, - totalTime: totalWorkingTime - }); - - // Use the shared working time preservation logic - const deferredTask = await this._saveWorkingTimeAndChangeStatus( - () => this.taskService.changeTaskStatus(projectId, taskId, { targetStatus: 'DEFERRED' as TaskStatus }), - 'deferring', - projectId, - taskId, - totalWorkingTime - ); - - this.logger.info(`Successfully deferred task ${taskId}`); - - return { - success: true, - task: deferredTask, - message: `Task ${taskId} successfully deferred`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to defer task:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to defer task' - }; - } - } - - /** - * Veto/return a task for rework using the veto pipeline - * @param projectId - The project ID - * @param taskId - The task ID to veto - * @param reason - Optional reason for the veto - * @returns Promise resolving to TaskResult - */ - async vetoTask(projectId: number, taskId: number, reason?: string): Promise { - try { - const vetoReason = reason || 'Task returned for rework'; - - this.logger.info('Returning task for rework using veto pipeline', { taskId, reason: vetoReason }); - - // Use the veto pipeline to handle all operations atomically - const pipelineResult = await this.taskService.vetoTaskPipeline(projectId, taskId, { - reason: vetoReason - }); - - if (!pipelineResult.isSuccess) { - return { - success: false, - error: pipelineResult.errorMessage || 'Veto pipeline failed' - }; - } - - this.logger.info(`Successfully returned task ${taskId} for rework`, { reason: vetoReason }); - - return { - success: true, - task: pipelineResult.updatedTask, - message: `Task ${taskId} successfully returned for rework`, - error: undefined - }; - } catch (error) { - this.logger.error('Failed to return task for rework:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to return task for rework' - }; - } - } - - /** - * Check if a task can be completed based on its current status - * @param task - The task to check - * @returns Boolean indicating if the task can be completed - */ - canCompleteTask(task: Task): boolean { - // Only tasks that are in progress or ready states can be completed - const completableStatuses = [ - 'IN_PROGRESS' as TaskStatus, - 'READY_FOR_ANNOTATION' as TaskStatus, - 'READY_FOR_REVIEW' as TaskStatus, - 'READY_FOR_COMPLETION' as TaskStatus, - 'CHANGES_REQUIRED' as TaskStatus - ]; - - return task.status ? completableStatuses.includes(task.status) : false; - } - - /** - * Check if a task can be opened by the current user - * @param task - The task to check - * @returns Promise resolving to boolean indicating if the task can be opened - */ - async canOpenTask(task: Task): Promise { - // Deferred tasks can only be opened by managers - if (task.status === 'DEFERRED') { - return await this.permissions.canUpdateProject(); - } - - // Vetoed tasks cannot be opened (they are view-only) - if (task.status === 'VETOED') { - return false; - } - - // Completed and archived tasks cannot be opened - if (task.status && ['COMPLETED', 'ARCHIVED'].includes(task.status as string)) { - return false; - } - - // All other tasks can be opened if user has basic permissions - return true; - } - - /** - * Calculate total working time including current timer session - * @param currentWorkingTime - Current saved working time in milliseconds - * @returns Total working time in milliseconds - */ - calculateTotalWorkingTime(currentWorkingTime: number): number { - if (!this.timer.isRunning()) { - return currentWorkingTime; - } - - const elapsedTime = this.timer.getElapsedTime(); - return currentWorkingTime + elapsedTime; - } - - /** - * Common helper to save working time and preserve it across task status changes - * @private - * @param statusChangeOperation - Function that performs the status change and returns the updated task - * @param operationName - Name of the operation for logging (e.g., "completion", "suspension") - * @param projectId - The project ID - * @param taskId - The task ID - * @param finalWorkingTime - The final working time to preserve - */ - private async _saveWorkingTimeAndChangeStatus( - statusChangeOperation: () => Promise, - operationName: string, - projectId: number, - taskId: number, - finalWorkingTime: number - ): Promise { - this.logger.info(`Starting ${operationName} with working time preservation`, { - taskId, - finalWorkingTime - }); - - // Execute the status change operation - const updatedTask = await statusChangeOperation(); - - // Ensure the working time is preserved in case the backend didn't return the latest value - if (updatedTask.workingTimeMs < finalWorkingTime) { - this.logger.warn(`Working time mismatch after ${operationName}. Backend: ${updatedTask.workingTimeMs}ms, Expected: ${finalWorkingTime}ms. Updating...`); - try { - const correctedTask = await this.taskService.updateWorkingTime(projectId, taskId, finalWorkingTime); - this.logger.info(`Working time corrected after ${operationName}`, { - taskId, - correctedTime: correctedTask.workingTimeMs - }); - return correctedTask as T; - } catch (correctionError) { - this.logger.error(`Failed to correct working time after ${operationName}:`, correctionError); - // Return the original task even if correction failed - return updatedTask; - } - } - - this.logger.info(`${operationName} completed successfully with preserved working time`, { - taskId, - workingTime: updatedTask.workingTimeMs - }); - return updatedTask; - } -} \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskManager.types.ts b/frontend/src/core/workspace/task/taskManager.types.ts deleted file mode 100644 index 872b6e91..00000000 --- a/frontend/src/core/workspace/task/taskManager.types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Task, TaskStatus, PipelineResultDto } from '@/services/project/task/task.types'; -import type { GetTasksResponse } from '@/services/project/task/taskService.types'; - -/** - * Result types for task operations - */ -export interface TaskResult { - success: boolean; - task?: Task; - message?: string; - error?: string; -} - -/** - * Interface for task service dependency - */ -export interface TaskService { - getTaskById(projectId: number, taskId: number): Promise; - getTasksForAsset(projectId: number, assetId: number): Promise; - getTasksForStage(projectId: number, stageId: number): Promise; - completeTaskPipeline(projectId: number, taskId: number): Promise; - vetoTaskPipeline(projectId: number, taskId: number, data: { reason: string }): Promise; - changeTaskStatus(projectId: number, taskId: number, data: { targetStatus: TaskStatus }): Promise; - updateWorkingTime(projectId: number, taskId: number, workingTimeMs: number): Promise; - saveWorkingTimeBeforeUnload(projectId: number, taskId: number, workingTimeMs: number): Promise; -} - -/** - * Interface for permissions service dependency - */ -export interface PermissionsService { - canUpdateProject(): Promise; -} \ No newline at end of file diff --git a/frontend/src/views/AnnotationWorkspace.vue b/frontend/src/views/AnnotationWorkspace.vue index de069a9d..080b46bb 100644 --- a/frontend/src/views/AnnotationWorkspace.vue +++ b/frontend/src/views/AnnotationWorkspace.vue @@ -415,17 +415,6 @@ onMounted(async () => { window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('pagehide', handlePageHide); document.addEventListener('visibilitychange', handleVisibilityChange); - - // Also handle focus/blur events - window.addEventListener('blur', () => { - // Window lost focus - time tracker will handle this - console.log('Window lost focus'); - }); - - window.addEventListener('focus', () => { - // Window gained focus - time tracker will handle this - console.log('Window gained focus'); - }); }); onUnmounted(() => { From cd3c2e7514c93d8596a93977ebb7cf2087a67367 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:17 +0200 Subject: [PATCH 18/23] fix: Correct service mock path in assetManager test and update comments in timeTracker and taskNavigationManager --- frontend/src/core/asset/__tests__/assetManager.test.ts | 2 +- frontend/src/core/timeTracking/timeTracker.ts | 2 +- frontend/src/core/workspace/task/index.ts | 2 -- frontend/src/core/workspace/task/taskNavigationManager.ts | 5 ++++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/core/asset/__tests__/assetManager.test.ts b/frontend/src/core/asset/__tests__/assetManager.test.ts index f64bd471..75b75505 100644 --- a/frontend/src/core/asset/__tests__/assetManager.test.ts +++ b/frontend/src/core/asset/__tests__/assetManager.test.ts @@ -3,7 +3,7 @@ import { AssetManager } from "../assetManager"; import { type Asset, AssetStatus } from '@/core/asset/asset.types'; // Mock the services -vi.mock("@/services/projects", () => ({ +vi.mock("@/services/project", () => ({ assetService: { getAssetById: vi.fn(), }, diff --git a/frontend/src/core/timeTracking/timeTracker.ts b/frontend/src/core/timeTracking/timeTracker.ts index e7f13fce..04ecb32d 100644 --- a/frontend/src/core/timeTracking/timeTracker.ts +++ b/frontend/src/core/timeTracking/timeTracker.ts @@ -211,7 +211,7 @@ export class TimeTracker implements TimerService { } /** - * Start timer (simplified interface for TaskManager compatibility) + * Start timer */ start(): void { if (this.currentSession) { diff --git a/frontend/src/core/workspace/task/index.ts b/frontend/src/core/workspace/task/index.ts index f691b819..bddc2431 100644 --- a/frontend/src/core/workspace/task/index.ts +++ b/frontend/src/core/workspace/task/index.ts @@ -1,6 +1,4 @@ -export { TaskManager } from './taskManager'; export { TaskNavigationManager } from './taskNavigationManager'; // Re-export types from their proper location -export type { TaskResult, TaskService, PermissionsService } from './taskManager.types'; export type { NavigationResult, NavigationInfo } from './taskNavigationManager.types'; \ No newline at end of file diff --git a/frontend/src/core/workspace/task/taskNavigationManager.ts b/frontend/src/core/workspace/task/taskNavigationManager.ts index fb8d27fc..82ec5e04 100644 --- a/frontend/src/core/workspace/task/taskNavigationManager.ts +++ b/frontend/src/core/workspace/task/taskNavigationManager.ts @@ -1,8 +1,11 @@ import type { Task } from '@/services/project/task/task.types'; import type { NavigationResult, NavigationInfo } from './taskNavigationManager.types'; -import type { PermissionsService } from './taskManager.types'; import { AppLogger } from '@/core/logger/logger'; +interface PermissionsService { + canUpdateProject(): Promise; +} + /** * Core business logic for task navigation operations. * Handles navigation between tasks, finding next/previous available tasks, From d36559f53a352936bd4f17a53bc1b481bb6e6a78 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:28 +0200 Subject: [PATCH 19/23] fix: Update API client mock paths in labelSchemeService and labelService tests feat: Enhance TaskService with task navigation methods for fetching next, previous, and context tasks --- .../__tests__/labelSchemeService.test.ts | 2 +- .../__tests__/labelService.test.ts | 2 +- .../src/services/project/task/taskService.ts | 73 ++++++++++++++++++- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts b/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts index faa73828..9873bb0f 100644 --- a/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts +++ b/frontend/src/services/project/labelScheme/__tests__/labelSchemeService.test.ts @@ -4,7 +4,7 @@ import type { LabelSchemeResponse, CreateLabelSchemeRequest, UpdateLabelSchemeRe import type { PaginatedResponse } from '@/services/base/paginatedResponse'; // Mock the API client -vi.mock('../apiClient', () => ({ +vi.mock('../../../apiClient', () => ({ default: { get: vi.fn(), post: vi.fn(), diff --git a/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts b/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts index 6dc4e5d8..593e8504 100644 --- a/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts +++ b/frontend/src/services/project/labelScheme/__tests__/labelService.test.ts @@ -4,7 +4,7 @@ import type { LabelResponse, CreateLabelRequest, UpdateLabelRequest } from '../l import type { PaginatedResponse } from '@/services/base/paginatedResponse'; // Mock the API client -vi.mock('../apiClient', () => ({ +vi.mock('../../../apiClient', () => ({ default: { get: vi.fn(), post: vi.fn(), diff --git a/frontend/src/services/project/task/taskService.ts b/frontend/src/services/project/task/taskService.ts index 38d208fe..b18f9db8 100644 --- a/frontend/src/services/project/task/taskService.ts +++ b/frontend/src/services/project/task/taskService.ts @@ -4,6 +4,7 @@ import type { GetTasksResponse, } from './taskService.types'; import { TaskStatus, type ChangeTaskStatusDto, type CompleteTaskDto, type CreateTaskRequest, type PipelineResultDto, type Task, type TaskTableRow, type TaskWithDetails, type UpdateTaskRequest, type VetoTaskDto } from './task.types'; +import type { TaskNavigationResponse } from './taskNavigation.types'; import { workflowStageService } from '../workflow/workflowStageService'; import { assetService } from '../asset/assetService'; @@ -169,10 +170,10 @@ class TaskService extends BaseProjectService { /** * Get current user's tasks */ - async getMyTasks(params: TasksQueryParams = {}): Promise { + async getMyTasks(projectId: number, params: TasksQueryParams = {}): Promise { this.logger.info('Fetching current user tasks', params); - const url = this.getBaseUrl('tasks/my-tasks'); + const url = this.buildProjectUrl(projectId, 'tasks/my-tasks'); const paginatedResponse = await this.getPaginated(url, params); const tasks: Task[] = paginatedResponse.data.map((dto: any) => this.transformTaskDto(dto)); @@ -188,6 +189,21 @@ class TaskService extends BaseProjectService { }; } + /** + * Get current user's assigned tasks for a specific workflow stage + */ + async getMyTasksForStage(projectId: number, stageId: number, params: TasksQueryParams = {}): Promise { + this.logger.info(`Fetching current user tasks for workflow stage ${stageId}`, params); + + const stageParams = { + ...params, + filterOn: 'current_workflow_stage_id', + filterQuery: stageId.toString() + }; + + return this.getMyTasks(projectId, stageParams); + } + /** * Get tasks for a specific asset */ @@ -534,6 +550,59 @@ class TaskService extends BaseProjectService { this.logger.debug(`Can veto task ${taskId}: ${canVeto}`); return canVeto; } + + // ==================== Navigation API ==================== + + /** + * Get the next available task for the current user + */ + async getNextTask(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting next task for current user from task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/next'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Next task result: taskId=${response.taskId}, hasNext=${response.hasNext}`); + return response; + } + + /** + * Get the previous available task for the current user + */ + async getPreviousTask(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting previous task for current user from task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/previous'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Previous task result: taskId=${response.taskId}, hasPrevious=${response.hasPrevious}`); + return response; + } + + /** + * Get navigation context for the current task + */ + async getNavigationContext(projectId: number, currentTaskId: number, workflowStageId?: number): Promise { + this.logger.info(`Getting navigation context for task ${currentTaskId} in project ${projectId}`, { workflowStageId }); + + const url = this.buildProjectUrl(projectId, 'tasks/navigation/context'); + const params = new URLSearchParams({ + currentTaskId: currentTaskId.toString(), + ...(workflowStageId && { workflowStageId: workflowStageId.toString() }) + }); + + const response = await this.get(`${url}?${params}`); + this.logger.info(`Navigation context: position=${response.currentPosition}/${response.totalTasks}, hasNext=${response.hasNext}, hasPrevious=${response.hasPrevious}`); + return response; + } } export const taskService = new TaskService(); \ No newline at end of file From d9ab7319ee2b176d69989added24b3ca8ff5b71d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:32 +0200 Subject: [PATCH 20/23] feat: Add TaskNavigationResponse and TaskNavigationInfo interfaces for task navigation --- .../project/task/taskNavigation.types.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/src/services/project/task/taskNavigation.types.ts diff --git a/frontend/src/services/project/task/taskNavigation.types.ts b/frontend/src/services/project/task/taskNavigation.types.ts new file mode 100644 index 00000000..0eb99154 --- /dev/null +++ b/frontend/src/services/project/task/taskNavigation.types.ts @@ -0,0 +1,34 @@ +/** + * Response from task navigation endpoints + */ +export interface TaskNavigationResponse { + /** ID of the next/previous task, null if none available */ + taskId?: number | null; + + /** Asset ID of the next/previous task, null if none available */ + assetId?: number | null; + + /** Whether there is a next task available */ + hasNext: boolean; + + /** Whether there is a previous task available */ + hasPrevious: boolean; + + /** Current position in the navigation sequence (1-based) */ + currentPosition: number; + + /** Total number of navigable tasks */ + totalTasks: number; + + /** Optional message for user feedback */ + message?: string | null; +} + +/** + * Information for navigating to a task + */ +export interface TaskNavigationInfo { + projectId: string; + assetId: string; + taskId: string; +} \ No newline at end of file From 330dc5fd250568c2386ca8ace575fa75023a0bd6 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:43 +0200 Subject: [PATCH 21/23] fix: Correct service mock paths in authStore and workspaceStore tests --- frontend/src/stores/__tests__/authStore.test.ts | 7 +++++++ frontend/src/stores/__tests__/workspaceStore.test.ts | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/stores/__tests__/authStore.test.ts b/frontend/src/stores/__tests__/authStore.test.ts index 1625dda4..dbcbeb75 100644 --- a/frontend/src/stores/__tests__/authStore.test.ts +++ b/frontend/src/stores/__tests__/authStore.test.ts @@ -11,6 +11,13 @@ vi.mock("@/services/auth/authService", () => ({ }, })); +vi.mock("../permissionStore", () => ({ + usePermissionStore: vi.fn(() => ({ + loadUserPermissions: vi.fn().mockResolvedValue(undefined), + clearPermissions: vi.fn(), + })), +})); + import { authService } from "@/services/auth/authService.ts"; import { RoleEnum, type AuthTokens, type LoginDto, type UserDto } from "@/services/auth/auth.types"; diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/stores/__tests__/workspaceStore.test.ts index eb315857..bff84b4c 100644 --- a/frontend/src/stores/__tests__/workspaceStore.test.ts +++ b/frontend/src/stores/__tests__/workspaceStore.test.ts @@ -9,8 +9,8 @@ import { AnnotationType } from "@/core/workspace/annotation.types"; import type { LabelScheme } from "@/services/project/labelScheme/label.types"; import { AssetStatus } from '@/core/asset/asset.types'; -// Mock the services from projects -vi.mock("@/services/projects", () => ({ +// Mock the services from project +vi.mock("@/services/project", () => ({ annotationService: { getAnnotationsForAsset: vi.fn(), createAnnotation: vi.fn(), @@ -40,7 +40,8 @@ vi.mock("@/services/projects", () => ({ import { annotationService, assetService, - labelSchemeService + labelSchemeService, + taskService } from "@/services/project"; // Mock the TimeTracker @@ -286,7 +287,6 @@ describe("Workspace Store", () => { }); // Mock task service - const { taskService } = await import("@/services/project"); vi.mocked(taskService.getTasksForAsset).mockResolvedValue([]); await workspaceStore.loadAsset("1", "1"); @@ -351,7 +351,7 @@ describe("Workspace Store", () => { labelId: mockAnnotation.labelId, isPrediction: false, confidenceScore: undefined, - isGroundTruth: false, + isGroundTruth: undefined, version: 1, notes: undefined, annotatorEmail: undefined, From 6a75628b83024a2c739b2d0ccec0974e9cb94e29 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:49 +0200 Subject: [PATCH 22/23] feat: Implement task navigation context in workspaceStore and update related methods --- frontend/src/stores/workspaceStore.ts | 324 ++++++++++---------- frontend/src/stores/workspaceStore.types.ts | 16 +- 2 files changed, 177 insertions(+), 163 deletions(-) diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/stores/workspaceStore.ts index 6c2ec100..62e895f6 100644 --- a/frontend/src/stores/workspaceStore.ts +++ b/frontend/src/stores/workspaceStore.ts @@ -1,12 +1,12 @@ import { defineStore } from "pinia"; import { faArrowPointer, faDotCircle, faMinus, faWaveSquare, faSquare, faDrawPolygon } from '@fortawesome/free-solid-svg-icons'; import type { ImageDimensions } from "@/core/asset/asset.types"; -import type { WorkspaceState } from "./workspaceStore.types"; +import type { WorkspaceState, TaskNavigationContext } from "./workspaceStore.types"; import type { Point } from "@/core/geometry/geometry.types"; import { ToolName, type Tool } from "@/core/workspace/tools.types"; import type { Annotation, CreateAnnotationDto } from '@/core/workspace/annotation.types'; import type { LabelScheme, Label } from '@/services/project/labelScheme/label.types'; -import { AssetManager, TaskManager, TaskNavigationManager } from '@/core/workspace'; +import { AssetManager } from '@/core/workspace'; import { TimeTracker } from '@/core/timeTracking'; import { annotationService, @@ -65,6 +65,7 @@ export const useWorkspaceStore = defineStore("workspace", { currentWorkflowStageType: null as WorkflowStageType | null, availableTasks: [] as Task[], initialTaskId: null as number | null, // Store the initial task ID from URL query + navigationContext: null as TaskNavigationContext | null, isLoading: false, error: null, }), @@ -131,31 +132,16 @@ export const useWorkspaceStore = defineStore("workspace", { } return state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); }, - getTaskNavigationInfo(state): { current: number; total: number } { - // Filter out tasks that cannot be opened for navigation - // Note: Deferred tasks permission checking is handled in the navigation buttons themselves - const accessibleTasks = state.availableTasks.filter((task: Task) => { - // Vetoed tasks cannot be opened (they are view-only) - if (task.status === TaskStatus.VETOED) { - return false; - } - - // Completed and archived tasks cannot be opened for editing - if (task.status && [TaskStatus.COMPLETED, TaskStatus.ARCHIVED].includes(task.status)) { - return false; - } - - return true; - }); - - const currentIndex = state.currentTaskData && accessibleTasks.length > 0 - ? accessibleTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id) - : -1; + getTaskNavigationInfo(): { current: number; total: number } { + // Simple navigation info from server context + if (this.navigationContext) { + return { + current: this.navigationContext.currentPosition, + total: this.navigationContext.totalTasks + }; + } - return { - current: currentIndex >= 0 ? currentIndex + 1 : 0, - total: accessibleTasks.length - }; + return { current: 0, total: 0 }; }, isTaskCompleted(state): boolean { if (!state.currentTaskData) return false; @@ -172,15 +158,11 @@ export const useWorkspaceStore = defineStore("workspace", { // Annotation editing is disabled when task is completed (preview mode) return this.isTaskCompleted; }, - canNavigateToPrevious(state): boolean { - if (!state.currentTaskData || state.availableTasks.length === 0) return false; - const currentIndex = state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); - return currentIndex > 0; + canNavigateToPrevious(): boolean { + return this.navigationContext?.hasPrevious ?? false; }, - canNavigateToNext(state): boolean { - if (!state.currentTaskData || state.availableTasks.length === 0) return false; - const currentIndex = state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); - return currentIndex < state.availableTasks.length - 1; + canNavigateToNext(): boolean { + return this.navigationContext?.hasNext ?? false; }, canCompleteCurrentTask(state): boolean { if (!state.currentTaskData || !state.currentAssetData) { @@ -226,40 +208,6 @@ export const useWorkspaceStore = defineStore("workspace", { return false; }, - // Task management core instances with access to store state - taskManager(): TaskManager { - const permissionsService = { - canUpdateProject: async () => { - // Import permission check dynamically to avoid circular dependencies - try { - const { usePermissions } = await import('@/composables/usePermissions'); - const { canUpdateProject } = usePermissions(); - return canUpdateProject.value; - } catch { - return false; - } - } - }; - - return new TaskManager(taskService, permissionsService, timeTracker); - }, - - taskNavigationManager(): TaskNavigationManager { - const permissionsService = { - canUpdateProject: async () => { - // Import permission check dynamically to avoid circular dependencies - try { - const { usePermissions } = await import('@/composables/usePermissions'); - const { canUpdateProject } = usePermissions(); - return canUpdateProject.value; - } catch { - return false; - } - } - }; - - return new TaskNavigationManager(permissionsService); - } }, actions: { @@ -338,12 +286,42 @@ export const useWorkspaceStore = defineStore("workspace", { } if (currentTask && workflowStageId) { - // Load all tasks for this workflow stage to enable navigation - const stageTasks = await taskService.getTasksForStage(numericProjectId, workflowStageId); - this.availableTasks = stageTasks.tasks; + // Load user's assigned tasks for this workflow stage to enable proper navigation + const assignedTasks = await taskService.getMyTasksForStage(numericProjectId, workflowStageId); + let tasksForNavigation = assignedTasks.tasks; + + // If current task is not in the assigned tasks list (e.g., completed task viewed in preview mode), + // add it to the list so the UI shows correct context, but navigation will still skip completed tasks + const currentTaskInList = tasksForNavigation.find(task => task.id === currentTask.id); + if (!currentTaskInList) { + tasksForNavigation = [...tasksForNavigation, currentTask]; + logger.info(`Added current task ${currentTask.id} to navigation context (likely viewing completed task in preview mode)`); + } + + this.availableTasks = tasksForNavigation; this.currentTaskData = currentTask; this.currentTaskId = currentTask.id; + // Load navigation context from server + try { + const navigationContext = await taskService.getNavigationContext( + numericProjectId, + currentTask.id, + currentTask.workflowStageId + ); + this.navigationContext = { + hasNext: navigationContext.hasNext, + hasPrevious: navigationContext.hasPrevious, + currentPosition: navigationContext.currentPosition, + totalTasks: navigationContext.totalTasks, + message: navigationContext.message + }; + logger.info(`Loaded navigation context: ${navigationContext.currentPosition}/${navigationContext.totalTasks}, hasNext=${navigationContext.hasNext}, hasPrevious=${navigationContext.hasPrevious}`); + } catch (navigationError) { + logger.warn('Failed to load navigation context:', navigationError); + this.navigationContext = null; + } + // Fetch current workflow stage type for completion logic try { const stageData = await workflowStageService.getWorkflowStageById( @@ -816,56 +794,92 @@ export const useWorkspaceStore = defineStore("workspace", { * Check if a task can be opened by the current user */ async canOpenTask(task: Task): Promise { - // Use TaskManager to check if the task can be opened - return await this.taskManager.canOpenTask(task); + // Simple check - most tasks can be opened, except archived + return task.status !== TaskStatus.ARCHIVED; }, /** * Navigate to the previous task in the current workflow stage */ async navigateToPreviousTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot navigate to previous task: no current task or available tasks'); + if (!this.currentTaskData || !this.currentProjectId) { + logger.warn('Cannot navigate to previous task: no current task or project'); return null; } - // Use TaskNavigationManager to find previous task - const result = await this.taskNavigationManager.navigateToPrevious( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); + try { + const navigation = await taskService.getPreviousTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); + + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to previous task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } - if (!result.success) { - logger.error('Failed to navigate to previous task:', result.error); + logger.info('No previous task available'); + return null; + } catch (error) { + logger.error('Failed to navigate to previous task:', error); return null; } - - return result.navigation; }, /** * Navigate to the next task in the current workflow stage */ async navigateToNextTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot navigate to next task: no current task or available tasks'); + if (!this.currentTaskData || !this.currentProjectId) { + logger.warn('Cannot navigate to next task: no current task or project'); return null; } - // Use TaskNavigationManager to find next task - const result = await this.taskNavigationManager.navigateToNext( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); + try { + const navigation = await taskService.getNextTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); - if (!result.success) { - logger.error('Failed to navigate to next task:', result.error); + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to next task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } + + logger.info('No next task available'); + return null; + } catch (error) { + logger.error('Failed to navigate to next task:', error); return null; } - - return result.navigation; }, /** @@ -880,28 +894,28 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to complete the task - const result = await this.taskManager.completeTask(numericProjectId, this.currentTaskData.id); + // Use TaskService pipeline to complete the task + const result = await taskService.completeTaskPipeline(numericProjectId, this.currentTaskData.id); - if (!result.success) { - this.error = result.error || 'Failed to complete task'; + if (!result.isSuccess) { + this.error = result.errorMessage || 'Failed to complete task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; + if (result.updatedTask) { + this.currentTaskData = result.updatedTask; // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; + this.availableTasks[taskIndex] = result.updatedTask; } } return true; } catch (error) { - logger.error('Failed to complete task via TaskManager:', error); + logger.error('Failed to complete task:', error); this.error = error instanceof Error ? error.message : 'Failed to complete task'; return false; } @@ -919,32 +933,33 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to suspend the task with working time preservation - const result = await this.taskManager.suspendTask( + // Update working time before suspending + await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Use TaskService to suspend the task + const result = await taskService.changeTaskStatus( numericProjectId, this.currentTaskData.id, - this.lastSavedWorkingTime + { targetStatus: TaskStatus.SUSPENDED } ); - if (!result.success) { - this.error = result.error || 'Failed to suspend task'; + if (!result) { + this.error = 'Failed to suspend task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; - } + this.currentTaskData = result; + + // Update the task in the available tasks list + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); + if (taskIndex !== -1) { + this.availableTasks[taskIndex] = result; } return true; } catch (error) { - logger.error('Failed to suspend task via TaskManager:', error); + logger.error('Failed to suspend task:', error); this.error = error instanceof Error ? error.message : 'Failed to suspend task'; return false; } @@ -962,32 +977,33 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to defer the task with working time preservation - const result = await this.taskManager.deferTask( + // Update working time before deferring + await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Use TaskService to defer the task + const result = await taskService.changeTaskStatus( numericProjectId, this.currentTaskData.id, - this.lastSavedWorkingTime + { targetStatus: TaskStatus.DEFERRED } ); - if (!result.success) { - this.error = result.error || 'Failed to defer task'; + if (!result) { + this.error = 'Failed to defer task'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; - } + this.currentTaskData = result; + + // Update the task in the available tasks list + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); + if (taskIndex !== -1) { + this.availableTasks[taskIndex] = result; } return true; } catch (error) { - logger.error('Failed to defer task via TaskManager:', error); + logger.error('Failed to defer task:', error); this.error = error instanceof Error ? error.message : 'Failed to defer task'; return false; } @@ -1005,32 +1021,32 @@ export const useWorkspaceStore = defineStore("workspace", { try { const numericProjectId = parseInt(this.currentProjectId); - // Use TaskManager to veto the task - const result = await this.taskManager.vetoTask( + // Use TaskService pipeline to veto the task + const result = await taskService.vetoTaskPipeline( numericProjectId, this.currentTaskData.id, - reason + { reason: reason || 'Task returned for rework' } ); - if (!result.success) { - this.error = result.error || 'Failed to return task for rework'; + if (!result.isSuccess) { + this.error = result.errorMessage || 'Failed to return task for rework'; return false; } // Update current task data - if (result.task) { - this.currentTaskData = result.task; + if (result.updatedTask) { + this.currentTaskData = result.updatedTask; // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.task!.id); + const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.task; + this.availableTasks[taskIndex] = result.updatedTask; } } return true; } catch (error) { - logger.error('Failed to return task for rework via TaskManager:', error); + logger.error('Failed to return task for rework:', error); this.error = error instanceof Error ? error.message : 'Failed to return task for rework'; return false; } @@ -1040,24 +1056,8 @@ export const useWorkspaceStore = defineStore("workspace", { * Get the next available task for seamless transitions */ async getNextAvailableTask(): Promise<{ projectId: string; assetId: string; taskId: string } | null> { - if (!this.currentTaskData || this.availableTasks.length === 0) { - logger.warn('Cannot get next task: no current task or available tasks'); - return null; - } - - // Use TaskNavigationManager to find next available task - const result = await this.taskNavigationManager.getNextAvailableTask( - this.currentTaskData, - this.availableTasks, - this.currentProjectId! - ); - - if (!result.success) { - logger.error('Failed to get next available task:', result.error); - return null; - } - - return result.navigation; + // Simply delegate to navigateToNextTask - they do the same thing + return this.navigateToNextTask(); }, /** diff --git a/frontend/src/stores/workspaceStore.types.ts b/frontend/src/stores/workspaceStore.types.ts index afe91ebc..10479c33 100644 --- a/frontend/src/stores/workspaceStore.types.ts +++ b/frontend/src/stores/workspaceStore.types.ts @@ -7,6 +7,17 @@ import type { Asset } from '@/core/asset/asset.types'; import type { Task } from '@/services/project/task/task.types'; import type { WorkflowStageType } from '@/services/project/workflow/workflowStage.types'; +/** + * Simple navigation context from server + */ +export interface TaskNavigationContext { + hasNext: boolean; + hasPrevious: boolean; + currentPosition: number; + totalTasks: number; + message?: string | null; +} + /** * Current flat workspace store state interface (legacy structure) * This matches the actual current implementation in workspaceStore.ts @@ -43,8 +54,11 @@ export interface WorkspaceState { currentTaskId: number | null; currentTaskData: Task | null; currentWorkflowStageType: WorkflowStageType | null; - availableTasks: Task[]; + availableTasks: Task[]; // Legacy - will be removed initialTaskId: number | null; + + // Simple navigation context + navigationContext: TaskNavigationContext | null; } /** From 238a59a9b3d3c5c2b2cff7af98601cbde8854f3c Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sat, 6 Sep 2025 21:56:54 +0200 Subject: [PATCH 23/23] feat: Add toggle for completed tasks visibility in TasksView --- frontend/src/views/project/TasksView.vue | 29 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/project/TasksView.vue b/frontend/src/views/project/TasksView.vue index 843c1d0d..d3ec8947 100644 --- a/frontend/src/views/project/TasksView.vue +++ b/frontend/src/views/project/TasksView.vue @@ -40,6 +40,13 @@ > {{ showDeferredTasks ? 'Hide Deferred' : 'Show Deferred' }} +