From 1b836d75d039f13b3b2fdc236b4ef005797e0d5a Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Sat, 20 Dec 2025 15:55:26 +0330 Subject: [PATCH 01/24] Add CrystaDocument --- .../CrystaLearn.Core/Data/AppDbContext.cs | 2 + .../Crysta/CrystaDocumentConfiguration.cs | 22 + .../Mappers/DocumentMapper.cs | 2 +- ...251220121952_AddCrystaDocument.Designer.cs | 2525 +++++++++++++++++ .../20251220121952_AddCrystaDocument.cs | 92 + .../Migrations/AppDbContextModelSnapshot.cs | 142 + .../Crysta/{Document.cs => CrystaDocument.cs} | 3 +- .../Services/Contracts/IDocumentRepository.cs | 2 +- .../DocumentRepositoryDirectGitHub.cs | 4 +- .../Services/DocumentRepositoryInMemory.cs | 10 +- .../Services/GitHub/GitHubExtensions.cs | 6 +- 11 files changed, 2797 insertions(+), 13 deletions(-) create mode 100644 src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs create mode 100644 src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.Designer.cs create mode 100644 src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs rename src/Core/CrystaLearn.Core/Models/Crysta/{Document.cs => CrystaDocument.cs} (94%) diff --git a/src/Core/CrystaLearn.Core/Data/AppDbContext.cs b/src/Core/CrystaLearn.Core/Data/AppDbContext.cs index e2a0860c..2b7b73f0 100644 --- a/src/Core/CrystaLearn.Core/Data/AppDbContext.cs +++ b/src/Core/CrystaLearn.Core/Data/AppDbContext.cs @@ -37,6 +37,8 @@ public partial class AppDbContext(DbContextOptions options) public DbSet CrystaProgramSyncModules { get; set; } = default!; + public DbSet CrystaDocument { get; set; } = default!; + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs b/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs new file mode 100644 index 00000000..0eafc101 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs @@ -0,0 +1,22 @@ +using CrystaLearn.Core.Models.Crysta; + +namespace CrystaLearn.Core.Data.Configurations.Crysta; + +public class CrystaDocumentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(d => d.Code).IsUnique(); + builder.HasIndex(d => d.IsActive); + builder.HasIndex(d => d.CrystaProgramId); + + builder.HasOne(d => d.CrystaProgram) + .WithMany() + .HasForeignKey(d => d.CrystaProgramId); + + builder.OwnsOne(d => d.SyncInfo, sync => + { + sync.HasIndex(s => s.SyncId).IsUnique(); + }); + } +} diff --git a/src/Core/CrystaLearn.Core/Mappers/DocumentMapper.cs b/src/Core/CrystaLearn.Core/Mappers/DocumentMapper.cs index f081d109..5d0a0e5f 100644 --- a/src/Core/CrystaLearn.Core/Mappers/DocumentMapper.cs +++ b/src/Core/CrystaLearn.Core/Mappers/DocumentMapper.cs @@ -7,5 +7,5 @@ namespace CrystaLearn.Core.Mappers; [Mapper] public static partial class DocumentMapper { - public static partial DocumentDto Map(this Document source); + public static partial DocumentDto Map(this CrystaDocument source); } diff --git a/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.Designer.cs b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.Designer.cs new file mode 100644 index 00000000..7453c1cc --- /dev/null +++ b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.Designer.cs @@ -0,0 +1,2525 @@ +// +using System; +using CrystaLearn.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CrystaLearn.Core.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251220121952_AddCrystaDocument")] + partial class AddCrystaDocument + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("CrystaLearn") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CrystaLearn.Core.Models.Attachments.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Path") + .HasColumnType("text"); + + b.HasKey("Id", "Kind"); + + b.ToTable("Attachments", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Chatbot.SystemPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptKind") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PromptKind") + .IsUnique(); + + b.ToTable("SystemPrompts", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DocumentType") + .HasColumnType("integer"); + + b.Property("FileExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FileName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FileNameWithoutExtension") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Folder") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SourceContentUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SourceHtmlUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("IsActive"); + + b.ToTable("CrystaDocument", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BadgeSystemUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocumentUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.ToTable("CrystaPrograms", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgramSyncModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("ModuleType") + .HasColumnType("integer"); + + b.Property("SyncConfig") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("ModuleType"); + + b.ToTable("CrystaProgramSyncModules", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AreaId") + .HasColumnType("uuid"); + + b.Property("AreaPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AssignedToId") + .HasColumnType("uuid"); + + b.Property("AssignedToText") + .HasColumnType("text"); + + b.Property("AttachmentsCount") + .HasColumnType("integer"); + + b.Property("BoardColumn") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BoardColumnDone") + .HasColumnType("boolean"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CommentCount") + .HasColumnType("integer"); + + b.Property("CompletedById") + .HasColumnType("uuid"); + + b.Property("CompletedByText") + .HasColumnType("text"); + + b.Property("CompletedWork") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedById1") + .HasColumnType("uuid"); + + b.Property("CreatedByText") + .HasColumnType("text"); + + b.Property("CreatedFromRevisionId") + .HasColumnType("integer"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CustomFields") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DescriptionHtml") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IterationId") + .HasColumnType("uuid"); + + b.Property("IterationPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("OriginalEstimate") + .HasColumnType("double precision"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProviderParentId") + .HasColumnType("text"); + + b.Property("ProviderStatus") + .HasColumnType("text"); + + b.Property("ProviderTaskId") + .HasColumnType("text"); + + b.Property("ProviderTaskUrl") + .HasColumnType("text"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reason") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Relations") + .HasColumnType("text"); + + b.Property("RemainingWork") + .HasColumnType("double precision"); + + b.Property("RevisedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Revision") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Severity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoryPoints") + .HasColumnType("double precision"); + + b.Property("SystemFields") + .HasColumnType("text"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TaskAssignDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskChangedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskCreateDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskDoneDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("CompletedById"); + + b.HasIndex("CreatedById1"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Status"); + + b.HasIndex("TaskCreateDateTime"); + + b.ToTable("CrystaTasks", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CommentType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ContentHtml") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedByText") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("EditedById") + .HasColumnType("uuid"); + + b.Property("EditedByText") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EditedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FormattedText") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("integer"); + + b.Property("ProviderCommentId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderCommentUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProviderTaskId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reactions") + .HasColumnType("text"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visibility") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("CreatedDateTime"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("EditedById"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("UserId"); + + b.ToTable("CrystaTaskComments", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AreaId") + .HasColumnType("uuid"); + + b.Property("AreaPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AssignedToId") + .HasColumnType("uuid"); + + b.Property("AssignedToText") + .HasColumnType("text"); + + b.Property("AttachmentsCount") + .HasColumnType("integer"); + + b.Property("BoardColumn") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BoardColumnDone") + .HasColumnType("boolean"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ChangedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CommentCount") + .HasColumnType("integer"); + + b.Property("CompletedById") + .HasColumnType("uuid"); + + b.Property("CompletedByText") + .HasColumnType("text"); + + b.Property("CompletedWork") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByDisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedByIdString") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedByText") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromRevisionId") + .HasColumnType("integer"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("CustomFields") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DescriptionHtml") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IterationId") + .HasColumnType("uuid"); + + b.Property("IterationPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("OriginalEstimate") + .HasColumnType("double precision"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProviderTaskId") + .HasColumnType("text"); + + b.Property("ProviderTaskUrl") + .HasColumnType("text"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reason") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Relations") + .HasColumnType("text"); + + b.Property("RemainingWork") + .HasColumnType("double precision"); + + b.Property("ResolvedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Severity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StateChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoryPoints") + .HasColumnType("double precision"); + + b.Property("SystemFields") + .HasColumnType("text"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TaskAssignDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskCreateDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskDoneDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("ChangedDate"); + + b.HasIndex("CompletedById"); + + b.HasIndex("CreatedById"); + + b.HasIndex("CreatedDate"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Revision"); + + b.HasIndex("State"); + + b.ToTable("CrystaTaskRevisions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttachmentChange") + .HasColumnType("text"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ChangedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedPropertiesJson") + .HasColumnType("text"); + + b.Property("CommentText") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("FieldDisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FieldName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsWorkItemFieldChange") + .HasColumnType("boolean"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProviderTaskId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderUpdateId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("RelationChange") + .HasColumnType("text"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChangedDate"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Revision"); + + b.HasIndex("UserId"); + + b.ToTable("CrystaTaskUpdates", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "CrystaLearn"); + + b.HasData( + new + { + Id = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7"), + ConcurrencyStamp = "8ff71671-a1d6-5f97-abb9-d87d7b47d6e7", + Name = "s-admin", + NormalizedName = "S-ADMIN" + }, + new + { + Id = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8"), + ConcurrencyStamp = "9ff71672-a1d5-4f97-abb7-d87d6b47d5e8", + Name = "demo", + NormalizedName = "DEMO" + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.RoleClaim", 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "ClaimType", "ClaimValue"); + + b.ToTable("RoleClaims", "CrystaLearn"); + + b.HasData( + new + { + Id = 1, + ClaimType = "mx-p-s", + ClaimValue = "-1", + RoleId = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7") + }, + new + { + Id = 2, + ClaimType = "feat", + ClaimValue = "3.0", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }, + new + { + Id = 3, + ClaimType = "feat", + ClaimValue = "3.1", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }, + new + { + Id = 4, + ClaimType = "feat", + ClaimValue = "4.0", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ElevatedAccessTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EmailTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("HasProfilePicture") + .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("OtpRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PhoneNumberTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetPasswordTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("TwoFactorTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PhoneNumber") + .IsUnique() + .HasFilter("\"PhoneNumber\" IS NOT NULL"); + + b.ToTable("Users", "CrystaLearn"); + + b.HasData( + new + { + Id = new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), + AccessFailedCount = 0, + BirthDate = new DateTimeOffset(new DateTime(2023, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + ConcurrencyStamp = "315e1a26-5b3a-4544-8e91-2760cd28e231", + Email = "test@bitplatform.dev", + EmailConfirmed = true, + EmailTokenRequestedOn = new DateTimeOffset(new DateTime(2023, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FullName = "CrystaLearn test account", + Gender = 0, + HasProfilePicture = false, + LockoutEnabled = true, + NormalizedEmail = "TEST@BITPLATFORM.DEV", + NormalizedUserName = "TEST", + PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PhoneNumber = "+31684207362", + PhoneNumberConfirmed = true, + SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", + TwoFactorEnabled = false, + UserName = "test" + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserClaim", 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ClaimType", "ClaimValue"); + + b.ToTable("UserClaims", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserRole", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId") + .IsUnique(); + + b.ToTable("UserRoles", "CrystaLearn"); + + b.HasData( + new + { + UserId = new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), + RoleId = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7") + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("CultureName") + .HasColumnType("text"); + + b.Property("DeviceInfo") + .HasColumnType("text"); + + b.Property("IP") + .HasColumnType("text"); + + b.Property("NotificationStatus") + .HasColumnType("integer"); + + b.Property("PlatformType") + .HasColumnType("integer"); + + b.Property("Privileged") + .HasColumnType("boolean"); + + b.Property("RenewedOn") + .HasColumnType("bigint"); + + b.Property("SignalRConnectionId") + .HasColumnType("text"); + + b.Property("StartedOn") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("bytea"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("AttestationClientDataJson") + .HasColumnType("bytea"); + + b.Property("AttestationFormat") + .HasColumnType("text"); + + b.Property("AttestationObject") + .HasColumnType("bytea"); + + b.Property("IsBackedUp") + .HasColumnType("boolean"); + + b.Property("IsBackupEligible") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .HasColumnType("bytea"); + + b.Property("RegDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Transports") + .HasColumnType("integer[]"); + + b.Property("UserHandle") + .HasColumnType("bytea"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("ExpirationTime") + .HasColumnType("bigint"); + + b.Property("P256dh") + .HasColumnType("text"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenewedOn") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique() + .HasFilter("\"UserSessionId\" IS NOT NULL"); + + b.ToTable("PushNotificationSubscriptions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserToken", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "CrystaLearn"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireCounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Value"); + + b.ToTable("HangfireCounter", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Field") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Field"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireHash", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvocationData") + .IsRequired() + .HasColumnType("text"); + + b.Property("StateId") + .HasColumnType("bigint"); + + b.Property("StateName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("StateId"); + + b.HasIndex("StateName"); + + b.ToTable("HangfireJob", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => + { + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("JobId", "Name"); + + b.ToTable("HangfireJobParameter", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Position"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireList", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("HangfireLock", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FetchedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("Queue", "FetchedAt"); + + b.ToTable("HangfireQueuedJob", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Queues") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Heartbeat"); + + b.ToTable("HangfireServer", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.HasKey("Key", "Value"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Score"); + + b.ToTable("HangfireSet", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("HangfireState", "jobs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaDocumentId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaDocument", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaDocumentId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("SyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgramSyncModule", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaProgramSyncModuleId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaProgramSyncModuleId"); + + b1.ToTable("CrystaProgramSyncModules", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaProgramSyncModuleId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("SyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTask", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "AssignedTo") + .WithMany() + .HasForeignKey("AssignedToId"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById1"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "CommentsSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "RevisionsSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "UpdatesSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "WorkItemSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.Navigation("AssignedTo"); + + b.Navigation("CommentsSyncInfo") + .IsRequired(); + + b.Navigation("CompletedBy"); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("Parent"); + + b.Navigation("RevisionsSyncInfo") + .IsRequired(); + + b.Navigation("UpdatesSyncInfo") + .IsRequired(); + + b.Navigation("WorkItemSyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskComment", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "EditedBy") + .WithMany() + .HasForeignKey("EditedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaTaskCommentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskCommentId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskComments", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskCommentId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("EditedBy"); + + b.Navigation("SyncInfo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskRevision", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "AssignedTo") + .WithMany() + .HasForeignKey("AssignedToId"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "CommentsSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "RevisionsSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "UpdatesSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "WorkItemSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.Navigation("AssignedTo"); + + b.Navigation("CommentsSyncInfo") + .IsRequired(); + + b.Navigation("CompletedBy"); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("RevisionsSyncInfo") + .IsRequired(); + + b.Navigation("UpdatesSyncInfo") + .IsRequired(); + + b.Navigation("WorkItemSyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskUpdate", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaTaskUpdateId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskUpdateId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskUpdates", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskUpdateId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("SyncInfo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.RoleClaim", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserClaim", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserRole", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.WebAuthnCredential", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("WebAuthnCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.UserSession", "UserSession") + .WithOne("PushNotificationSubscription") + .HasForeignKey("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", "UserSessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("UserSession"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserLogin", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserToken", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireState", "State") + .WithMany() + .HasForeignKey("StateId"); + + b.Navigation("State"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("Parameters") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("QueuedJobs") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("States") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Roles"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("WebAuthnCredentials"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.Navigation("PushNotificationSubscription"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Navigation("Parameters"); + + b.Navigation("QueuedJobs"); + + b.Navigation("States"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs new file mode 100644 index 00000000..269b6a39 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CrystaLearn.Core.Migrations +{ + /// + public partial class AddCrystaDocument : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CrystaDocument", + schema: "CrystaLearn", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + Code = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Culture = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Content = table.Column(type: "text", nullable: true), + SourceHtmlUrl = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + SourceContentUrl = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + CrystaUrl = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + Folder = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + FileName = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + LastHash = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + SyncInfo_SyncId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + SyncInfo_SyncStartDateTime = table.Column(type: "timestamp with time zone", nullable: true), + SyncInfo_SyncEndDateTime = table.Column(type: "timestamp with time zone", nullable: true), + SyncInfo_ContentHash = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + SyncInfo_SyncGroup = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + SyncInfo_SyncStatus = table.Column(type: "integer", nullable: true), + SyncInfo_LastSyncDateTime = table.Column(type: "timestamp with time zone", nullable: true), + SyncInfo_LastSyncOffset = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + CrystaProgramId = table.Column(type: "uuid", nullable: true), + FileExtension = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + FileNameWithoutExtension = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + DocumentType = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CrystaDocument", x => x.Id); + table.ForeignKey( + name: "FK_CrystaDocument_CrystaPrograms_CrystaProgramId", + column: x => x.CrystaProgramId, + principalSchema: "CrystaLearn", + principalTable: "CrystaPrograms", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_CrystaDocument_Code", + schema: "CrystaLearn", + table: "CrystaDocument", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CrystaDocument_CrystaProgramId", + schema: "CrystaLearn", + table: "CrystaDocument", + column: "CrystaProgramId"); + + migrationBuilder.CreateIndex( + name: "IX_CrystaDocument_IsActive", + schema: "CrystaLearn", + table: "CrystaDocument", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_CrystaDocument_SyncInfo_SyncId", + schema: "CrystaLearn", + table: "CrystaDocument", + column: "SyncInfo_SyncId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CrystaDocument", + schema: "CrystaLearn"); + } + } +} diff --git a/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs b/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs index 941f4073..e0278ada 100644 --- a/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs @@ -69,6 +69,90 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SystemPrompts", "CrystaLearn"); }); + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DocumentType") + .HasColumnType("integer"); + + b.Property("FileExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FileName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FileNameWithoutExtension") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Folder") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SourceContentUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SourceHtmlUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("IsActive"); + + b.ToTable("CrystaDocument", "CrystaLearn"); + }); + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgram", b => { b.Property("Id") @@ -1549,6 +1633,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("DataProtectionKeys", "CrystaLearn"); }); + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaDocumentId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaDocument", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaDocumentId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("SyncInfo") + .IsRequired(); + }); + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgramSyncModule", b => { b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") diff --git a/src/Core/CrystaLearn.Core/Models/Crysta/Document.cs b/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs similarity index 94% rename from src/Core/CrystaLearn.Core/Models/Crysta/Document.cs rename to src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs index 436a4d33..1db633a2 100644 --- a/src/Core/CrystaLearn.Core/Models/Crysta/Document.cs +++ b/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs @@ -3,7 +3,8 @@ namespace CrystaLearn.Core.Models.Crysta; -public class Document : Entity +[Table("CrystaDocument", Schema = "CrystaLearn")] +public class CrystaDocument : Entity { [MaxLength(150)] public virtual string Code { get; set; } = default!; diff --git a/src/Core/CrystaLearn.Core/Services/Contracts/IDocumentRepository.cs b/src/Core/CrystaLearn.Core/Services/Contracts/IDocumentRepository.cs index 7ef79196..48ddbf21 100644 --- a/src/Core/CrystaLearn.Core/Services/Contracts/IDocumentRepository.cs +++ b/src/Core/CrystaLearn.Core/Services/Contracts/IDocumentRepository.cs @@ -5,7 +5,7 @@ namespace CrystaLearn.Core.Services.Contracts; public interface IDocumentRepository { - Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken); + Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken); Task GetDocumentByCrystaUrlAsync(string crystaUrl, string? culture, CancellationToken cancellationToken); diff --git a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs index 001b2347..81534435 100644 --- a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs +++ b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs @@ -11,7 +11,7 @@ public partial class DocumentRepositoryDirectGitHub : IDocumentRepository [AutoInject] private IGitHubService GitHubService { get; set; } = default; [AutoInject] private ICrystaProgramRepository CrystaProgramRepository { get; set; } = default; - public async Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken) + public async Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken) { var program = await CrystaProgramRepository.GetCrystaProgramByCodeAsync(programCode, cancellationToken); if (program == null) @@ -23,7 +23,7 @@ public async Task> GetDocumentsAsync(string programCode, Cancella var list = await GitHubService.GetFilesAsync(documentUrl); - var result = new List(); + var result = new List(); var culture = ""; diff --git a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryInMemory.cs b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryInMemory.cs index 50ad5099..82e3fe74 100644 --- a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryInMemory.cs +++ b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryInMemory.cs @@ -12,9 +12,9 @@ public partial class DocumentRepositoryInMemory : IDocumentRepository [AutoInject] private IGitHubService GitHubService { get; set; } = default; [AutoInject] private ICrystaProgramRepository CrystaProgramRepository { get; set; } = default; - private ConcurrentDictionary> ProgramDocs { get; set; } = new(); + private ConcurrentDictionary> ProgramDocs { get; set; } = new(); - public async Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken) + public async Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken) { if (ProgramDocs.TryGetValue(programCode, out var list)) { @@ -57,7 +57,7 @@ public async Task> GetDocumentsAsync(string programCode, Cancella - private async Task PopulateContentAsync(Document document) + private async Task PopulateContentAsync(CrystaDocument document) { if (string.IsNullOrWhiteSpace(document.SourceHtmlUrl)) { @@ -68,7 +68,7 @@ private async Task PopulateContentAsync(Document document) document.Content = document.GetHtmlContent(); } - private async Task> GetProgramDocsFromGitHubAsync(string programCode, CancellationToken cancellationToken) + private async Task> GetProgramDocsFromGitHubAsync(string programCode, CancellationToken cancellationToken) { var program = await CrystaProgramRepository.GetCrystaProgramByCodeAsync(programCode, cancellationToken); if (program == null) @@ -78,7 +78,7 @@ private async Task> GetProgramDocsFromGitHubAsync(string programC var list = program.DocumentUrl is not null ? await GitHubService.GetFilesAsync(program.DocumentUrl) : []; - var result = new List(); + var result = new List(); foreach (var item in list) { diff --git a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs index b9e90c89..6db03493 100644 --- a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs +++ b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs @@ -5,7 +5,7 @@ namespace CrystaLearn.Core.Services.GitHub; public static class GitHubExtensions { - public static Models.Crysta.Document CreateDocument(this GitHubItem item, CrystaProgram program) + public static Models.Crysta.CrystaDocument CreateDocument(this GitHubItem item, CrystaProgram program) { string culture = ""; string programDocUrl = program.DocumentUrl ?? throw new Exception($"Program with code '{program.Code}' has no document url."); @@ -50,7 +50,7 @@ public static Models.Crysta.Document CreateDocument(this GitHubItem item, Crysta var folderPath = relativePath; - var doc = new Models.Crysta.Document + var doc = new Models.Crysta.CrystaDocument { Id = Guid.NewGuid(), Code = code, @@ -78,7 +78,7 @@ public static Models.Crysta.Document CreateDocument(this GitHubItem item, Crysta return doc; } - public static string? GetHtmlContent(this Models.Crysta.Document doc) + public static string? GetHtmlContent(this Models.Crysta.CrystaDocument doc) { var content = doc.Content; if (content == null) From 8d444f90350605db897902ab3b7b10fff13e3253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:00:22 +0000 Subject: [PATCH 02/24] Initial plan From 5e981049b18b24ebbab5694d1449760e3ef37a62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:07:15 +0000 Subject: [PATCH 03/24] Implement GithubSyncService with interface and tests Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Sync/GithubSyncServiceTests.cs | 84 ++++++++++++ .../ApplicationBuilderExtensions.cs | 1 + .../Services/Contracts/IGithubSyncService.cs | 9 ++ .../Services/Sync/CrystaProgramSyncService.cs | 4 + .../Services/Sync/GithubSyncService.cs | 129 ++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs create mode 100644 src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs create mode 100644 src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs diff --git a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs new file mode 100644 index 00000000..6c474a0f --- /dev/null +++ b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs @@ -0,0 +1,84 @@ +using CrystaLearn.Core.Extensions; +using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Services; +using CrystaLearn.Core.Services.Contracts; +using CrystaLearn.Core.Tests.Infra; + +namespace CrystaLearn.Core.Tests.Sync; + +public class GithubSyncServiceTests : TestBase +{ + [Fact] + public async Task SyncGithubDocuments_WithValidModule_MustWork() + { + // Arrange + var services = CreateServiceProvider(configServices: (sc) => + { + sc.AddTransient(); + sc.AddSingleton(); + }); + + using var scope = services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var moduleService = scope.ServiceProvider.GetRequiredService(); + var programRepo = scope.ServiceProvider.GetRequiredService(); + + // Get a test program + var programs = await programRepo.GetCrystaProgramsAsync(CancellationToken.None); + var testProgram = programs.FirstOrDefault(); + + if (testProgram == null) + { + // Skip test if no programs available + return; + } + + var module = new CrystaProgramSyncModule + { + Id = Guid.NewGuid(), + CrystaProgramId = testProgram.Id, + CrystaProgram = testProgram, + ModuleType = SyncModuleType.GitHubDocument, + SyncInfo = new SyncInfo + { + LastSyncDateTime = DateTimeOffset.Now.AddYears(-1), + LastSyncOffset = "0" + } + }; + + // Act + var result = await service.SyncAsync(module); + + // Assert + Assert.NotNull(result); + Assert.True(result.AddCount >= 0); + Assert.True(result.UpdateCount >= 0); + Assert.True(result.SameCount >= 0); + Assert.True(result.DeleteCount >= 0); + } + + [Fact] + public async Task SyncGithubDocuments_WithInvalidModuleType_MustThrowException() + { + // Arrange + var services = CreateServiceProvider(configServices: (sc) => + { + sc.AddTransient(); + sc.AddSingleton(); + }); + + using var scope = services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + var module = new CrystaProgramSyncModule + { + Id = Guid.NewGuid(), + ModuleType = SyncModuleType.AzureBoard, // Wrong type + SyncInfo = new SyncInfo() + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await service.SyncAsync(module)); + } +} diff --git a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs index acee4304..126bdacf 100644 --- a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs @@ -36,6 +36,7 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); } diff --git a/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs new file mode 100644 index 00000000..50d90079 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs @@ -0,0 +1,9 @@ +using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Services.Sync; + +namespace CrystaLearn.Core.Services.Contracts; + +public interface IGithubSyncService +{ + Task SyncAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs index 2639ecc5..15085dac 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs @@ -11,6 +11,7 @@ namespace CrystaLearn.Core.Services.Sync; public partial class CrystaProgramSyncService : ICrystaProgramSyncService { [AutoInject] private IAzureBoardSyncService AzureBoardSyncService { get; set; } = default!; + [AutoInject] private IGithubSyncService GithubSyncService { get; set; } = default!; public async Task SyncAsync(CrystaProgramSyncModule module) { @@ -19,6 +20,9 @@ public async Task SyncAsync(CrystaProgramSyncModule module) case SyncModuleType.AzureBoard: await AzureBoardSyncService.SyncAsync(module); break; + case SyncModuleType.GitHubDocument: + await GithubSyncService.SyncAsync(module); + break; default: throw new ArgumentOutOfRangeException(); } diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs new file mode 100644 index 00000000..e950f2be --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -0,0 +1,129 @@ +using CrystaLearn.Core.Data; +using CrystaLearn.Core.Extensions; +using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Services.Contracts; + +namespace CrystaLearn.Core.Services.Sync; + +public partial class GithubSyncService : IGithubSyncService +{ + [AutoInject] private IDocumentRepository DocumentRepository { get; set; } = default!; + [AutoInject] private AppDbContext DbContext { get; set; } = default!; + [AutoInject] private ICrystaProgramSyncModuleService CrystaProgramSyncModuleService { get; set; } = default!; + + public async Task SyncAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) + { + if (module.ModuleType != SyncModuleType.GitHubDocument) + { + throw new InvalidOperationException("Invalid module type"); + } + + var program = module.CrystaProgram ?? throw new ArgumentNullException(nameof(module.CrystaProgram)); + var programCode = program.Code ?? throw new ArgumentNullException(nameof(program.Code)); + + module.SyncInfo.SyncStartDateTime = DateTimeOffset.Now; + + // Fetch documents from GitHub using DocumentRepositoryInMemory + var githubDocuments = await DocumentRepository.GetDocumentsAsync(programCode, cancellationToken); + + // Sync documents to database + var syncResult = await SyncDocumentsAsync(githubDocuments, program.Id, cancellationToken); + + // Update module sync info + module.SyncInfo.LastSyncDateTime = DateTimeOffset.Now; + module.SyncInfo.SyncEndDateTime = DateTimeOffset.Now; + module.SyncInfo.SyncStatus = SyncStatus.Success; + module.UpdatedAt = DateTimeOffset.Now; + + // Persist module sync info + await CrystaProgramSyncModuleService.UpdateSyncModuleAsync(module); + + return syncResult; + } + + private async Task SyncDocumentsAsync(List githubDocuments, Guid programId, CancellationToken cancellationToken) + { + var result = new SyncResult { AddCount = 0, UpdateCount = 0, SameCount = 0, DeleteCount = 0 }; + + // Get existing documents from database for this program + var existingDocuments = await DbContext.CrystaDocument + .Where(d => d.CrystaProgramId == programId) + .ToListAsync(cancellationToken); + + // Create a map of existing documents by their unique identifier (combination of code and culture) + var existingDocMap = existingDocuments.ToDictionary(d => $"{d.Code}_{d.Culture}", d => d); + + // Track which documents from GitHub we've processed + var processedKeys = new HashSet(); + + foreach (var githubDoc in githubDocuments) + { + var key = $"{githubDoc.Code}_{githubDoc.Culture}"; + processedKeys.Add(key); + + if (existingDocMap.TryGetValue(key, out var existingDoc)) + { + // Document exists - check if it needs updating + if (existingDoc.LastHash != githubDoc.LastHash) + { + // Document has changed - update it + existingDoc.Title = githubDoc.Title; + existingDoc.Content = githubDoc.Content; + existingDoc.SourceHtmlUrl = githubDoc.SourceHtmlUrl; + existingDoc.SourceContentUrl = githubDoc.SourceContentUrl; + existingDoc.CrystaUrl = githubDoc.CrystaUrl; + existingDoc.Folder = githubDoc.Folder; + existingDoc.FileName = githubDoc.FileName; + existingDoc.FileExtension = githubDoc.FileExtension; + existingDoc.FileNameWithoutExtension = githubDoc.FileNameWithoutExtension; + existingDoc.DocumentType = githubDoc.DocumentType; + existingDoc.LastHash = githubDoc.LastHash; + existingDoc.IsActive = true; + existingDoc.SyncInfo.LastSyncDateTime = DateTimeOffset.Now; + existingDoc.SyncInfo.ContentHash = githubDoc.LastHash; + existingDoc.SyncInfo.SyncStatus = SyncStatus.Success; + existingDoc.UpdatedAt = DateTimeOffset.Now; + + result.UpdateCount++; + } + else + { + // Document unchanged + result.SameCount++; + } + } + else + { + // New document - add it + githubDoc.CrystaProgramId = programId; + githubDoc.CreatedAt = DateTimeOffset.Now; + githubDoc.UpdatedAt = DateTimeOffset.Now; + githubDoc.SyncInfo.LastSyncDateTime = DateTimeOffset.Now; + githubDoc.SyncInfo.SyncStatus = SyncStatus.Success; + githubDoc.SyncInfo.ContentHash = githubDoc.LastHash; + +#pragma warning disable NonAsyncEFCoreMethodsUsageAnalyzer + DbContext.CrystaDocument.Add(githubDoc); +#pragma warning restore NonAsyncEFCoreMethodsUsageAnalyzer + result.AddCount++; + } + } + + // Mark documents that are no longer in GitHub as inactive + foreach (var existingDoc in existingDocuments) + { + var key = $"{existingDoc.Code}_{existingDoc.Culture}"; + if (!processedKeys.Contains(key) && existingDoc.IsActive) + { + existingDoc.IsActive = false; + existingDoc.UpdatedAt = DateTimeOffset.Now; + result.DeleteCount++; + } + } + + // Save all changes to database + await DbContext.SaveChangesAsync(cancellationToken); + + return result; + } +} From 41f088766c14cc88018204d8903d4dc150bc5f05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:09:12 +0000 Subject: [PATCH 04/24] Update documentation to reflect GithubSyncService implementation Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- docs/architecture/sync-overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/sync-overview.md b/docs/architecture/sync-overview.md index e933ff93..cd45387d 100644 --- a/docs/architecture/sync-overview.md +++ b/docs/architecture/sync-overview.md @@ -23,7 +23,7 @@ graph TB subgraph "CrystaLearn Sync Layer" ABS[AzureBoardSyncService] - GHS[GitHubService] + GHS[GithubSyncService] LIS[LinkedInSyncService*] TWS[TwitterSyncService*] end @@ -146,7 +146,7 @@ Each synced entity follows a consistent three-layer service architecture: - Orchestrates synchronization between external source and local database - Handles change detection via hash comparison - Manages incremental sync using timestamps and offsets - - Example: `AzureBoardSyncService` + - Example: `AzureBoardSyncService`, `GithubSyncService` 3. **Provider Service Layer** (`[Provider]Service`) - Low-level communication with external APIs From 72586dafba7dfcbeba3a2aca297b94582c4df6d9 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 14:19:25 +0330 Subject: [PATCH 05/24] Refactor document sync to use ICrystaDocumentService Introduce ICrystaDocumentService for batch document persistence, with EF Core and in-memory implementations for production and testing. Refactor GithubSyncService to use this abstraction, improving separation of concerns and testability. Update DI registrations and rename fake program repository for consistency. Sync module service now loads related CrystaProgram entities. Note: tasks.json changes include duplicate build/test tasks. --- .vscode/tasks.json | 109 +++++++++++------- .../Sync/GithubSyncServiceTests.cs | 3 +- .../ApplicationBuilderExtensions.cs | 6 +- .../Services/CrystaProgramService.cs | 25 ++++ ...oryFake.cs => CrystaProgramServiceFake.cs} | 2 +- .../CrystaProgramSyncModuleService.cs | 2 +- .../CrystaProgramSyncModuleServiceFake.cs | 4 +- .../Services/Sync/CrystaDocumentService.cs | 39 +++++++ .../Sync/CrystaDocumentServiceFake.cs | 105 +++++++++++++++++ .../Services/Sync/GithubSyncService.cs | 26 +++-- .../Services/Sync/ICrystaDocumentService.cs | 11 ++ 11 files changed, 273 insertions(+), 59 deletions(-) create mode 100644 src/Core/CrystaLearn.Core/Services/CrystaProgramService.cs rename src/Core/CrystaLearn.Core/Services/{CrystaProgramRepositoryFake.cs => CrystaProgramServiceFake.cs} (93%) create mode 100644 src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs create mode 100644 src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs create mode 100644 src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1fcf92be..ee1168c9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,46 +1,67 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "before-build", - "type": "shell", - "command": "dotnet build src/Client/CrystaLearn.Client.Core/CrystaLearn.Client.Core.csproj -t:BeforeBuildTasks", - "runOptions": { - "runOn": "folderOpen" - } - }, - { - "label": "build", - "type": "shell", - "command": "dotnet build src/Server/CrystaLearn.Server.Web/CrystaLearn.Server.Web.csproj" - }, - { - "label": "generate-resx-files", - "type": "shell", - "command": "dotnet build src/Shared/CrystaLearn.Shared.csproj -t:PrepareResources" - }, - { - "label": "run", - "type": "shell", - "command": "dotnet", - "args": [ - "run" - ], - "options": { - "cwd": "${workspaceFolder}/src/Server/CrystaLearn.Server.Web" - }, - "isBackground": true - }, - { - "label": "run-tests", - "type": "shell", - "command": "dotnet", - "args": [ - "test" - ], - "options": { - "cwd": "${workspaceFolder}/src/Tests" - } - } - ] + "version": "2.0.0", + "tasks": [ + { + "label": "before-build", + "type": "shell", + "command": "dotnet build src/Client/CrystaLearn.Client.Core/CrystaLearn.Client.Core.csproj -t:BeforeBuildTasks", + "runOptions": { + "runOn": "folderOpen" + } + }, + { + "label": "build", + "type": "shell", + "command": "dotnet build src/Server/CrystaLearn.Server.Web/CrystaLearn.Server.Web.csproj" + }, + { + "label": "generate-resx-files", + "type": "shell", + "command": "dotnet build src/Shared/CrystaLearn.Shared.csproj -t:PrepareResources" + }, + { + "label": "run", + "type": "shell", + "command": "dotnet", + "args": [ + "run" + ], + "options": { + "cwd": "${workspaceFolder}/src/Server/CrystaLearn.Server.Web" + }, + "isBackground": true + }, + { + "label": "run-tests", + "type": "shell", + "command": "dotnet", + "args": [ + "test" + ], + "options": { + "cwd": "${workspaceFolder}/src/Tests" + } + }, + { + "label": "build", + "type": "shell", + "command": "dotnet build src/Server/CrystaLearn.Server.Web/CrystaLearn.Server.Web.csproj" + }, + { + "label": "run-tests", + "type": "shell", + "command": "dotnet", + "args": [ + "test" + ] + }, + { + "label": "run-tests", + "type": "shell", + "command": "dotnet", + "args": [ + "test" + ] + } + ] } \ No newline at end of file diff --git a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs index 6c474a0f..d7023bf6 100644 --- a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs +++ b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs @@ -1,4 +1,4 @@ -using CrystaLearn.Core.Extensions; +using CrystaLearn.Core.Extensions; using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services; using CrystaLearn.Core.Services.Contracts; @@ -15,6 +15,7 @@ public async Task SyncGithubDocuments_WithValidModule_MustWork() var services = CreateServiceProvider(configServices: (sc) => { sc.AddTransient(); + sc.AddTransient(); sc.AddSingleton(); }); diff --git a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs index 126bdacf..526aacf1 100644 --- a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs @@ -30,8 +30,10 @@ void AddDbContext(DbContextOptionsBuilder options) builder.AddGitHubClient(); - services.AddSingleton(); - services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramService.cs new file mode 100644 index 00000000..f81601c7 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramService.cs @@ -0,0 +1,25 @@ +using CrystaLearn.Core.Data; +using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Services.Contracts; + +namespace CrystaLearn.Core.Services; + +public partial class CrystaProgramService : ICrystaProgramRepository +{ + [AutoInject] private AppDbContext DbContext { get; set; } = default!; + + public async Task> GetCrystaProgramsAsync(CancellationToken cancellationToken) + { + return await DbContext.CrystaPrograms + .Where(p => p.IsActive) + .OrderBy(p => p.Title) + .ToListAsync(cancellationToken); + } + + public async Task GetCrystaProgramByCodeAsync(string code, CancellationToken cancellationToken) + { + return await DbContext.CrystaPrograms + .Where(p => p.Code == code && p.IsActive) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramRepositoryFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramServiceFake.cs similarity index 93% rename from src/Core/CrystaLearn.Core/Services/CrystaProgramRepositoryFake.cs rename to src/Core/CrystaLearn.Core/Services/CrystaProgramServiceFake.cs index fd994293..64bd61d5 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramRepositoryFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramServiceFake.cs @@ -3,7 +3,7 @@ namespace CrystaLearn.Core.Services; -public partial class CrystaProgramRepositoryFake : ICrystaProgramRepository +public partial class CrystaProgramServiceFake : ICrystaProgramRepository { public static CrystaProgram FakeProgramCSI = new() { diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 6f3e8a16..95e173ef 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -20,7 +20,7 @@ public CrystaProgramSyncModuleService(AppDbContext dbContext) this.DbContext = dbContext; if (_modules.Count == 0) { - _modules = DbContext.Set().ToListAsync().GetAwaiter().GetResult(); + _modules = DbContext.Set().Include(f => f.CrystaProgram).ToListAsync().GetAwaiter().GetResult(); } } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 0870e209..95faa1f9 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -24,8 +24,8 @@ public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) new CrystaProgramSyncModule { Id = Guid.NewGuid(), - CrystaProgramId = CrystaProgramRepositoryFake.FakeProgramCSI.Id, - CrystaProgram = CrystaProgramRepositoryFake.FakeProgramCSI, + CrystaProgramId = CrystaProgramServiceFake.FakeProgramCSI.Id, + CrystaProgram = CrystaProgramServiceFake.FakeProgramCSI, ModuleType = SyncModuleType.AzureBoard, SyncConfig = $$""" diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs new file mode 100644 index 00000000..8f611b51 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs @@ -0,0 +1,39 @@ +using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Data; + +namespace CrystaLearn.Core.Services.Sync; + +public partial class CrystaDocumentService : ICrystaDocumentService +{ + [AutoInject] private AppDbContext DbContext { get; set; } = default!; + + public async Task SaveDocumentsAsync( + IEnumerable newDocuments, + IEnumerable updatedDocuments, + IEnumerable deletedDocuments, + CancellationToken cancellationToken = default) + { + if (newDocuments != null && newDocuments.Any()) + { + await DbContext.CrystaDocument.AddRangeAsync(newDocuments); + } + + if (updatedDocuments != null && updatedDocuments.Any()) + { + // Updated documents may already be tracked; ensure they are marked modified + DbContext.CrystaDocument.UpdateRange(updatedDocuments); + } + + if (deletedDocuments != null && deletedDocuments.Any()) + { + foreach (var d in deletedDocuments) + { + d.IsActive = false; + d.UpdatedAt = DateTimeOffset.Now; + } + DbContext.CrystaDocument.UpdateRange(deletedDocuments); + } + + await DbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs new file mode 100644 index 00000000..ccc639e2 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs @@ -0,0 +1,105 @@ +using CrystaLearn.Core.Models.Crysta; + +namespace CrystaLearn.Core.Services.Sync; + +public class CrystaDocumentServiceFake : ICrystaDocumentService +{ + private readonly List _store = new(); + + public IReadOnlyCollection InMemoryDocuments => _store.AsReadOnly(); + + public Task SaveDocumentsAsync( + IEnumerable newDocuments, + IEnumerable updatedDocuments, + IEnumerable deletedDocuments, + CancellationToken cancellationToken = default) + { + if (newDocuments != null) + { + foreach (var d in newDocuments) + { + var copy = CloneDocument(d); + _store.Add(copy); + } + } + + if (updatedDocuments != null) + { + foreach (var d in updatedDocuments) + { + var key = GetKey(d); + var existing = _store.FirstOrDefault(x => GetKey(x) == key); + if (existing != null) + { + ApplyUpdate(existing, d); + } + else + { + _store.Add(CloneDocument(d)); + } + } + } + + if (deletedDocuments != null) + { + foreach (var d in deletedDocuments) + { + var key = GetKey(d); + var existing = _store.FirstOrDefault(x => GetKey(x) == key); + if (existing != null) + { + existing.IsActive = false; + existing.UpdatedAt = DateTimeOffset.Now; + } + } + } + + return Task.CompletedTask; + } + + private static string GetKey(CrystaDocument d) => $"{d.Code}_{d.Culture}"; + + private static CrystaDocument CloneDocument(CrystaDocument src) + { + return new CrystaDocument + { + Id = src.Id, + Code = src.Code, + Culture = src.Culture, + Title = src.Title, + Content = src.Content, + SourceHtmlUrl = src.SourceHtmlUrl, + SourceContentUrl = src.SourceContentUrl, + CrystaUrl = src.CrystaUrl, + Folder = src.Folder, + FileName = src.FileName, + FileExtension = src.FileExtension, + FileNameWithoutExtension = src.FileNameWithoutExtension, + DocumentType = src.DocumentType, + LastHash = src.LastHash, + IsActive = src.IsActive, + CreatedAt = src.CreatedAt, + UpdatedAt = src.UpdatedAt, + CrystaProgramId = src.CrystaProgramId, + SyncInfo = src.SyncInfo // assuming SyncInfo is a reference type handled in tests + }; + } + + private static void ApplyUpdate(CrystaDocument existing, CrystaDocument src) + { + existing.Title = src.Title; + existing.Content = src.Content; + existing.SourceHtmlUrl = src.SourceHtmlUrl; + existing.SourceContentUrl = src.SourceContentUrl; + existing.CrystaUrl = src.CrystaUrl; + existing.Folder = src.Folder; + existing.FileName = src.FileName; + existing.FileExtension = src.FileExtension; + existing.FileNameWithoutExtension = src.FileNameWithoutExtension; + existing.DocumentType = src.DocumentType; + existing.LastHash = src.LastHash; + existing.IsActive = src.IsActive; + existing.SyncInfo = src.SyncInfo; + existing.UpdatedAt = DateTimeOffset.Now; + } +} diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index e950f2be..b5e5c8a8 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -1,4 +1,4 @@ -using CrystaLearn.Core.Data; +using CrystaLearn.Core.Data; using CrystaLearn.Core.Extensions; using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services.Contracts; @@ -10,6 +10,7 @@ public partial class GithubSyncService : IGithubSyncService [AutoInject] private IDocumentRepository DocumentRepository { get; set; } = default!; [AutoInject] private AppDbContext DbContext { get; set; } = default!; [AutoInject] private ICrystaProgramSyncModuleService CrystaProgramSyncModuleService { get; set; } = default!; + [AutoInject] private ICrystaDocumentService CrystaDocumentService { get; set; } = default!; public async Task SyncAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) { @@ -56,6 +57,11 @@ private async Task SyncDocumentsAsync(List githubDoc // Track which documents from GitHub we've processed var processedKeys = new HashSet(); + // Collect documents to add / update / delete and then save in batch + var newDocuments = new List(); + var updatedDocuments = new List(); + var deletedDocuments = new List(); + foreach (var githubDoc in githubDocuments) { var key = $"{githubDoc.Code}_{githubDoc.Culture}"; @@ -85,6 +91,8 @@ private async Task SyncDocumentsAsync(List githubDoc existingDoc.UpdatedAt = DateTimeOffset.Now; result.UpdateCount++; + // Keep track to update in batch + updatedDocuments.Add(existingDoc); } else { @@ -94,17 +102,14 @@ private async Task SyncDocumentsAsync(List githubDoc } else { - // New document - add it + // New document - prepare to add it in batch githubDoc.CrystaProgramId = programId; githubDoc.CreatedAt = DateTimeOffset.Now; githubDoc.UpdatedAt = DateTimeOffset.Now; githubDoc.SyncInfo.LastSyncDateTime = DateTimeOffset.Now; githubDoc.SyncInfo.SyncStatus = SyncStatus.Success; githubDoc.SyncInfo.ContentHash = githubDoc.LastHash; - -#pragma warning disable NonAsyncEFCoreMethodsUsageAnalyzer - DbContext.CrystaDocument.Add(githubDoc); -#pragma warning restore NonAsyncEFCoreMethodsUsageAnalyzer + newDocuments.Add(githubDoc); result.AddCount++; } } @@ -118,11 +123,16 @@ private async Task SyncDocumentsAsync(List githubDoc existingDoc.IsActive = false; existingDoc.UpdatedAt = DateTimeOffset.Now; result.DeleteCount++; + deletedDocuments.Add(existingDoc); } } - // Save all changes to database - await DbContext.SaveChangesAsync(cancellationToken); + // Persist changes via the document service (batch save) + await CrystaDocumentService.SaveDocumentsAsync( + newDocuments, + updatedDocuments, + deletedDocuments, + cancellationToken); return result; } diff --git a/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs b/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs new file mode 100644 index 00000000..fd818e46 --- /dev/null +++ b/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs @@ -0,0 +1,11 @@ +using CrystaLearn.Core.Models.Crysta; +namespace CrystaLearn.Core.Services.Sync; + +public interface ICrystaDocumentService +{ + Task SaveDocumentsAsync( + IEnumerable newDocuments, + IEnumerable updatedDocuments, + IEnumerable deletedDocuments, + CancellationToken cancellationToken = default); +} From b3b52107e21c514b0cc613816dd95deaeb3ac98a Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 16:05:57 +0330 Subject: [PATCH 06/24] Add DisplayCode To CrystaDocument --- .../20251220121952_AddCrystaDocument.cs | 12 +- ..._AddDisplayCodeToCrytaDocument.Designer.cs | 2529 +++++++++++++++++ ...226123528_AddDisplayCodeToCrytaDocument.cs | 52 + .../Migrations/AppDbContextModelSnapshot.cs | 8 +- .../Models/Crysta/CrystaDocument.cs | 5 +- .../Services/GitHub/GitHubExtensions.cs | 2 + .../Sync/CrystaDocumentServiceFake.cs | 3 +- .../Services/Sync/GithubSyncService.cs | 5 +- 8 files changed, 2604 insertions(+), 12 deletions(-) create mode 100644 src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.Designer.cs create mode 100644 src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.cs diff --git a/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs index 269b6a39..c113c7a0 100644 --- a/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs +++ b/src/Core/CrystaLearn.Core/Migrations/20251220121952_AddCrystaDocument.cs @@ -54,12 +54,12 @@ protected override void Up(MigrationBuilder migrationBuilder) principalColumn: "Id"); }); - migrationBuilder.CreateIndex( - name: "IX_CrystaDocument_Code", - schema: "CrystaLearn", - table: "CrystaDocument", - column: "Code", - unique: true); + //migrationBuilder.CreateIndex( + // name: "IX_CrystaDocument_Code", + // schema: "CrystaLearn", + // table: "CrystaDocument", + // column: "Code", + // unique: true); migrationBuilder.CreateIndex( name: "IX_CrystaDocument_CrystaProgramId", diff --git a/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.Designer.cs b/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.Designer.cs new file mode 100644 index 00000000..d2277c0e --- /dev/null +++ b/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.Designer.cs @@ -0,0 +1,2529 @@ +// +using System; +using CrystaLearn.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CrystaLearn.Core.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251226123528_AddDisplayCodeToCrytaDocument")] + partial class AddDisplayCodeToCrytaDocument + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("CrystaLearn") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CrystaLearn.Core.Models.Attachments.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Path") + .HasColumnType("text"); + + b.HasKey("Id", "Kind"); + + b.ToTable("Attachments", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Chatbot.SystemPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptKind") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PromptKind") + .IsUnique(); + + b.ToTable("SystemPrompts", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DisplayCode") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("DocumentType") + .HasColumnType("integer"); + + b.Property("FileExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FileName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FileNameWithoutExtension") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Folder") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SourceContentUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SourceHtmlUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("IsActive"); + + b.ToTable("CrystaDocument", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BadgeSystemUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocumentUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.ToTable("CrystaPrograms", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgramSyncModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("ModuleType") + .HasColumnType("integer"); + + b.Property("SyncConfig") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("ModuleType"); + + b.ToTable("CrystaProgramSyncModules", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AreaId") + .HasColumnType("uuid"); + + b.Property("AreaPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AssignedToId") + .HasColumnType("uuid"); + + b.Property("AssignedToText") + .HasColumnType("text"); + + b.Property("AttachmentsCount") + .HasColumnType("integer"); + + b.Property("BoardColumn") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BoardColumnDone") + .HasColumnType("boolean"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CommentCount") + .HasColumnType("integer"); + + b.Property("CompletedById") + .HasColumnType("uuid"); + + b.Property("CompletedByText") + .HasColumnType("text"); + + b.Property("CompletedWork") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedById1") + .HasColumnType("uuid"); + + b.Property("CreatedByText") + .HasColumnType("text"); + + b.Property("CreatedFromRevisionId") + .HasColumnType("integer"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CustomFields") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DescriptionHtml") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IterationId") + .HasColumnType("uuid"); + + b.Property("IterationPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("OriginalEstimate") + .HasColumnType("double precision"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProviderParentId") + .HasColumnType("text"); + + b.Property("ProviderStatus") + .HasColumnType("text"); + + b.Property("ProviderTaskId") + .HasColumnType("text"); + + b.Property("ProviderTaskUrl") + .HasColumnType("text"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reason") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Relations") + .HasColumnType("text"); + + b.Property("RemainingWork") + .HasColumnType("double precision"); + + b.Property("RevisedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Revision") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Severity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoryPoints") + .HasColumnType("double precision"); + + b.Property("SystemFields") + .HasColumnType("text"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TaskAssignDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskChangedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskCreateDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskDoneDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("CompletedById"); + + b.HasIndex("CreatedById1"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Status"); + + b.HasIndex("TaskCreateDateTime"); + + b.ToTable("CrystaTasks", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CommentType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ContentHtml") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedByText") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("EditedById") + .HasColumnType("uuid"); + + b.Property("EditedByText") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EditedDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FormattedText") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("integer"); + + b.Property("ProviderCommentId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderCommentUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProviderTaskId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reactions") + .HasColumnType("text"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visibility") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("CreatedDateTime"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("EditedById"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("UserId"); + + b.ToTable("CrystaTaskComments", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AreaId") + .HasColumnType("uuid"); + + b.Property("AreaPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AssignedToId") + .HasColumnType("uuid"); + + b.Property("AssignedToText") + .HasColumnType("text"); + + b.Property("AttachmentsCount") + .HasColumnType("integer"); + + b.Property("BoardColumn") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BoardColumnDone") + .HasColumnType("boolean"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ChangedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CommentCount") + .HasColumnType("integer"); + + b.Property("CompletedById") + .HasColumnType("uuid"); + + b.Property("CompletedByText") + .HasColumnType("text"); + + b.Property("CompletedWork") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByDisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedByIdString") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedByText") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromRevisionId") + .HasColumnType("integer"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("CustomFields") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DescriptionHtml") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IterationId") + .HasColumnType("uuid"); + + b.Property("IterationPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("OriginalEstimate") + .HasColumnType("double precision"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProviderTaskId") + .HasColumnType("text"); + + b.Property("ProviderTaskUrl") + .HasColumnType("text"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("Reason") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Relations") + .HasColumnType("text"); + + b.Property("RemainingWork") + .HasColumnType("double precision"); + + b.Property("ResolvedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Severity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StateChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoryPoints") + .HasColumnType("double precision"); + + b.Property("SystemFields") + .HasColumnType("text"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TaskAssignDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskCreateDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskDoneDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToId"); + + b.HasIndex("ChangedDate"); + + b.HasIndex("CompletedById"); + + b.HasIndex("CreatedById"); + + b.HasIndex("CreatedDate"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Revision"); + + b.HasIndex("State"); + + b.ToTable("CrystaTaskRevisions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttachmentChange") + .HasColumnType("text"); + + b.Property("ChangedBy") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ChangedById") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ChangedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedPropertiesJson") + .HasColumnType("text"); + + b.Property("CommentText") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrystaProgramId") + .HasColumnType("uuid"); + + b.Property("CrystaTaskId") + .HasColumnType("uuid"); + + b.Property("FieldDisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FieldName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsWorkItemFieldChange") + .HasColumnType("boolean"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProviderTaskId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderUpdateId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RawJson") + .HasColumnType("text"); + + b.Property("RelationChange") + .HasColumnType("text"); + + b.Property("Revision") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChangedDate"); + + b.HasIndex("CrystaProgramId"); + + b.HasIndex("CrystaTaskId"); + + b.HasIndex("ProviderTaskId"); + + b.HasIndex("Revision"); + + b.HasIndex("UserId"); + + b.ToTable("CrystaTaskUpdates", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "CrystaLearn"); + + b.HasData( + new + { + Id = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7"), + ConcurrencyStamp = "8ff71671-a1d6-5f97-abb9-d87d7b47d6e7", + Name = "s-admin", + NormalizedName = "S-ADMIN" + }, + new + { + Id = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8"), + ConcurrencyStamp = "9ff71672-a1d5-4f97-abb7-d87d6b47d5e8", + Name = "demo", + NormalizedName = "DEMO" + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.RoleClaim", 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "ClaimType", "ClaimValue"); + + b.ToTable("RoleClaims", "CrystaLearn"); + + b.HasData( + new + { + Id = 1, + ClaimType = "mx-p-s", + ClaimValue = "-1", + RoleId = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7") + }, + new + { + Id = 2, + ClaimType = "feat", + ClaimValue = "3.0", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }, + new + { + Id = 3, + ClaimType = "feat", + ClaimValue = "3.1", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }, + new + { + Id = 4, + ClaimType = "feat", + ClaimValue = "4.0", + RoleId = new Guid("9ff71672-a1d5-4f97-abb7-d87d6b47d5e8") + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ElevatedAccessTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EmailTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("HasProfilePicture") + .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("OtpRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PhoneNumberTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetPasswordTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("TwoFactorTokenRequestedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PhoneNumber") + .IsUnique() + .HasFilter("\"PhoneNumber\" IS NOT NULL"); + + b.ToTable("Users", "CrystaLearn"); + + b.HasData( + new + { + Id = new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), + AccessFailedCount = 0, + BirthDate = new DateTimeOffset(new DateTime(2023, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + ConcurrencyStamp = "315e1a26-5b3a-4544-8e91-2760cd28e231", + Email = "test@bitplatform.dev", + EmailConfirmed = true, + EmailTokenRequestedOn = new DateTimeOffset(new DateTime(2023, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FullName = "CrystaLearn test account", + Gender = 0, + HasProfilePicture = false, + LockoutEnabled = true, + NormalizedEmail = "TEST@BITPLATFORM.DEV", + NormalizedUserName = "TEST", + PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PhoneNumber = "+31684207362", + PhoneNumberConfirmed = true, + SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", + TwoFactorEnabled = false, + UserName = "test" + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserClaim", 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") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ClaimType", "ClaimValue"); + + b.ToTable("UserClaims", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserRole", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId") + .IsUnique(); + + b.ToTable("UserRoles", "CrystaLearn"); + + b.HasData( + new + { + UserId = new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), + RoleId = new Guid("8ff71671-a1d6-5f97-abb9-d87d7b47d6e7") + }); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("CultureName") + .HasColumnType("text"); + + b.Property("DeviceInfo") + .HasColumnType("text"); + + b.Property("IP") + .HasColumnType("text"); + + b.Property("NotificationStatus") + .HasColumnType("integer"); + + b.Property("PlatformType") + .HasColumnType("integer"); + + b.Property("Privileged") + .HasColumnType("boolean"); + + b.Property("RenewedOn") + .HasColumnType("bigint"); + + b.Property("SignalRConnectionId") + .HasColumnType("text"); + + b.Property("StartedOn") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("bytea"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("AttestationClientDataJson") + .HasColumnType("bytea"); + + b.Property("AttestationFormat") + .HasColumnType("text"); + + b.Property("AttestationObject") + .HasColumnType("bytea"); + + b.Property("IsBackedUp") + .HasColumnType("boolean"); + + b.Property("IsBackupEligible") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .HasColumnType("bytea"); + + b.Property("RegDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Transports") + .HasColumnType("integer[]"); + + b.Property("UserHandle") + .HasColumnType("bytea"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("ExpirationTime") + .HasColumnType("bigint"); + + b.Property("P256dh") + .HasColumnType("text"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushChannel") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenewedOn") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserSessionId") + .IsUnique() + .HasFilter("\"UserSessionId\" IS NOT NULL"); + + b.ToTable("PushNotificationSubscriptions", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserToken", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "CrystaLearn"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireCounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Value"); + + b.ToTable("HangfireCounter", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Field") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Field"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireHash", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvocationData") + .IsRequired() + .HasColumnType("text"); + + b.Property("StateId") + .HasColumnType("bigint"); + + b.Property("StateName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("StateId"); + + b.HasIndex("StateName"); + + b.ToTable("HangfireJob", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => + { + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("JobId", "Name"); + + b.ToTable("HangfireJobParameter", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Position"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireList", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("HangfireLock", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FetchedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("Queue", "FetchedAt"); + + b.ToTable("HangfireQueuedJob", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Queues") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Heartbeat"); + + b.ToTable("HangfireServer", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.HasKey("Key", "Value"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Score"); + + b.ToTable("HangfireSet", "jobs"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("HangfireState", "jobs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", "CrystaLearn"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaDocument", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaDocumentId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaDocument", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaDocumentId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("SyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaProgramSyncModule", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaProgramSyncModuleId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaProgramSyncModuleId"); + + b1.ToTable("CrystaProgramSyncModules", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaProgramSyncModuleId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("SyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTask", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "AssignedTo") + .WithMany() + .HasForeignKey("AssignedToId"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById1"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "CommentsSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "RevisionsSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "UpdatesSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "WorkItemSyncInfo", b1 => + { + b1.Property("CrystaTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTasks", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskId"); + }); + + b.Navigation("AssignedTo"); + + b.Navigation("CommentsSyncInfo") + .IsRequired(); + + b.Navigation("CompletedBy"); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("Parent"); + + b.Navigation("RevisionsSyncInfo") + .IsRequired(); + + b.Navigation("UpdatesSyncInfo") + .IsRequired(); + + b.Navigation("WorkItemSyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskComment", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "EditedBy") + .WithMany() + .HasForeignKey("EditedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaTaskCommentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskCommentId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskComments", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskCommentId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("EditedBy"); + + b.Navigation("SyncInfo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskRevision", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "AssignedTo") + .WithMany() + .HasForeignKey("AssignedToId"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedById"); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "CommentsSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "RevisionsSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "UpdatesSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "WorkItemSyncInfo", b1 => + { + b1.Property("CrystaTaskRevisionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskRevisionId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskRevisions", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskRevisionId"); + }); + + b.Navigation("AssignedTo"); + + b.Navigation("CommentsSyncInfo") + .IsRequired(); + + b.Navigation("CompletedBy"); + + b.Navigation("CreatedBy"); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("RevisionsSyncInfo") + .IsRequired(); + + b.Navigation("UpdatesSyncInfo") + .IsRequired(); + + b.Navigation("WorkItemSyncInfo") + .IsRequired(); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Crysta.CrystaTaskUpdate", b => + { + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaProgram", "CrystaProgram") + .WithMany() + .HasForeignKey("CrystaProgramId"); + + b.HasOne("CrystaLearn.Core.Models.Crysta.CrystaTask", "CrystaTask") + .WithMany() + .HasForeignKey("CrystaTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.OwnsOne("CrystaLearn.Core.Models.Crysta.SyncInfo", "SyncInfo", b1 => + { + b1.Property("CrystaTaskUpdateId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b1.Property("ContentHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("LastSyncDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("LastSyncOffset") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b1.Property("SyncEndDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("SyncStartDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("SyncStatus") + .HasColumnType("integer"); + + b1.HasKey("CrystaTaskUpdateId"); + + b1.HasIndex("SyncId") + .IsUnique(); + + b1.ToTable("CrystaTaskUpdates", "CrystaLearn"); + + b1.WithOwner() + .HasForeignKey("CrystaTaskUpdateId"); + }); + + b.Navigation("CrystaProgram"); + + b.Navigation("CrystaTask"); + + b.Navigation("SyncInfo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.RoleClaim", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserClaim", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserRole", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.WebAuthnCredential", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("WebAuthnCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.UserSession", "UserSession") + .WithOne("PushNotificationSubscription") + .HasForeignKey("CrystaLearn.Core.Models.PushNotification.PushNotificationSubscription", "UserSessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("UserSession"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserLogin", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CrystaLearn.Server.Api.Models.Identity.UserToken", b => + { + b.HasOne("CrystaLearn.Core.Models.Identity.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireState", "State") + .WithMany() + .HasForeignKey("StateId"); + + b.Navigation("State"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("Parameters") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("QueuedJobs") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => + { + b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job") + .WithMany("States") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Roles"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("WebAuthnCredentials"); + }); + + modelBuilder.Entity("CrystaLearn.Core.Models.Identity.UserSession", b => + { + b.Navigation("PushNotificationSubscription"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Navigation("Parameters"); + + b.Navigation("QueuedJobs"); + + b.Navigation("States"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.cs b/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.cs new file mode 100644 index 00000000..06e604dd --- /dev/null +++ b/src/Core/CrystaLearn.Core/Migrations/20251226123528_AddDisplayCodeToCrytaDocument.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CrystaLearn.Core.Migrations +{ + /// + public partial class AddDisplayCodeToCrytaDocument : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Code", + schema: "CrystaLearn", + table: "CrystaDocument", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(150)", + oldMaxLength: 150); + + migrationBuilder.AddColumn( + name: "DisplayCode", + schema: "CrystaLearn", + table: "CrystaDocument", + type: "character varying(150)", + maxLength: 150, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisplayCode", + schema: "CrystaLearn", + table: "CrystaDocument"); + + migrationBuilder.AlterColumn( + name: "Code", + schema: "CrystaLearn", + table: "CrystaDocument", + type: "character varying(150)", + maxLength: 150, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs b/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs index e0278ada..d900955c 100644 --- a/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Core/CrystaLearn.Core/Migrations/AppDbContextModelSnapshot.cs @@ -78,8 +78,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Code") .IsRequired() - .HasMaxLength(150) - .HasColumnType("character varying(150)"); + .HasColumnType("text"); b.Property("Content") .HasColumnType("text"); @@ -99,6 +98,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(10) .HasColumnType("character varying(10)"); + b.Property("DisplayCode") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + b.Property("DocumentType") .HasColumnType("integer"); diff --git a/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs b/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs index 1db633a2..87c85a60 100644 --- a/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs +++ b/src/Core/CrystaLearn.Core/Models/Crysta/CrystaDocument.cs @@ -6,8 +6,11 @@ namespace CrystaLearn.Core.Models.Crysta; [Table("CrystaDocument", Schema = "CrystaLearn")] public class CrystaDocument : Entity { - [MaxLength(150)] public virtual string Code { get; set; } = default!; + + [MaxLength(150)] + public virtual string DisplayCode { get; set; } = default!; + [MaxLength(200)] public virtual string Title { get; set; } = default!; [MaxLength(10)] diff --git a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs index 6db03493..d6d5c2dd 100644 --- a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs +++ b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs @@ -54,6 +54,7 @@ public static Models.Crysta.CrystaDocument CreateDocument(this GitHubItem item, { Id = Guid.NewGuid(), Code = code, + DisplayCode = code, Title = title, Culture = culture, Content = null, @@ -73,6 +74,7 @@ public static Models.Crysta.CrystaDocument CreateDocument(this GitHubItem item, SyncStatus = SyncStatus.Success, ContentHash = item.Sha, SyncStartDateTime = DateTimeOffset.Now, + SyncId = item.HtmlUrl } }; return doc; diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs index ccc639e2..18249156 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs @@ -1,4 +1,4 @@ -using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Models.Crysta; namespace CrystaLearn.Core.Services.Sync; @@ -65,6 +65,7 @@ private static CrystaDocument CloneDocument(CrystaDocument src) { Id = src.Id, Code = src.Code, + DisplayCode = src.DisplayCode, Culture = src.Culture, Title = src.Title, Content = src.Content, diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index b5e5c8a8..6838dfcc 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -52,7 +52,8 @@ private async Task SyncDocumentsAsync(List githubDoc .ToListAsync(cancellationToken); // Create a map of existing documents by their unique identifier (combination of code and culture) - var existingDocMap = existingDocuments.ToDictionary(d => $"{d.Code}_{d.Culture}", d => d); + + var existingDocMap = existingDocuments.ToDictionary(d => d.SyncInfo.SyncId, d => d); // Track which documents from GitHub we've processed var processedKeys = new HashSet(); @@ -64,7 +65,7 @@ private async Task SyncDocumentsAsync(List githubDoc foreach (var githubDoc in githubDocuments) { - var key = $"{githubDoc.Code}_{githubDoc.Culture}"; + var key = githubDoc.SyncInfo.SyncId; processedKeys.Add(key); if (existingDocMap.TryGetValue(key, out var existingDoc)) From d268acf3e450b2b36545375778dc156927503ba9 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 16:15:10 +0330 Subject: [PATCH 07/24] Enhance document content loading from GitHub URLs For each document with a valid SourceHtmlUrl, fetch the file content asynchronously from GitHub if not already loaded, then process it with GetHtmlContent(). This ensures documents are properly loaded and processed before being added to the result list. Enhance document content retrieval from GitHub Fetch and process document content from GitHub when SourceHtmlUrl is present, ensuring documents have up-to-date content before being added to the result list. --- .../Services/DocumentRepositoryDirectGitHub.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs index 81534435..6ebda0c1 100644 --- a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs +++ b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs @@ -30,6 +30,11 @@ public async Task> GetDocumentsAsync(string programCode, Ca foreach(var item in list) { var doc = item.CreateDocument(program); + if (!string.IsNullOrWhiteSpace(doc.SourceHtmlUrl)) + { + doc.Content ??= await GitHubService.GetFileContentAsync(doc.SourceHtmlUrl); + doc.Content = doc.GetHtmlContent(); + } result.Add(doc); } From 583159fabb6c531021df07a41e4a3c7c73b9472c Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 17:06:59 +0330 Subject: [PATCH 08/24] Update document sync logic and indexing for consistency - Comment out unique index on CrystaDocument.Code to allow duplicates - Remove null chars from document content after HTML generation - Use SHA hash of HtmlUrl for SyncId instead of raw URL - Update processed key logic in GithubSyncService to use SyncId - Add missing using for extension methods in GitHubExtensions --- .../Configurations/Crysta/CrystaDocumentConfiguration.cs | 2 +- .../Services/DocumentRepositoryDirectGitHub.cs | 1 + .../CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs | 5 +++-- src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs b/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs index 0eafc101..16e08d91 100644 --- a/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs +++ b/src/Core/CrystaLearn.Core/Data/Configurations/Crysta/CrystaDocumentConfiguration.cs @@ -6,7 +6,7 @@ public class CrystaDocumentConfiguration : IEntityTypeConfiguration builder) { - builder.HasIndex(d => d.Code).IsUnique(); + //builder.HasIndex(d => d.Code).IsUnique(); builder.HasIndex(d => d.IsActive); builder.HasIndex(d => d.CrystaProgramId); diff --git a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs index 6ebda0c1..820cb2a2 100644 --- a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs +++ b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs @@ -34,6 +34,7 @@ public async Task> GetDocumentsAsync(string programCode, Ca { doc.Content ??= await GitHubService.GetFileContentAsync(doc.SourceHtmlUrl); doc.Content = doc.GetHtmlContent(); + doc.Content = new string(doc.Content?.Where(c => c != '\0').ToArray()); } result.Add(doc); } diff --git a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs index d6d5c2dd..2cb3c090 100644 --- a/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs +++ b/src/Core/CrystaLearn.Core/Services/GitHub/GitHubExtensions.cs @@ -1,4 +1,5 @@ -using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Extensions; +using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Shared; using Markdig; @@ -74,7 +75,7 @@ public static Models.Crysta.CrystaDocument CreateDocument(this GitHubItem item, SyncStatus = SyncStatus.Success, ContentHash = item.Sha, SyncStartDateTime = DateTimeOffset.Now, - SyncId = item.HtmlUrl + SyncId = item.HtmlUrl.Sha() } }; return doc; diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index 6838dfcc..03e7f9d4 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -118,7 +118,7 @@ private async Task SyncDocumentsAsync(List githubDoc // Mark documents that are no longer in GitHub as inactive foreach (var existingDoc in existingDocuments) { - var key = $"{existingDoc.Code}_{existingDoc.Culture}"; + var key = existingDoc.SyncInfo.SyncId; if (!processedKeys.Contains(key) && existingDoc.IsActive) { existingDoc.IsActive = false; From 440c9387b4a96ab3bd231668d389da117437f848 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 21:35:53 +0330 Subject: [PATCH 09/24] Add document retrieval by CrystaUrl and program code Implemented GetDocumentsByCrystaUrlAsync and GetDocumentsByProgramCodeAsync in ICrystaDocumentService and its main implementation. Updated DocumentController to use these methods for fetching documents and selecting language variants. Changed SyncInfoDto.SyncId from Guid? to string? for greater flexibility. --- .../Services/Sync/CrystaDocumentService.cs | 28 +++++++++++++++++++ .../Sync/CrystaDocumentServiceFake.cs | 10 +++++++ .../Services/Sync/ICrystaDocumentService.cs | 4 +++ .../Controllers/Crysta/DocumentController.cs | 25 +++++++++++++---- .../Dtos/Crysta/SyncInfoDto.cs | 2 +- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs index 8f611b51..5532bdf7 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentService.cs @@ -1,5 +1,6 @@ using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Data; +using Microsoft.EntityFrameworkCore; namespace CrystaLearn.Core.Services.Sync; @@ -36,4 +37,31 @@ public async Task SaveDocumentsAsync( await DbContext.SaveChangesAsync(cancellationToken); } + + public async Task> GetDocumentsByCrystaUrlAsync(string crystaUrl, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(crystaUrl)) + { + return new List(); + } + + return await DbContext.CrystaDocument + .AsNoTracking() + .Where(d => d.CrystaUrl == crystaUrl && d.IsActive) + .ToListAsync(cancellationToken); + } + + public async Task> GetDocumentsByProgramCodeAsync(string programCode, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(programCode)) + { + return new List(); + } + + return await DbContext.CrystaDocument + .AsNoTracking() + .Where(d => d.CrystaProgram != null && d.CrystaProgram.Code == programCode && d.IsActive) + .OrderBy(d => d.FileName) + .ToListAsync(cancellationToken); + } } diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs index 18249156..49145211 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaDocumentServiceFake.cs @@ -103,4 +103,14 @@ private static void ApplyUpdate(CrystaDocument existing, CrystaDocument src) existing.SyncInfo = src.SyncInfo; existing.UpdatedAt = DateTimeOffset.Now; } + + public Task> GetDocumentsByCrystaUrlAsync(string crystaUrl, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> GetDocumentsByProgramCodeAsync(string programCode, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } } diff --git a/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs b/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs index fd818e46..bb50ee2e 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/ICrystaDocumentService.cs @@ -8,4 +8,8 @@ Task SaveDocumentsAsync( IEnumerable updatedDocuments, IEnumerable deletedDocuments, CancellationToken cancellationToken = default); + + Task> GetDocumentsByCrystaUrlAsync(string crystaUrl, CancellationToken cancellationToken = default); + + Task> GetDocumentsByProgramCodeAsync(string programCode, CancellationToken cancellationToken = default); } diff --git a/src/Server/CrystaLearn.Server.Api/Controllers/Crysta/DocumentController.cs b/src/Server/CrystaLearn.Server.Api/Controllers/Crysta/DocumentController.cs index 1910845c..baeee19a 100644 --- a/src/Server/CrystaLearn.Server.Api/Controllers/Crysta/DocumentController.cs +++ b/src/Server/CrystaLearn.Server.Api/Controllers/Crysta/DocumentController.cs @@ -1,5 +1,5 @@ using System.Web; -using CrystaLearn.Core.Services.Contracts; +using CrystaLearn.Core.Services.Sync; using CrystaLearn.Shared.Controllers.Crysta; using CrystaLearn.Shared.Dtos.Crysta; @@ -8,14 +8,14 @@ namespace CrystaLearn.Server.Api.Controllers.Crysta; [ApiController, Route("api/[controller]/[action]")] public partial class DocumentController : AppControllerBase, IDocumentController { - [AutoInject] private IDocumentRepository DocumentRepository { get; set; } + [AutoInject] private ICrystaDocumentService CrystaDocumentService { get; set; } [AllowAnonymous] [HttpGet("{programCode}")] //[ResponseCache(Duration = 1 * 24 * 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = ["*"])] public async Task> GetDocuments(string programCode, CancellationToken cancellationToken) { - var list = await DocumentRepository.GetDocumentsAsync(programCode, cancellationToken); + var list = await CrystaDocumentService.GetDocumentsByProgramCodeAsync(programCode, cancellationToken); var dtos = list.Select(x => x.Map()).ToList(); dtos.ForEach(o => o.Content = null); return dtos; @@ -28,7 +28,22 @@ public async Task> GetDocuments(string programCode, Cancellati CancellationToken cancellationToken) { var decoded = HttpUtility.UrlDecode(crystaUrl); - var result = await DocumentRepository.GetDocumentByCrystaUrlAsync(decoded, culture, cancellationToken); - return result; + var docs = await CrystaDocumentService.GetDocumentsByCrystaUrlAsync(decoded, cancellationToken); + + var languageVariants = docs.Where(o => o.CrystaUrl == decoded).ToList(); + + var document = languageVariants.FirstOrDefault(d => culture?.StartsWith(d.Culture) ?? false); + document ??= languageVariants.FirstOrDefault(d => d.Culture.StartsWith("en")); + document ??= languageVariants.FirstOrDefault(d => d.Culture.StartsWith("fa")); + + if (document is null) + { + return null; + } + + var dto = document.Map(); + dto.CultureVariants = languageVariants.Select(x => x.Culture).ToArray(); + + return dto; } } diff --git a/src/Shared/CrystaLearn.Shared/Dtos/Crysta/SyncInfoDto.cs b/src/Shared/CrystaLearn.Shared/Dtos/Crysta/SyncInfoDto.cs index cfdf8649..dd27e0ec 100644 --- a/src/Shared/CrystaLearn.Shared/Dtos/Crysta/SyncInfoDto.cs +++ b/src/Shared/CrystaLearn.Shared/Dtos/Crysta/SyncInfoDto.cs @@ -7,7 +7,7 @@ namespace CrystaLearn.Shared.Dtos.Crysta; public class SyncInfoDto { - public Guid? SyncId { get; set; } + public string? SyncId { get; set; } public DateTimeOffset? SyncStartDateTime { get; set; } public DateTimeOffset? SyncEndDateTime { get; set; } [MaxLength(100)] From f363a65317590923397fbfa2680f057fe5f35f94 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 22:12:48 +0330 Subject: [PATCH 10/24] Update src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs index d7023bf6..5997f2c3 100644 --- a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs +++ b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs @@ -21,7 +21,6 @@ public async Task SyncGithubDocuments_WithValidModule_MustWork() using var scope = services.CreateScope(); var service = scope.ServiceProvider.GetRequiredService(); - var moduleService = scope.ServiceProvider.GetRequiredService(); var programRepo = scope.ServiceProvider.GetRequiredService(); // Get a test program From 74c23f2d5a363f79eb0dd787de300c7bf3b32bd2 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 22:16:52 +0330 Subject: [PATCH 11/24] Update src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index 03e7f9d4..351c63c8 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -5,7 +5,7 @@ namespace CrystaLearn.Core.Services.Sync; -public partial class GithubSyncService : IGithubSyncService +public partial class GitHubSyncService : IGitHubSyncService { [AutoInject] private IDocumentRepository DocumentRepository { get; set; } = default!; [AutoInject] private AppDbContext DbContext { get; set; } = default!; From e2f4c5c86b6fe23ef23c5d0a2beb6f322296c5bd Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 22:22:15 +0330 Subject: [PATCH 12/24] Standardize GitHub sync service naming; add doc retrieval Refactor all IGithubSyncService/GithubSyncService references to IGitHubSyncService/GitHubSyncService for consistent naming. Implement GetDocumentByCrystaUrlAsync in DocumentRepositoryDirectGitHub to support culture-aware document retrieval and content fetching from GitHub. Update service registration, dependency injection, and test class names accordingly. Improve null handling and code clarity in the new method. --- .../Sync/GithubSyncServiceTests.cs | 6 ++-- .../ApplicationBuilderExtensions.cs | 2 +- .../Services/Contracts/IGithubSyncService.cs | 4 +-- .../DocumentRepositoryDirectGitHub.cs | 32 +++++++++++++++++-- .../Services/Sync/CrystaProgramSyncService.cs | 2 +- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs index 5997f2c3..f7a429c3 100644 --- a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs +++ b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs @@ -6,7 +6,7 @@ namespace CrystaLearn.Core.Tests.Sync; -public class GithubSyncServiceTests : TestBase +public class GitHubSyncServiceTests : TestBase { [Fact] public async Task SyncGithubDocuments_WithValidModule_MustWork() @@ -20,7 +20,7 @@ public async Task SyncGithubDocuments_WithValidModule_MustWork() }); using var scope = services.CreateScope(); - var service = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); var programRepo = scope.ServiceProvider.GetRequiredService(); // Get a test program @@ -68,7 +68,7 @@ public async Task SyncGithubDocuments_WithInvalidModuleType_MustThrowException() }); using var scope = services.CreateScope(); - var service = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); var module = new CrystaProgramSyncModule { diff --git a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs index 526aacf1..b20079c9 100644 --- a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs @@ -38,7 +38,7 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); } diff --git a/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs index 50d90079..45860014 100644 --- a/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Contracts/IGithubSyncService.cs @@ -1,9 +1,9 @@ -using CrystaLearn.Core.Models.Crysta; +using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services.Sync; namespace CrystaLearn.Core.Services.Contracts; -public interface IGithubSyncService +public interface IGitHubSyncService { Task SyncAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default); } diff --git a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs index 820cb2a2..9a97298e 100644 --- a/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs +++ b/src/Core/CrystaLearn.Core/Services/DocumentRepositoryDirectGitHub.cs @@ -3,6 +3,7 @@ using CrystaLearn.Core.Services.GitHub; using CrystaLearn.Shared.Dtos.Crysta; using Markdig; +using CrystaLearn.Core.Mappers; namespace CrystaLearn.Core.Services; @@ -10,6 +11,7 @@ public partial class DocumentRepositoryDirectGitHub : IDocumentRepository { [AutoInject] private IGitHubService GitHubService { get; set; } = default; [AutoInject] private ICrystaProgramRepository CrystaProgramRepository { get; set; } = default; + [AutoInject] private CrystaLearn.Core.Services.Sync.ICrystaDocumentService CrystaDocumentService { get; set; } = default!; public async Task> GetDocumentsAsync(string programCode, CancellationToken cancellationToken) { @@ -42,10 +44,36 @@ public async Task> GetDocumentsAsync(string programCode, Ca return result; } - public async Task GetDocumentByCrystaUrlAsync(string crystaUrl, + public async Task GetDocumentByCrystaUrlAsync(string crystaUrl, string? culture, CancellationToken cancellationToken) { - throw new NotImplementedException(); + var languageVariants = await CrystaDocumentService.GetDocumentsByCrystaUrlAsync(crystaUrl, cancellationToken); + + if (languageVariants == null || !languageVariants.Any()) + { + return null; + } + + var document = languageVariants.FirstOrDefault(d => culture?.StartsWith(d.Culture) ?? false); + document ??= languageVariants.FirstOrDefault(d => d.Culture.StartsWith("en")); + document ??= languageVariants.FirstOrDefault(d => d.Culture.StartsWith("fa")); + + if (document is null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(document.SourceHtmlUrl) && string.IsNullOrEmpty(document.Content)) + { + document.Content ??= await GitHubService.GetFileContentAsync(document.SourceHtmlUrl); + document.Content = document.GetHtmlContent(); + document.Content = new string(document.Content?.Where(c => c != '\0').ToArray()); + } + + var dto = document.Map(); + dto.CultureVariants = languageVariants.Select(x => x.Culture).ToArray(); + + return dto; } //public async Task GetDocumentContentByUrlAsync(string programCode, string url, diff --git a/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs index 15085dac..7ec25af4 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/CrystaProgramSyncService.cs @@ -11,7 +11,7 @@ namespace CrystaLearn.Core.Services.Sync; public partial class CrystaProgramSyncService : ICrystaProgramSyncService { [AutoInject] private IAzureBoardSyncService AzureBoardSyncService { get; set; } = default!; - [AutoInject] private IGithubSyncService GithubSyncService { get; set; } = default!; + [AutoInject] private IGitHubSyncService GithubSyncService { get; set; } = default!; public async Task SyncAsync(CrystaProgramSyncModule module) { From 8ff53a48a91e7e4f44f6687a3a73fac04023a3a9 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 26 Dec 2025 22:30:21 +0330 Subject: [PATCH 13/24] Update .vscode/tasks.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ee1168c9..e32e2120 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -41,27 +41,6 @@ "options": { "cwd": "${workspaceFolder}/src/Tests" } - }, - { - "label": "build", - "type": "shell", - "command": "dotnet build src/Server/CrystaLearn.Server.Web/CrystaLearn.Server.Web.csproj" - }, - { - "label": "run-tests", - "type": "shell", - "command": "dotnet", - "args": [ - "test" - ] - }, - { - "label": "run-tests", - "type": "shell", - "command": "dotnet", - "args": [ - "test" - ] } ] } \ No newline at end of file From 8f764a6e206174b763c574285012c096f16ed0ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:02:10 +0000 Subject: [PATCH 14/24] Initial plan From 702d8ed239f627fb290afbb678564307f4141796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:07:17 +0000 Subject: [PATCH 15/24] Add null checks for SyncId in GithubSyncService Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Services/Sync/GithubSyncService.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index 351c63c8..14ce5992 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -52,8 +52,10 @@ private async Task SyncDocumentsAsync(List githubDoc .ToListAsync(cancellationToken); // Create a map of existing documents by their unique identifier (combination of code and culture) - - var existingDocMap = existingDocuments.ToDictionary(d => d.SyncInfo.SyncId, d => d); + // Filter out documents with null SyncId to prevent ArgumentNullException + var existingDocMap = existingDocuments + .Where(d => d.SyncInfo.SyncId != null) + .ToDictionary(d => d.SyncInfo.SyncId!, d => d); // Track which documents from GitHub we've processed var processedKeys = new HashSet(); @@ -66,6 +68,13 @@ private async Task SyncDocumentsAsync(List githubDoc foreach (var githubDoc in githubDocuments) { var key = githubDoc.SyncInfo.SyncId; + + // Skip documents with null SyncId to prevent adding null to HashSet + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + processedKeys.Add(key); if (existingDocMap.TryGetValue(key, out var existingDoc)) @@ -119,6 +128,13 @@ private async Task SyncDocumentsAsync(List githubDoc foreach (var existingDoc in existingDocuments) { var key = existingDoc.SyncInfo.SyncId; + + // Skip documents with null SyncId to prevent checking null in HashSet + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + if (!processedKeys.Contains(key) && existingDoc.IsActive) { existingDoc.IsActive = false; From 5d50b94c8e2d8eabbb1e91a9016d7c7a6742c12f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:08:49 +0000 Subject: [PATCH 16/24] Make null check logic consistent using IsNullOrWhiteSpace Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs index 14ce5992..9b73e825 100644 --- a/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs +++ b/src/Core/CrystaLearn.Core/Services/Sync/GithubSyncService.cs @@ -54,7 +54,7 @@ private async Task SyncDocumentsAsync(List githubDoc // Create a map of existing documents by their unique identifier (combination of code and culture) // Filter out documents with null SyncId to prevent ArgumentNullException var existingDocMap = existingDocuments - .Where(d => d.SyncInfo.SyncId != null) + .Where(d => !string.IsNullOrWhiteSpace(d.SyncInfo.SyncId)) .ToDictionary(d => d.SyncInfo.SyncId!, d => d); // Track which documents from GitHub we've processed From 543a77a1cd634b62da5f11e42388290aad754727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:01:02 +0000 Subject: [PATCH 17/24] Initial plan From 9f0628a702196e3a5e2f57a2452aafe4388f95b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:05:58 +0000 Subject: [PATCH 18/24] Fix static field modification in instance constructor - Removed static keyword from _modules field to prevent shared state issues - Changed from AppDbContext to IDbContextFactory for proper singleton support - Added lazy initialization with thread-safe semaphore lock - Changed service registration from Transient to Singleton - Updated Fake service with same pattern for consistency Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../ApplicationBuilderExtensions.cs | 2 +- .../CrystaProgramSyncModuleService.cs | 47 ++++++++++++++----- .../CrystaProgramSyncModuleServiceFake.cs | 6 ++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs index b20079c9..6c1a3d28 100644 --- a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs @@ -39,7 +39,7 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 95e173ef..7c7ddfdc 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -12,20 +12,40 @@ namespace CrystaLearn.Core.Services; public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService { - private static List _modules = new(); - private AppDbContext DbContext { get; set; } = default!; + private List _modules = new(); + private IDbContextFactory DbContextFactory { get; set; } = default!; + private bool _initialized = false; + private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); - public CrystaProgramSyncModuleService(AppDbContext dbContext) + public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { - this.DbContext = dbContext; - if (_modules.Count == 0) + this.DbContextFactory = dbContextFactory; + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (!_initialized) { - _modules = DbContext.Set().Include(f => f.CrystaProgram).ToListAsync().GetAwaiter().GetResult(); + await _initLock.WaitAsync(cancellationToken); + try + { + if (!_initialized) + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + _modules = await dbContext.Set().Include(f => f.CrystaProgram).ToListAsync(cancellationToken); + _initialized = true; + } + } + finally + { + _initLock.Release(); + } } } public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { + await EnsureInitializedAsync(cancellationToken); return _modules; } @@ -34,32 +54,33 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) // Try to persist to database if DbContext is available and configured try { - if (DbContext != null) + if (DbContextFactory != null) { - var set = DbContext.Set(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(CancellationToken.None); + var set = dbContext.Set(); var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: CancellationToken.None); if (existing != null) { // Update all scalar properties from incoming module - DbContext.Entry(existing).CurrentValues.SetValues(module); + dbContext.Entry(existing).CurrentValues.SetValues(module); // If SyncInfo is an owned/complex type, ensure its properties are updated as well if (module.SyncInfo != null) { existing.SyncInfo ??= new SyncInfo(); - DbContext.Entry(existing).CurrentValues.SetValues(existing); // ensure entry is tracked - DbContext.Entry(existing).Reference(e => e.SyncInfo).TargetEntry?.CurrentValues.SetValues(module.SyncInfo); + dbContext.Entry(existing).CurrentValues.SetValues(existing); // ensure entry is tracked + dbContext.Entry(existing).Reference(e => e.SyncInfo).TargetEntry?.CurrentValues.SetValues(module.SyncInfo); } - DbContext.Update(existing); + dbContext.Update(existing); } else { await set.AddAsync(module); } - await DbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); // keep in-memory copy in sync as well - replace whole object to reflect all fields var idx = _modules.FindIndex(m => m.Id == module.Id); diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 95faa1f9..da148699 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -8,14 +8,15 @@ namespace CrystaLearn.Core.Services; public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService { - private static List _modules = new(); + private List _modules = new(); + private bool _initialized = false; private IConfiguration Configuration { get; set; } = default!; public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) { Configuration = configuration; - if (_modules.Count == 0) + if (!_initialized) { var pat = Configuration["AzureDevOps:PersonalAccessToken"]; @@ -42,6 +43,7 @@ public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) } } }; + _initialized = true; } } From ffae89b25e2b223caaa576344aa526b23ca7a8ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:07:53 +0000 Subject: [PATCH 19/24] Add thread-safety and improve code quality - Add Microsoft.EntityFrameworkCore using statement for EF Core extensions - Add thread-safe synchronization for UpdateSyncModuleAsync with SemaphoreSlim - Move initialization to async method with proper thread-safety in Fake service - Add synchronization locks for in-memory list modifications Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../CrystaProgramSyncModuleService.cs | 38 ++++++--- .../CrystaProgramSyncModuleServiceFake.cs | 80 ++++++++++++------- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 7c7ddfdc..25d41581 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -6,6 +6,7 @@ using CrystaLearn.Core.Data; using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services.Contracts; +using Microsoft.EntityFrameworkCore; namespace CrystaLearn.Core.Services; @@ -16,6 +17,7 @@ public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleSe private IDbContextFactory DbContextFactory { get; set; } = default!; private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { @@ -83,14 +85,22 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) await dbContext.SaveChangesAsync(); // keep in-memory copy in sync as well - replace whole object to reflect all fields - var idx = _modules.FindIndex(m => m.Id == module.Id); - if (idx >= 0) + await _updateLock.WaitAsync(); + try { - _modules[idx] = module; + var idx = _modules.FindIndex(m => m.Id == module.Id); + if (idx >= 0) + { + _modules[idx] = module; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } return; @@ -102,14 +112,22 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } // Fallback: update in-memory collection (replace whole object) - var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); - if (existingInMemoryIndex >= 0) + await _updateLock.WaitAsync(); + try { - _modules[existingInMemoryIndex] = module; + var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); + if (existingInMemoryIndex >= 0) + { + _modules[existingInMemoryIndex] = module; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } } } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index da148699..57264b87 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -10,59 +10,85 @@ public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModu { private List _modules = new(); private bool _initialized = false; + private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); private IConfiguration Configuration { get; set; } = default!; public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) { Configuration = configuration; + } + + private async Task EnsureInitializedAsync() + { if (!_initialized) { - var pat = Configuration["AzureDevOps:PersonalAccessToken"]; - - _modules = new List + await _initLock.WaitAsync(); + try { - new CrystaProgramSyncModule + if (!_initialized) { - Id = Guid.NewGuid(), - CrystaProgramId = CrystaProgramServiceFake.FakeProgramCSI.Id, - CrystaProgram = CrystaProgramServiceFake.FakeProgramCSI, - ModuleType = SyncModuleType.AzureBoard, - SyncConfig = - $$""" + var pat = Configuration["AzureDevOps:PersonalAccessToken"]; + + _modules = new List + { + new CrystaProgramSyncModule + { + Id = Guid.NewGuid(), + CrystaProgramId = CrystaProgramServiceFake.FakeProgramCSI.Id, + CrystaProgram = CrystaProgramServiceFake.FakeProgramCSI, + ModuleType = SyncModuleType.AzureBoard, + SyncConfig = + $$""" + { + "Organization": "cs-internship", + "PersonalAccessToken": "{{pat}}", + "Project": "CS Internship Program" + } + """, + SyncInfo = new SyncInfo { - "Organization": "cs-internship", - "PersonalAccessToken": "{{pat}}", - "Project": "CS Internship Program" + LastSyncDateTime = DateTimeOffset.Now.AddDays(-2), + LastSyncOffset = "0" } - """, - SyncInfo = new SyncInfo - { - LastSyncDateTime = DateTimeOffset.Now.AddDays(-2), - LastSyncOffset = "0" - } + } + }; + _initialized = true; } - }; - _initialized = true; + } + finally + { + _initLock.Release(); + } } } public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { + await EnsureInitializedAsync(); return _modules; } public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) { - var existing = _modules.FirstOrDefault(m => m.Id == module.Id); - if (existing != null) + await _updateLock.WaitAsync(); + try { - existing.SyncInfo = module.SyncInfo; - existing.SyncConfig = module.SyncConfig; + var existing = _modules.FirstOrDefault(m => m.Id == module.Id); + if (existing != null) + { + existing.SyncInfo = module.SyncInfo; + existing.SyncConfig = module.SyncConfig; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } } } From f74ef3eee345af3e61d3348c7780b9dc2c9263c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:09:09 +0000 Subject: [PATCH 20/24] Add CancellationToken support to UpdateSyncModuleAsync - Add CancellationToken parameter to interface method with default value - Propagate cancellation token through all database operations - Pass cancellation token to AddAsync and SaveChangesAsync methods Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Contracts/ICrystaProgramSyncModuleService.cs | 2 +- .../Services/CrystaProgramSyncModuleService.cs | 14 +++++++------- .../Services/CrystaProgramSyncModuleServiceFake.cs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs index 56b5d57a..1dfb3b4e 100644 --- a/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs @@ -7,5 +7,5 @@ public interface ICrystaProgramSyncModuleService Task> GetSyncModulesAsync(CancellationToken cancellationToken); // Save or update a sync module (persist SyncInfo changes) - Task UpdateSyncModuleAsync(CrystaProgramSyncModule module); + Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default); } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 25d41581..d15ea5b9 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -51,17 +51,17 @@ public async Task> GetSyncModulesAsync(Cancellatio return _modules; } - public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) + public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) { // Try to persist to database if DbContext is available and configured try { if (DbContextFactory != null) { - await using var dbContext = await DbContextFactory.CreateDbContextAsync(CancellationToken.None); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); var set = dbContext.Set(); - var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: CancellationToken.None); + var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: cancellationToken); if (existing != null) { // Update all scalar properties from incoming module @@ -79,13 +79,13 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } else { - await set.AddAsync(module); + await set.AddAsync(module, cancellationToken); } - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(cancellationToken); // keep in-memory copy in sync as well - replace whole object to reflect all fields - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var idx = _modules.FindIndex(m => m.Id == module.Id); @@ -112,7 +112,7 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } // Fallback: update in-memory collection (replace whole object) - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 57264b87..5b6863f4 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -70,9 +70,9 @@ public async Task> GetSyncModulesAsync(Cancellatio return _modules; } - public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) + public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) { - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var existing = _modules.FirstOrDefault(m => m.Id == module.Id); From e7b5014c2d49220e03c4c90d3f527ad986ecf1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:10:20 +0000 Subject: [PATCH 21/24] Implement IDisposable for proper resource cleanup - Add IDisposable implementation to both service classes - Properly dispose SemaphoreSlim instances to free unmanaged resources - Add _disposed flag to prevent double disposal Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Services/CrystaProgramSyncModuleService.cs | 13 ++++++++++++- .../Services/CrystaProgramSyncModuleServiceFake.cs | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index d15ea5b9..c9c354b2 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -10,7 +10,7 @@ namespace CrystaLearn.Core.Services; -public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService +public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService, IDisposable { private List _modules = new(); @@ -18,6 +18,7 @@ public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleSe private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); + private bool _disposed = false; public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { @@ -130,4 +131,14 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, Cancella _updateLock.Release(); } } + + public void Dispose() + { + if (!_disposed) + { + _initLock?.Dispose(); + _updateLock?.Dispose(); + _disposed = true; + } + } } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 5b6863f4..6d07f8eb 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -6,12 +6,13 @@ namespace CrystaLearn.Core.Services; -public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService +public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService, IDisposable { private List _modules = new(); private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); + private bool _disposed = false; private IConfiguration Configuration { get; set; } = default!; @@ -91,4 +92,14 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, Cancella _updateLock.Release(); } } + + public void Dispose() + { + if (!_disposed) + { + _initLock?.Dispose(); + _updateLock?.Dispose(); + _disposed = true; + } + } } From 482ee1d7b8572c154df8b47d037782a56bf5a7bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:11:27 +0000 Subject: [PATCH 22/24] Add CancellationToken to EnsureInitializedAsync in Fake service - Pass cancellation token to semaphore wait operations - Enable proper cancellation propagation during initialization Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Services/CrystaProgramSyncModuleServiceFake.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 6d07f8eb..5817979b 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -21,11 +21,11 @@ public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) Configuration = configuration; } - private async Task EnsureInitializedAsync() + private async Task EnsureInitializedAsync(CancellationToken cancellationToken = default) { if (!_initialized) { - await _initLock.WaitAsync(); + await _initLock.WaitAsync(cancellationToken); try { if (!_initialized) @@ -67,7 +67,7 @@ private async Task EnsureInitializedAsync() public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { - await EnsureInitializedAsync(); + await EnsureInitializedAsync(cancellationToken); return _modules; } From 3e48a2037f218a07392db36b65efbd2d8c630738 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Thu, 8 Jan 2026 20:22:54 +0330 Subject: [PATCH 23/24] Register CrystaDocumentServiceFake in SyncGithubDocuments_WithValidModule_MustWork --- src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs index f7a429c3..62c8434f 100644 --- a/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs +++ b/src/Core/CrystaLearn.Core.Test/Sync/GithubSyncServiceTests.cs @@ -2,6 +2,7 @@ using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services; using CrystaLearn.Core.Services.Contracts; +using CrystaLearn.Core.Services.Sync; using CrystaLearn.Core.Tests.Infra; namespace CrystaLearn.Core.Tests.Sync; @@ -16,6 +17,7 @@ public async Task SyncGithubDocuments_WithValidModule_MustWork() { sc.AddTransient(); sc.AddTransient(); + sc.AddTransient(); sc.AddSingleton(); }); From f5c81f9b5dda11bd926091e2e6135dae460ff208 Mon Sep 17 00:00:00 2001 From: Afshin Alizadeh Date: Fri, 6 Mar 2026 05:21:41 -0800 Subject: [PATCH 24/24] Update docs/architecture/sync-overview.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/architecture/sync-overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/sync-overview.md b/docs/architecture/sync-overview.md index cd45387d..5617477d 100644 --- a/docs/architecture/sync-overview.md +++ b/docs/architecture/sync-overview.md @@ -23,7 +23,7 @@ graph TB subgraph "CrystaLearn Sync Layer" ABS[AzureBoardSyncService] - GHS[GithubSyncService] + GHS[GitHubSyncService] LIS[LinkedInSyncService*] TWS[TwitterSyncService*] end