diff --git a/Api/Features/Tracking/TrackingControllerTests.cs b/Api/Features/Tracking/TrackingControllerTests.cs index 5067cd81..8b2b217b 100644 --- a/Api/Features/Tracking/TrackingControllerTests.cs +++ b/Api/Features/Tracking/TrackingControllerTests.cs @@ -24,6 +24,7 @@ public async Task CreateTaskEntryAsync_ShouldThrowValidationErrorIfAtLeastOneOfR TaskId = "#2231", ProjectId = 1, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; var response = await HttpClient.PostAsJsonAsync("/api/time/tracking/task-entries", createTaskEntryRequest); diff --git a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommand.cs b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommand.cs index bbbc17ee..6deb1cb2 100644 --- a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommand.cs +++ b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommand.cs @@ -33,6 +33,7 @@ protected override async Task MakeChangesToEntryAsync(CreateTaskEntryReque ProjectId = createTaskEntryRequest.ProjectId, TaskId = createTaskEntryRequest.TaskId, Description = createTaskEntryRequest.Description, + TimeZoneId = createTaskEntryRequest.TimeZoneId }; await _context diff --git a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommandTests.cs b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommandTests.cs index 8b7b6c01..88105af2 100644 --- a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommandTests.cs +++ b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryCommandTests.cs @@ -27,6 +27,7 @@ public async Task CreateTaskEntryAsync_ShouldThrowInvalidTimeRangeExceptionIfSta TaskId = "#2231", ProjectId = 1, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; var exception = await Assert.ThrowsAsync( @@ -65,6 +66,7 @@ public async Task CreateTaskEntryAsync_ShouldThrowConflictingTimeRangeExceptionI TaskId = "#2232", ProjectId = 1, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; var exception = await Assert.ThrowsAsync( diff --git a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryRequest.cs b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryRequest.cs index 3f72f967..001aee25 100644 --- a/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryRequest.cs +++ b/Application/Features/Tracking/CreateTaskEntry/CreateTaskEntryRequest.cs @@ -21,4 +21,7 @@ public class CreateTaskEntryRequest [Required] public required string Description { get; set; } + + [Required] + public required string TimeZoneId { get; set; } } diff --git a/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryCommand.cs b/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryCommand.cs index 5b97b4a0..ba891d20 100644 --- a/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryCommand.cs +++ b/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryCommand.cs @@ -29,6 +29,7 @@ protected override async Task MakeChangesToEntryAsync(CreateUnwellEntryReq EmployeeId = _claimsProvider.EmployeeId, StartTime = createUnwellEntryRequest.StartTime, EndTime = createUnwellEntryRequest.EndTime, + TimeZoneId = createUnwellEntryRequest.TimeZoneId }; await _context diff --git a/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryRequest.cs b/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryRequest.cs index 49866b18..469baed4 100644 --- a/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryRequest.cs +++ b/Application/Features/Tracking/CreateUnwellEntry/CreateUnwellEntryRequest.cs @@ -9,4 +9,7 @@ public class CreateUnwellEntryRequest [Required] public required DateTime EndTime { get; set; } + + [Required] + public required string TimeZoneId { get; set; } } diff --git a/Application/Features/Tracking/GetEntriesByPeriod/GetEntriesByPeriodHandler.cs b/Application/Features/Tracking/GetEntriesByPeriod/GetEntriesByPeriodHandler.cs index dc60aa07..d42a489a 100644 --- a/Application/Features/Tracking/GetEntriesByPeriod/GetEntriesByPeriodHandler.cs +++ b/Application/Features/Tracking/GetEntriesByPeriod/GetEntriesByPeriodHandler.cs @@ -35,7 +35,7 @@ DateOnly endDate Title = x.Title, ProjectId = x.ProjectId, TaskId = x.TaskId, - Description = x.Description + Description = x.Description, }) .ToList(); diff --git a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommand.cs b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommand.cs index 9a0e3d95..3572ba84 100644 --- a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommand.cs +++ b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommand.cs @@ -35,6 +35,7 @@ await _context .SetProperty(x => x.TaskId, updateTaskEntryRequest.TaskId) .SetProperty(x => x.ProjectId, updateTaskEntryRequest.ProjectId) .SetProperty(x => x.Description, updateTaskEntryRequest.Description) + .SetProperty(x => x.TimeZoneId, updateTaskEntryRequest.TimeZoneId) ); return updateTaskEntryRequest.Id; diff --git a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommandTests.cs b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommandTests.cs index 4925ed3e..21efcad9 100644 --- a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommandTests.cs +++ b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryCommandTests.cs @@ -39,6 +39,7 @@ public async Task UpdateTaskEntryAsync_ShouldThrowInvalidTimeRangeExceptionIfSta TaskId = "#22", ProjectId = 2, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; var exception = await Assert.ThrowsAsync( @@ -89,6 +90,7 @@ public async Task UpdateTaskEntryAsync_ShouldThrowConflictingTimeRangeExceptionI TaskId = "#2232", ProjectId = 1, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; var exception = await Assert.ThrowsAsync( @@ -129,6 +131,7 @@ public async Task UpdateTaskEntryAsync_ShouldNotUpdateDeletedTaskEntry() TaskId = "#2232", ProjectId = 1, Description = "Task description", + TimeZoneId = "Asia/Yekaterinburg" }; await updateTaskEntryCommand.ExecuteAsync(updateTaskEntryRequest); diff --git a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryRequest.cs b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryRequest.cs index a838f04d..1aea5e55 100644 --- a/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryRequest.cs +++ b/Application/Features/Tracking/UpdateTaskEntry/UpdateTaskEntryRequest.cs @@ -23,4 +23,7 @@ public class UpdateTaskEntryRequest [Required] public required string Description { get; set; } + + [Required] + public required string TimeZoneId { get; set; } } diff --git a/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryCommand.cs b/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryCommand.cs index 637dc972..94fd5f8e 100644 --- a/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryCommand.cs +++ b/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryCommand.cs @@ -31,6 +31,7 @@ await _context .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.StartTime, updateUnwellEntryRequest.StartTime) .SetProperty(x => x.EndTime, updateUnwellEntryRequest.EndTime) + .SetProperty(x => x.TimeZoneId, updateUnwellEntryRequest.TimeZoneId) ); return updateUnwellEntryRequest.Id; diff --git a/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryRequest.cs b/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryRequest.cs index bb293ce6..b499816d 100644 --- a/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryRequest.cs +++ b/Application/Features/Tracking/UpdateUnwellEntry/UpdateUnwellEntryRequest.cs @@ -11,4 +11,7 @@ public class UpdateUnwellEntryRequest [Required] public required DateTime EndTime { get; set; } + + [Required] + public required string TimeZoneId { get; set; } } diff --git a/Application/Mappings/TrackedEntryBaseMapping.cs b/Application/Mappings/TrackedEntryBaseMapping.cs index a0095b0d..fdac8aad 100644 --- a/Application/Mappings/TrackedEntryBaseMapping.cs +++ b/Application/Mappings/TrackedEntryBaseMapping.cs @@ -27,5 +27,23 @@ public void Configure(EntityTypeBuilder builder) .ToTable(x => x.HasCheckConstraint( "ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\"")); + + builder + .Property(x => x.StartTimeUtc) + .HasComputedColumnSql( + "start_time AT TIME ZONE time_zone_id", + stored: true + ) + .HasColumnType("timestamptz") + .ValueGeneratedOnAddOrUpdate(); + + builder + .Property(x => x.EndTimeUtc) + .HasComputedColumnSql( + "end_time AT TIME ZONE time_zone_id", + stored: true + ) + .HasColumnType("timestamptz") + .ValueGeneratedOnAddOrUpdate(); } } diff --git a/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.Designer.cs b/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.Designer.cs new file mode 100644 index 00000000..53a7e261 --- /dev/null +++ b/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.Designer.cs @@ -0,0 +1,158 @@ +// +using System; +using Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired")] + partial class AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Entities.TrackedEntryBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DeletionReason") + .HasColumnType("text") + .HasColumnName("deletion_reason"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)") + .HasColumnName("discriminator"); + + b.Property("Duration") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("interval") + .HasColumnName("duration") + .HasComputedColumnSql("end_time - start_time", true); + + b.Property("EmployeeId") + .HasColumnType("bigint") + .HasColumnName("employee_id"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_time"); + + b.Property("EndTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("end_time_utc") + .HasComputedColumnSql("end_time AT TIME ZONE time_zone_id", true); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_time"); + + b.Property("StartTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("start_time_utc") + .HasComputedColumnSql("start_time AT TIME ZONE time_zone_id", true); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_tracked_entries"); + + b.ToTable("tracked_entries", null, t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TrackedEntryBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Core.Entities.TaskEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ProjectId") + .HasColumnType("bigint") + .HasColumnName("project_id"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TaskEntry"); + }); + + modelBuilder.Entity("Core.Entities.UnwellEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("UnwellEntry"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.cs b/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.cs new file mode 100644 index 00000000..853afed4 --- /dev/null +++ b/Application/Migrations/20260304065519_AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Application.Migrations +{ + /// + public partial class AddStartTimeUtcAndEndTimeUtcAndMakeTimeZoneIdRequired : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "time_zone_id", + table: "tracked_entries", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "end_time_utc", + table: "tracked_entries", + type: "timestamptz", + nullable: false, + computedColumnSql: "end_time AT TIME ZONE time_zone_id", + stored: true); + + migrationBuilder.AddColumn( + name: "start_time_utc", + table: "tracked_entries", + type: "timestamptz", + nullable: false, + computedColumnSql: "start_time AT TIME ZONE time_zone_id", + stored: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "end_time_utc", + table: "tracked_entries"); + + migrationBuilder.DropColumn( + name: "start_time_utc", + table: "tracked_entries"); + + migrationBuilder.AlterColumn( + name: "time_zone_id", + table: "tracked_entries", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.Designer.cs b/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.Designer.cs new file mode 100644 index 00000000..783b5f74 --- /dev/null +++ b/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.Designer.cs @@ -0,0 +1,158 @@ +// +using System; +using Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap")] + partial class RemoveOldCkEntriesTaskUnwellNoTimeOverlap + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Entities.TrackedEntryBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DeletionReason") + .HasColumnType("text") + .HasColumnName("deletion_reason"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)") + .HasColumnName("discriminator"); + + b.Property("Duration") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("interval") + .HasColumnName("duration") + .HasComputedColumnSql("end_time - start_time", true); + + b.Property("EmployeeId") + .HasColumnType("bigint") + .HasColumnName("employee_id"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_time"); + + b.Property("EndTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("end_time_utc") + .HasComputedColumnSql("end_time AT TIME ZONE time_zone_id", true); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_time"); + + b.Property("StartTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("start_time_utc") + .HasComputedColumnSql("start_time AT TIME ZONE time_zone_id", true); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_tracked_entries"); + + b.ToTable("tracked_entries", null, t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TrackedEntryBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Core.Entities.TaskEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ProjectId") + .HasColumnType("bigint") + .HasColumnName("project_id"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TaskEntry"); + }); + + modelBuilder.Entity("Core.Entities.UnwellEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("UnwellEntry"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.cs b/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.cs new file mode 100644 index 00000000..8f90ee7b --- /dev/null +++ b/Application/Migrations/20260304065622_RemoveOldCkEntriesTaskUnwellNoTimeOverlap.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Application.Migrations +{ + /// + public partial class RemoveOldCkEntriesTaskUnwellNoTimeOverlap : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Drop constraint + migrationBuilder.Sql(@" + ALTER TABLE tracked_entries + DROP CONSTRAINT IF EXISTS ck_entries_task_unwell_no_time_overlap; + "); + // Drop btree_gist extension + migrationBuilder.Sql(@" + DROP EXTENSION IF EXISTS btree_gist; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Enable the btree_gist PostgreSQL extension. + // This extension makes it possible for our complex constraint to work with several fields. + // In our case they are: tenant_id, employee_id, and tsrange + migrationBuilder.Sql(@" + CREATE EXTENSION IF NOT EXISTS btree_gist; + "); + // ADR about validation can be read here https://github.com/TourmalineCore/inner-circle-documentation/blob/master/time-tracker/adrs/003-time-api-overlap-validation.md + migrationBuilder.Sql(@" + ALTER TABLE tracked_entries + ADD CONSTRAINT ck_entries_task_unwell_no_time_overlap + EXCLUDE USING GIST ( + tenant_id WITH =, + employee_id WITH =, + tsrange(start_time, end_time, '[)') WITH && + ) + WHERE (type IN (1, 2) AND deleted_at_utc IS NULL); + "); + } + } +} diff --git a/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.Designer.cs b/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.Designer.cs new file mode 100644 index 00000000..0d71c3a4 --- /dev/null +++ b/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.Designer.cs @@ -0,0 +1,158 @@ +// +using System; +using Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId")] + partial class AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Entities.TrackedEntryBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DeletionReason") + .HasColumnType("text") + .HasColumnName("deletion_reason"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)") + .HasColumnName("discriminator"); + + b.Property("Duration") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("interval") + .HasColumnName("duration") + .HasComputedColumnSql("end_time - start_time", true); + + b.Property("EmployeeId") + .HasColumnType("bigint") + .HasColumnName("employee_id"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_time"); + + b.Property("EndTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("end_time_utc") + .HasComputedColumnSql("end_time AT TIME ZONE time_zone_id", true); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_time"); + + b.Property("StartTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("start_time_utc") + .HasComputedColumnSql("start_time AT TIME ZONE time_zone_id", true); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("tenant_id"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_tracked_entries"); + + b.ToTable("tracked_entries", null, t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TrackedEntryBase"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Core.Entities.TaskEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ProjectId") + .HasColumnType("bigint") + .HasColumnName("project_id"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("TaskEntry"); + }); + + modelBuilder.Entity("Core.Entities.UnwellEntry", b => + { + b.HasBaseType("Core.Entities.TrackedEntryBase"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_entries_end_time_is_greater_than_start_time", "\"end_time\" > \"start_time\""); + + t.HasCheckConstraint("ck_entries_type_not_zero", "\"type\" <> 0"); + }); + + b.HasDiscriminator().HasValue("UnwellEntry"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.cs b/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.cs new file mode 100644 index 00000000..822e5709 --- /dev/null +++ b/Application/Migrations/20260304065845_AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Application.Migrations +{ + /// + public partial class AddCkEntriesTaskUnwellNoTimeOverlapWithTimeZoneId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Enable the btree_gist PostgreSQL extension. + // This extension makes it possible for our complex constraint to work with several fields. + // In our case they are: tenant_id, employee_id, and tsrange + migrationBuilder.Sql(@" + CREATE EXTENSION IF NOT EXISTS btree_gist; + "); + + // ADR about validation can be read here https://github.com/TourmalineCore/inner-circle-documentation/blob/master/time-tracker/adrs/003-time-api-overlap-validation.md + migrationBuilder.Sql(@" + ALTER TABLE tracked_entries + ADD CONSTRAINT ck_entries_task_unwell_no_time_overlap + EXCLUDE USING GIST ( + tenant_id WITH =, + employee_id WITH =, + tstzrange(start_time_utc, end_time_utc, '[)') WITH && + ) + WHERE (type IN (1, 2) AND deleted_at_utc IS NULL); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + ALTER TABLE tracked_entries + DROP CONSTRAINT IF EXISTS ck_entries_task_unwell_no_time_overlap; + "); + + migrationBuilder.Sql(@" + DROP EXTENSION IF EXISTS btree_gist; + "); + } + } +} diff --git a/Application/Migrations/AppDbContextModelSnapshot.cs b/Application/Migrations/AppDbContextModelSnapshot.cs index d266965b..49b8ba26 100644 --- a/Application/Migrations/AppDbContextModelSnapshot.cs +++ b/Application/Migrations/AppDbContextModelSnapshot.cs @@ -59,15 +59,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp without time zone") .HasColumnName("end_time"); + b.Property("EndTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("end_time_utc") + .HasComputedColumnSql("end_time AT TIME ZONE time_zone_id", true); + b.Property("StartTime") .HasColumnType("timestamp without time zone") .HasColumnName("start_time"); + b.Property("StartTimeUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamptz") + .HasColumnName("start_time_utc") + .HasComputedColumnSql("start_time AT TIME ZONE time_zone_id", true); + b.Property("TenantId") .HasColumnType("bigint") .HasColumnName("tenant_id"); b.Property("TimeZoneId") + .IsRequired() .HasColumnType("text") .HasColumnName("time_zone_id"); diff --git a/Core/Entities/TrackedEntryBase.cs b/Core/Entities/TrackedEntryBase.cs index 747b5b27..f8c7687b 100644 --- a/Core/Entities/TrackedEntryBase.cs +++ b/Core/Entities/TrackedEntryBase.cs @@ -15,8 +15,11 @@ public TrackedEntryBase() public DateTime EndTime { get; set; } - // TODO: make it required when we add this prop to frontend - public string? TimeZoneId { get; set; } + public DateTime StartTimeUtc { get; private set; } + + public DateTime EndTimeUtc { get; private set; } + + public string TimeZoneId { get; set; } public TimeSpan Duration { get; set; } diff --git a/README.md b/README.md index 37fed67a..5757cf26 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # inner-circle-time-api -[![coverage](https://img.shields.io/badge/e2e_coverage-22.20%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/units_coverage-16.07%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/integration_coverage-59.04%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) -[![coverage](https://img.shields.io/badge/full_coverage-83.30%25-olivedrab)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/e2e_coverage-19.74%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/units_coverage-13.93%25-crimson)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/integration_coverage-64.12%25-orange)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/full_coverage-84.63%25-olivedrab)](https://github.com/TourmalineCore/inner-circle-time-api/actions/workflows/calculate-tests-coverage-on-pull-request.yml) This repo contains Inner Circle Time API. @@ -117,7 +117,9 @@ However, UI doesn't support requests execution, this requires adding Auth dialog interval Duration bigint EmployeeId timestampwithouttimezone EndTime + timestamptz EndTimeUtc timestampwithouttimezone StartTime + timestamptz StartTimeUtc bigint TenantId text TimeZoneId integer Type diff --git a/e2e/task-entries-creation-employees-isolation.feature b/e2e/task-entries-creation-employees-isolation.feature index beac7b92..287e2053 100644 --- a/e2e/task-entries-creation-employees-isolation.feature +++ b/e2e/task-entries-creation-employees-isolation.feature @@ -42,6 +42,8 @@ Feature: Task Entries * def dracoAccountProjectId = response.projects[0].id + * def timeZoneId = 'Asia/Yekaterinburg' + # Create a new task entry for Draco # Here we specified 2032 year to avoid conflicts with other tests * def randomTitle = '[API-E2E]-Test-task-entry-' + Math.random() @@ -61,6 +63,7 @@ Feature: Task Entries "projectId": #(dracoAccountProjectId), "taskId": "#(taskId)", "description": "#(description)", + "timeZoneId": "#(timeZoneId)" } """ When method POST @@ -107,6 +110,7 @@ Feature: Task Entries "projectId": #(severusAccountProjectId), "taskId": "#(taskId)", "description": "#(description)", + "timeZoneId": "#(timeZoneId)" } """ When method POST diff --git a/e2e/task-entries-creation-tenants-isolation.feature b/e2e/task-entries-creation-tenants-isolation.feature index b364b4f4..a2cd65f4 100644 --- a/e2e/task-entries-creation-tenants-isolation.feature +++ b/e2e/task-entries-creation-tenants-isolation.feature @@ -42,6 +42,8 @@ Feature: Task Entries * def slytherineTenantProjectId = response.projects[0].id + * def timeZoneId = 'Asia/Yekaterinburg' + # Create a new task entry in slytherin tenant # Here we specified 2031 year to avoid conflicts with other tests * def randomTitle = '[API-E2E]-Test-task-entry-' + Math.random() @@ -61,6 +63,7 @@ Feature: Task Entries "projectId": #(slytherineTenantProjectId), "taskId": "#(taskId)", "description": "#(description)", + "timeZoneId": "#(timeZoneId)" } """ When method POST @@ -107,6 +110,7 @@ Feature: Task Entries "projectId": #(ravenclawTenantProjectId), "taskId": "#(taskId)", "description": "#(description)", + "timeZoneId": "#(timeZoneId)" } """ When method POST diff --git a/e2e/task-entries-creation-time-zone-isolation.feature b/e2e/task-entries-creation-time-zone-isolation.feature new file mode 100644 index 00000000..470fa5a9 --- /dev/null +++ b/e2e/task-entries-creation-time-zone-isolation.feature @@ -0,0 +1,138 @@ +Feature: Task Entries + # https://github.com/karatelabs/karate/issues/1191 + # https://github.com/karatelabs/karate?tab=readme-ov-file#karate-fork + + Background: + * header Content-Type = 'application/json' + + Scenario: Task Entries Creation Time Zone Isolation + + * def jsUtils = read('./js-utils.js') + * def authApiRootUrl = jsUtils().getEnvVariable('AUTH_API_ROOT_URL') + * def apiRootUrl = jsUtils().getEnvVariable('API_ROOT_URL') + * def authSlytherineTenantDracoLoginWithAllPermissions = jsUtils().getEnvVariable('AUTH_SLYTHERINE_TENANT_DRACO_MALFOY_LOGIN_WITH_ALL_PERMISSIONS') + * def authSlytherineTenantDracoPasswordWithAllPermissions = jsUtils().getEnvVariable('AUTH_SLYTHERINE_TENANT_DRACO_MALFOY_PASSWORD_WITH_ALL_PERMISSIONS') + + # Authentication + Given url authApiRootUrl + And path '/login' + And request + """ + { + "login": "#(authSlytherineTenantDracoLoginWithAllPermissions)", + "password": "#(authSlytherineTenantDracoPasswordWithAllPermissions)" + } + """ + And method POST + Then status 200 + + * def accessToken = karate.toMap(response.accessToken.value) + + * configure headers = jsUtils().getAuthHeaders(accessToken) + + # Get employee's projects + Given url apiRootUrl + Given path 'tracking/task-entries/projects' + And params { startDate: "2033-11-05", endDate: "2033-11-05" } + When method GET + Then status 200 + + * def firstProjectId = response.projects[0].id + * def secondProjectId = response.projects[1].id + + * def startTime = '2033-11-05T14:00:00' + * def endTime = '2033-11-05T16:00:00' + + # Create a new task entry with Asia/Yekaterinburg time zone + * def randomTitle = '[API-E2E]-Test-task-entry-' + Math.random() + * def taskId = '#2233' + * def description = 'Task description' + * def timeZoneIdAsiaYekaterinburg = 'Asia/Yekaterinburg' + + Given url apiRootUrl + Given path 'tracking/task-entries' + And request + """ + { + "title": "#(randomTitle)", + "startTime": "#(startTime)", + "endTime": "#(endTime)", + "projectId": #(firstProjectId), + "taskId": "#(taskId)", + "description": "#(description)", + "timeZoneId": "#(timeZoneIdAsiaYekaterinburg)" + } + """ + When method POST + Then status 200 + + * def newTaskEntryId1 = response.newTaskEntryId + + * def timeZoneIdEuropeMoscow = 'Europe/Moscow' + + Given url apiRootUrl + Given path 'tracking/task-entries' + And request + """ + { + "title": "#(randomTitle)", + "startTime": "#(startTime)", + "endTime": "#(endTime)", + "projectId": #(firstProjectId), + "taskId": "#(taskId)", + "description": "#(description)", + "timeZoneId": "#(timeZoneIdEuropeMoscow)" + } + """ + When method POST + Then status 200 + + * def newTaskEntryId2 = response.newTaskEntryId + + # Verify task entry data + Given path 'tracking/entries' + And params { startDate: "2033-11-05", endDate: "2033-11-05" } + When method GET + And match response.taskEntries contains + """ + { + "id": "#(newTaskEntryId1)", + "type": 1, + "title": "#(randomTitle)", + "startTime": "#(startTime)", + "endTime": "#(endTime)", + "projectId": #(firstProjectId), + "taskId": "#(taskId)", + "description": "#(description)", + }, + { + "id": "#(newTaskEntryId2)", + "type": 1, + "title": "#(randomTitle)", + "startTime": "#(startTime)", + "endTime": "#(endTime)", + "projectId": #(firstProjectId), + "taskId": "#(taskId)", + "description": "#(description)", + } + """ + + # Cleanup: Delete the task entry (hard delete) + Given path 'tracking/entries', newTaskEntryId1, 'hard-delete' + When method DELETE + Then status 200 + And match response == { isDeleted: true } + + # Cleanup: Delete the task entry (hard delete) + Given path 'tracking/entries', newTaskEntryId2, 'hard-delete' + When method DELETE + Then status 200 + And match response == { isDeleted: true } + + # Cleanup Verification: Verify that task entry was deleted + Given path 'tracking/entries' + And params { startDate: "2033-11-05", endDate: "2033-11-05" } + When method GET + Then status 200 + And assert response.taskEntries.filter(x => x.id == newTaskEntryId1).length == 0 + And assert response.taskEntries.filter(x => x.id == newTaskEntryId2).length == 0 \ No newline at end of file diff --git a/e2e/task-entries-happy-path.feature b/e2e/task-entries-happy-path.feature index 8f17a45a..defc92ed 100644 --- a/e2e/task-entries-happy-path.feature +++ b/e2e/task-entries-happy-path.feature @@ -40,6 +40,8 @@ Feature: Task Entries * def firstProjectId = response.projects[0].id * def secondProjectId = response.projects[1].id + * def timeZoneId = 'Asia/Yekaterinburg' + # Create a new task entry * def randomTitle = '[API-E2E]-Test-task-entry-' + Math.random() * def startTime = '2030-11-05T14:00:00' @@ -58,6 +60,7 @@ Feature: Task Entries "projectId": #(firstProjectId), "taskId": "#(taskId)", "description": "#(description)", + "timeZoneId": "#(timeZoneId)" } """ When method POST @@ -82,6 +85,7 @@ Feature: Task Entries "projectId": #(secondProjectId), "taskId": "#(newTaskId)", "description": "#(newDescription)", + "timeZoneId": "#(timeZoneId)" } """ When method POST @@ -101,7 +105,7 @@ Feature: Task Entries "endTime": "#(newEndTime)", "projectId": #(secondProjectId), "taskId": "#(newTaskId)", - "description": "#(newDescription)", + "description": "#(newDescription)" } """ diff --git a/e2e/unwell-entries-happy-path.feature b/e2e/unwell-entries-happy-path.feature index 23a6c193..03bff94c 100644 --- a/e2e/unwell-entries-happy-path.feature +++ b/e2e/unwell-entries-happy-path.feature @@ -30,6 +30,8 @@ Feature: Unwell Entries * configure headers = jsUtils().getAuthHeaders(accessToken) + * def timeZoneId = 'Asia/Yekaterinburg' + # Create a new unwell entry * def startTime = '2029-11-05T14:00:00' * def endTime = '2029-11-05T16:00:00' @@ -41,6 +43,7 @@ Feature: Unwell Entries { "startTime": "#(startTime)", "endTime": "#(endTime)", + "timeZoneId": "#(timeZoneId)" } """ When method POST @@ -58,6 +61,7 @@ Feature: Unwell Entries { "startTime": "#(newStartTime)", "endTime": "#(newEndTime)", + "timeZoneId": "#(timeZoneId)" } """ When method POST