diff --git a/Artskart3.Api/Controllers/LookupController.cs b/Artskart3.Api/Controllers/LookupController.cs index 22e5ee8f..25911b58 100644 --- a/Artskart3.Api/Controllers/LookupController.cs +++ b/Artskart3.Api/Controllers/LookupController.cs @@ -11,10 +11,12 @@ namespace Artskart3.Api.Controllers; public class LookupController : ControllerBase { private readonly ILookupService _lookupService; + private readonly ITaxonHierarchyService _taxonHierarchy; - public LookupController(ILookupService lookupService) + public LookupController(ILookupService lookupService, ITaxonHierarchyService taxonHierarchy) { _lookupService = lookupService ?? throw new ArgumentNullException(nameof(lookupService)); + _taxonHierarchy = taxonHierarchy ?? throw new ArgumentNullException(nameof(taxonHierarchy)); } /// @@ -82,4 +84,17 @@ public async Task>> GetBasisOfRecords var basisOfRecords = await _lookupService.GetBasisOfRecordsAsync(cancellationToken); return Ok(basisOfRecords); } + + /// + /// Returnerer direkte barn i taksonomien for en gitt forelder-taxon. + /// Uten parentTaxonId returneres rotnodene (kingdom-nivå). + /// Data hentes fra minne — ingen databasekall per request. + /// + [HttpGet("TaxonTree")] + [Produces("application/json")] + public ActionResult> GetTaxonTree([FromQuery] int? parentTaxonId = null, CancellationToken cancellationToken = default) + { + var children = _taxonHierarchy.GetChildren(parentTaxonId); + return Ok(children); + } } diff --git a/Artskart3.Api/Controllers/SearchController.cs b/Artskart3.Api/Controllers/SearchController.cs index b1465e65..7e607fd1 100644 --- a/Artskart3.Api/Controllers/SearchController.cs +++ b/Artskart3.Api/Controllers/SearchController.cs @@ -277,6 +277,7 @@ private bool ValidateFilterArraySizes(ObservationSearchFilterDto filter, out Bad ReadOnlySpan<(string name, int? length)> arrays = [ (nameof(filter.TaxonGroupIds), filter.TaxonGroupIds?.Length), + (nameof(filter.TaxonIds), filter.TaxonIds?.Length), (nameof(filter.CategoryIds), filter.CategoryIds?.Length), (nameof(filter.OrganizationIds), filter.OrganizationIds?.Length), (nameof(filter.MunicipalityIds), filter.MunicipalityIds?.Length), @@ -343,6 +344,7 @@ private bool ValidateLocationFilterArraySizes(LocationSearchFilterDto filter, ou ReadOnlySpan<(string name, int? length)> arrays = [ (nameof(filter.TaxonGroupIds), filter.TaxonGroupIds?.Length), + (nameof(filter.TaxonIds), filter.TaxonIds?.Length), (nameof(filter.CategoryIds), filter.CategoryIds?.Length), (nameof(filter.OrganizationIds), filter.OrganizationIds?.Length), (nameof(filter.MunicipalityIds), filter.MunicipalityIds?.Length), diff --git a/Artskart3.Core/Application/DTOs/IObservationFilter.cs b/Artskart3.Core/Application/DTOs/IObservationFilter.cs index ef088e34..bba97b29 100644 --- a/Artskart3.Core/Application/DTOs/IObservationFilter.cs +++ b/Artskart3.Core/Application/DTOs/IObservationFilter.cs @@ -7,6 +7,7 @@ namespace Artskart3.Core.Application.DTOs; public interface IObservationFilter { int[]? TaxonGroupIds { get; } + int[]? TaxonIds { get; } int[]? CategoryIds { get; } int[]? OrganizationIds { get; } string[]? MunicipalityIds { get; } diff --git a/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs b/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs index 2c517fb7..b1f2831e 100644 --- a/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs +++ b/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs @@ -4,6 +4,8 @@ public class LocationSearchFilterDto : IObservationFilter { public int[]? TaxonGroupIds { get; set; } + public int[]? TaxonIds { get; set; } + public int[]? CategoryIds { get; set; } public int[]? BasisOfRecordIds { get; set; } @@ -29,6 +31,7 @@ public class LocationSearchFilterDto : IObservationFilter /// public bool HasActiveFilters => TaxonGroupIds?.Length > 0 || + TaxonIds?.Length > 0 || CategoryIds?.Length > 0 || BasisOfRecordIds?.Length > 0 || OrganizationIds?.Length > 0 || @@ -50,6 +53,7 @@ public class LocationSearchFilterDto : IObservationFilter /// public bool HasObservationAttributeFilters => TaxonGroupIds?.Length > 0 || + TaxonIds?.Length > 0 || CategoryIds?.Length > 0 || BasisOfRecordIds?.Length > 0 || OrganizationIds?.Length > 0 || diff --git a/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs b/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs index 05e8e875..a51f8c04 100644 --- a/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs +++ b/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs @@ -10,6 +10,8 @@ public class ObservationSearchFilterDto : PaginatedRequestDto, IObservationFilte public int[]? TaxonGroupIds { get; set; } + public int[]? TaxonIds { get; set; } + public int[]? CategoryIds { get; set; } public int[]? OrganizationIds { get; set; } diff --git a/Artskart3.Core/Application/DTOs/TaxonTreeNodeDto.cs b/Artskart3.Core/Application/DTOs/TaxonTreeNodeDto.cs new file mode 100644 index 00000000..543aa216 --- /dev/null +++ b/Artskart3.Core/Application/DTOs/TaxonTreeNodeDto.cs @@ -0,0 +1,14 @@ +namespace Artskart3.Core.Application.DTOs; + +public class TaxonTreeNodeDto +{ + public int Id { get; set; } + public string? ValidScientificName { get; set; } + public string? PreferredPopularName { get; set; } + public int TaxonRankId { get; set; } + public int TaxonGroupId { get; set; } + public int? CumulativeObservationCount { get; set; } + public bool ExistsInCountry { get; set; } + public bool HasChildren { get; set; } + public List Children { get; set; } = []; +} diff --git a/Artskart3.Core/Application/Services/Interfaces/ITaxonHierarchyService.cs b/Artskart3.Core/Application/Services/Interfaces/ITaxonHierarchyService.cs new file mode 100644 index 00000000..858e794c --- /dev/null +++ b/Artskart3.Core/Application/Services/Interfaces/ITaxonHierarchyService.cs @@ -0,0 +1,23 @@ +using Artskart3.Core.Application.DTOs; + +namespace Artskart3.Core.Application.Services.Interfaces; + +/// +/// Oppstartslastet oppslag for taksonhierarki. +/// Data lastes fra Taxon-tabellen og holdes i minne, oppdateres periodisk. +/// +public interface ITaxonHierarchyService +{ + /// + /// Returnerer TaxonRankId for et gitt taxonId, eller null hvis ukjent. + /// Brukes for å bestemme hvilken kolonne i ObservationTaxonHierarchy som skal spørres. + /// + int? GetTaxonRankId(int taxonId); + + /// + /// Returnerer direkte barn av en gitt taxon som trenoder. + /// Hvis parentTaxonId er null, returneres rotnodene (kingdom-nivå). + /// Filtrerer til kun taxoner med observasjoner eller som finnes i landet. + /// + List GetChildren(int? parentTaxonId); +} diff --git a/Artskart3.Core/Domain/Entities/ObservationTaxonHierarchy.cs b/Artskart3.Core/Domain/Entities/ObservationTaxonHierarchy.cs new file mode 100644 index 00000000..312325c4 --- /dev/null +++ b/Artskart3.Core/Domain/Entities/ObservationTaxonHierarchy.cs @@ -0,0 +1,38 @@ +namespace Artskart3.Core.Domain.Entities; + +/// +/// Denormalisert hierarkitabell for raske taksonomiske oppslag. +/// Hver rad knytter en observasjon til alle sine forfedre i taksonomien, +/// med én kolonne per rangsnivå (kingdom, phylum, class, osv.). +/// +public class ObservationTaxonHierarchy +{ + public int ObservationId { get; set; } + + public int? KingdomTaxonId { get; set; } // TaxonRankId 1 + public int? SubkingdomTaxonId { get; set; } // TaxonRankId 2 + public int? PhylumTaxonId { get; set; } // TaxonRankId 3 + public int? SubphylumTaxonId { get; set; } // TaxonRankId 4 + public int? SuperclassTaxonId { get; set; } // TaxonRankId 5 + public int? ClassTaxonId { get; set; } // TaxonRankId 6 + public int? SubclassTaxonId { get; set; } // TaxonRankId 7 + public int? InfraclassTaxonId { get; set; } // TaxonRankId 8 + public int? CohortTaxonId { get; set; } // TaxonRankId 9 + public int? SuperorderTaxonId { get; set; } // TaxonRankId 10 + public int? OrderTaxonId { get; set; } // TaxonRankId 11 + public int? SuborderTaxonId { get; set; } // TaxonRankId 12 + public int? InfraorderTaxonId { get; set; } // TaxonRankId 13 + public int? SuperfamilyTaxonId { get; set; } // TaxonRankId 14 + public int? FamilyTaxonId { get; set; } // TaxonRankId 15 + public int? SubfamilyTaxonId { get; set; } // TaxonRankId 16 + public int? TribeTaxonId { get; set; } // TaxonRankId 17 + public int? SubtribeTaxonId { get; set; } // TaxonRankId 18 + public int? GenusTaxonId { get; set; } // TaxonRankId 19 + public int? SubgenusTaxonId { get; set; } // TaxonRankId 20 + public int? SectionTaxonId { get; set; } // TaxonRankId 21 + public int? SpeciesTaxonId { get; set; } // TaxonRankId 22 + public int? SubspeciesTaxonId { get; set; } // TaxonRankId 23 + public int? VarietyTaxonId { get; set; } // TaxonRankId 24 + public int? FormTaxonId { get; set; } // TaxonRankId 25 + public int? NotSetTaxonId { get; set; } // TaxonRankId 26 +} diff --git a/Artskart3.Infrastructure/Data/DbContext/ArtskartDbContext.cs b/Artskart3.Infrastructure/Data/DbContext/ArtskartDbContext.cs index ba2a015f..aba476e0 100644 --- a/Artskart3.Infrastructure/Data/DbContext/ArtskartDbContext.cs +++ b/Artskart3.Infrastructure/Data/DbContext/ArtskartDbContext.cs @@ -1024,6 +1024,40 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => new { e.EntityTypeId, e.EntityId, e.ObservationId }).HasDatabaseName("IX_ObservationEntityIndex_EntityType_EntityId_ObsId"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ObservationId); + entity.Property(e => e.ObservationId).ValueGeneratedNever(); + entity.ToTable("ObservationTaxonHierarchy"); + + entity.HasIndex(e => e.KingdomTaxonId).HasDatabaseName("IX_OTH_Kingdom").HasFilter("[KingdomTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubkingdomTaxonId).HasDatabaseName("IX_OTH_Subkingdom").HasFilter("[SubkingdomTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.PhylumTaxonId).HasDatabaseName("IX_OTH_Phylum").HasFilter("[PhylumTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubphylumTaxonId).HasDatabaseName("IX_OTH_Subphylum").HasFilter("[SubphylumTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SuperclassTaxonId).HasDatabaseName("IX_OTH_Superclass").HasFilter("[SuperclassTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.ClassTaxonId).HasDatabaseName("IX_OTH_Class").HasFilter("[ClassTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubclassTaxonId).HasDatabaseName("IX_OTH_Subclass").HasFilter("[SubclassTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.InfraclassTaxonId).HasDatabaseName("IX_OTH_Infraclass").HasFilter("[InfraclassTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.CohortTaxonId).HasDatabaseName("IX_OTH_Cohort").HasFilter("[CohortTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SuperorderTaxonId).HasDatabaseName("IX_OTH_Superorder").HasFilter("[SuperorderTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.OrderTaxonId).HasDatabaseName("IX_OTH_Order").HasFilter("[OrderTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SuborderTaxonId).HasDatabaseName("IX_OTH_Suborder").HasFilter("[SuborderTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.InfraorderTaxonId).HasDatabaseName("IX_OTH_Infraorder").HasFilter("[InfraorderTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SuperfamilyTaxonId).HasDatabaseName("IX_OTH_Superfamily").HasFilter("[SuperfamilyTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.FamilyTaxonId).HasDatabaseName("IX_OTH_Family").HasFilter("[FamilyTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubfamilyTaxonId).HasDatabaseName("IX_OTH_Subfamily").HasFilter("[SubfamilyTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.TribeTaxonId).HasDatabaseName("IX_OTH_Tribe").HasFilter("[TribeTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubtribeTaxonId).HasDatabaseName("IX_OTH_Subtribe").HasFilter("[SubtribeTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.GenusTaxonId).HasDatabaseName("IX_OTH_Genus").HasFilter("[GenusTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubgenusTaxonId).HasDatabaseName("IX_OTH_Subgenus").HasFilter("[SubgenusTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SectionTaxonId).HasDatabaseName("IX_OTH_Section").HasFilter("[SectionTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SpeciesTaxonId).HasDatabaseName("IX_OTH_Species").HasFilter("[SpeciesTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.SubspeciesTaxonId).HasDatabaseName("IX_OTH_Subspecies").HasFilter("[SubspeciesTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.VarietyTaxonId).HasDatabaseName("IX_OTH_Variety").HasFilter("[VarietyTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.FormTaxonId).HasDatabaseName("IX_OTH_Form").HasFilter("[FormTaxonId] IS NOT NULL"); + entity.HasIndex(e => e.NotSetTaxonId).HasDatabaseName("IX_OTH_NotSet").HasFilter("[NotSetTaxonId] IS NOT NULL"); + }); + OnModelCreatingPartial(modelBuilder); } diff --git a/Artskart3.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/Artskart3.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 45e52963..0fadda54 100644 --- a/Artskart3.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Artskart3.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -24,6 +24,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.Designer.cs b/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.Designer.cs new file mode 100644 index 00000000..e5038c0f --- /dev/null +++ b/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.Designer.cs @@ -0,0 +1,3153 @@ +// +using System; +using Artskart3.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; + +#nullable disable + +namespace Artskart3.Infrastructure.Migrations +{ + [DbContext(typeof(ArtskartDbContext))] + [Migration("20260702113806_AddObservationTaxonHierarchy")] + partial class AddObservationTaxonHierarchy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaTypeId") + .HasColumnType("int"); + + b.Property("Bbox") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Centroid") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("geometry") + .HasComputedColumnSql("([WktPolygon].[STCentroid]())", false); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Fid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsCurrent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("ParentFid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SyncDateTime") + .HasColumnType("datetime"); + + b.Property("TimeStamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime") + .HasDefaultValue(new DateTime(1900, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("WktPolygon") + .HasColumnType("geometry"); + + b.Property("ZoomLevel") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_dbo.Area"); + + b.HasIndex(new[] { "AreaTypeId", "Id", "Fid" }, "IX_AreaTypeID"); + + b.HasIndex(new[] { "AreaTypeId", "Fid", "IsCurrent" }, "IX_Area_AreaTypeId_Fid_IsCurrent"); + + b.HasIndex(new[] { "ZoomLevel", "IsCurrent" }, "IX_Area_ZoomLevel_IsCurrent"); + + b.HasIndex(new[] { "Fid" }, "IX_Fid"); + + b.HasIndex(new[] { "ParentFid" }, "IX_ParentFid"); + + b.HasIndex(new[] { "Name" }, "NonClusteredIndex-20180305-111522"); + + b.ToTable("Area", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.AreaType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsRequired") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.AreaType"); + + b.ToTable("AreaType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.BasisOfRecord", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Variants") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id") + .HasName("PK_dbo.BasisOfRecord"); + + b.ToTable("BasisOfRecord", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Behavior", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Variants") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id") + .HasName("PK_dbo.Behavior"); + + b.ToTable("Behavior", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Category", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CategoryTypeId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("nvarchar(2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.Category"); + + b.HasIndex(new[] { "CategoryTypeId" }, "IX_CategoryTypeId"); + + b.ToTable("Category", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.CategoryType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.CategoryType"); + + b.ToTable("CategoryType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.CommandLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Command") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CommandType") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DatabaseName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorNumber") + .HasColumnType("int"); + + b.Property("ExtendedInfo") + .HasColumnType("xml"); + + b.Property("IndexName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IndexType") + .HasColumnType("tinyint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ObjectName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ObjectType") + .HasMaxLength(2) + .IsUnicode(false) + .HasColumnType("char(2)") + .IsFixedLength(); + + b.Property("PartitionNumber") + .HasColumnType("int"); + + b.Property("SchemaName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("StatisticsName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("CommandLog", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.DeletedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RecordId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ReplicationId") + .HasColumnType("int"); + + b.Property("TimeStamp") + .HasColumnType("datetime"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.DeletedItem"); + + b.HasIndex(new[] { "RecordId" }, "Ix_RecordId"); + + b.HasIndex(new[] { "TimeStamp" }, "Ix_TimeStamp"); + + b.HasIndex(new[] { "TimeStamp" }, "Ix_TimeStampWithRecordId"); + + b.ToTable("DeletedItem", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ExportStatus", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Doi") + .HasColumnType("nvarchar(max)"); + + b.Property("ExportCreated") + .HasColumnType("datetime2"); + + b.Property("ExportFinished") + .HasColumnType("datetime2"); + + b.Property("ExportInfo") + .HasColumnType("nvarchar(max)"); + + b.Property("ExportJobId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExportStarted") + .HasColumnType("datetime2"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ExportStatus"); + + b.ToTable("ExportStatus", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Fab4exclude", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.FAB4Exclude"); + + b.ToTable("FAB4Exclude", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Filter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SerializedFilter") + .IsRequired() + .HasMaxLength(3700) + .HasColumnType("nvarchar(3700)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.Filter"); + + b.ToTable("Filter", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.GeometryColumn", b => + { + b.Property("FTableCatalog") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)") + .HasColumnName("f_table_catalog"); + + b.Property("FTableSchema") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)") + .HasColumnName("f_table_schema"); + + b.Property("FTableName") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)") + .HasColumnName("f_table_name"); + + b.Property("FGeometryColumn") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)") + .HasColumnName("f_geometry_column"); + + b.Property("CoordDimension") + .HasColumnType("int") + .HasColumnName("coord_dimension"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("GeometryType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)") + .HasColumnName("geometry_type"); + + b.Property("Id") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Srid") + .HasColumnType("int") + .HasColumnName("srid"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("FTableCatalog", "FTableSchema", "FTableName", "FGeometryColumn") + .HasName("geometry_columns_pk"); + + b.ToTable("geometry_columns", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ImportLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModeCommand") + .HasColumnType("int") + .HasColumnName("Mode_Command"); + + b.Property("ModeDataset") + .HasColumnType("nvarchar(max)") + .HasColumnName("Mode_Dataset"); + + b.Property("ModeFilePath") + .HasColumnType("nvarchar(max)") + .HasColumnName("Mode_FilePath"); + + b.Property("ModePartitionYear") + .HasColumnType("int") + .HasColumnName("Mode_PartitionYear"); + + b.Property("ModeProcessMode") + .HasColumnType("int") + .HasColumnName("Mode_ProcessMode"); + + b.Property("ModeSensitiveRecords") + .HasColumnType("bit") + .HasColumnName("Mode_SensitiveRecords"); + + b.Property("Runtime") + .HasColumnType("float"); + + b.Property("SerializedObject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SumNewRecordsImported") + .HasColumnType("int"); + + b.Property("SumRecordsDeleted") + .HasColumnType("int"); + + b.Property("SumRecordsProcessed") + .HasColumnType("int"); + + b.Property("SumRecordsRejected") + .HasColumnType("int"); + + b.Property("SumUpdatedRecords") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("TimestampFinished") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ImportLog"); + + b.HasIndex(new[] { "Timestamp" }, "Ix_Time"); + + b.ToTable("ImportLog", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ImportNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("SeverityDescription") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ImportNotification"); + + b.HasIndex(new[] { "Timestamp" }, "IX_TimeStamp"); + + b.ToTable("ImportNotification", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ImportState", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DateLastProcessed") + .HasColumnType("datetime2"); + + b.Property("DateLastProcessedFinished") + .HasColumnType("datetime2"); + + b.Property("DateLastProcessedWithChangeInRecords") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Doi") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FailCount") + .HasColumnType("int"); + + b.Property("GbifDataSetId") + .HasColumnType("uniqueidentifier"); + + b.Property("ImportedNumberOfRecords") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastProcessResult") + .HasColumnType("nvarchar(max)"); + + b.Property("LastRecordDateLastModified") + .HasColumnType("datetime2"); + + b.Property("LastRecordImportedCatalogNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("RejectedNumberOfRecords") + .HasColumnType("int"); + + b.Property("ReportedLastTrackDateInCache") + .HasColumnType("datetime2"); + + b.Property("ReportedTotalNumberOfRecordsInCache") + .HasColumnType("int"); + + b.Property("TrackDateOfLastRecordProcessed") + .HasColumnType("datetime2"); + + b.Property("UpdateMode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ImportState"); + + b.ToTable("ImportState", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CoordinatePrecision") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("East") + .HasColumnType("int"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("Locality") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LocationId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("LookupId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NodeId") + .HasColumnType("int"); + + b.Property("North") + .HasColumnType("int"); + + b.Property("TimeStamp") + .HasColumnType("datetime"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.Location"); + + b.HasIndex(new[] { "CoordinatePrecision" }, "IX_CoordinatePrecision"); + + b.HasIndex(new[] { "East", "North" }, "IX_EastNorth"); + + b.HasIndex(new[] { "East" }, "IX_EastNorthGeom"); + + b.HasIndex(new[] { "LookupId" }, "IX_LookupId"); + + b.ToTable("Location", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Maskeringsruter16x16km", b => + { + b.Property("Objectid") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("OBJECTID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Objectid")); + + b.Property("KriteriumMaskeringsrute") + .HasColumnType("int") + .HasColumnName("KRITERIUM_MASKERINGSRUTE"); + + b.Property("Ruteid") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("char(50)") + .HasColumnName("RUTEID") + .IsFixedLength(); + + b.Property("Shape") + .HasColumnType("geometry") + .HasColumnName("SHAPE"); + + b.HasKey("Objectid"); + + b.ToTable("Maskeringsruter_16x16km", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Maskeringsruter4x4km", b => + { + b.Property("Objectid") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("OBJECTID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Objectid")); + + b.Property("KriteriumMaskeringsrute") + .HasColumnType("int") + .HasColumnName("KRITERIUM_MASKERINGSRUTE"); + + b.Property("Ruteid") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("char(50)") + .HasColumnName("RUTEID") + .IsFixedLength(); + + b.Property("Shape") + .HasColumnType("geometry") + .HasColumnName("SHAPE"); + + b.HasKey("Objectid"); + + b.ToTable("Maskeringsruter_4x4km", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Maskeringsruter8x8km", b => + { + b.Property("Objectid") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("OBJECTID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Objectid")); + + b.Property("KriteriumMaskeringsrute") + .HasColumnType("int") + .HasColumnName("KRITERIUM_MASKERINGSRUTE"); + + b.Property("Ruteid") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("char(50)") + .HasColumnName("RUTEID") + .IsFixedLength(); + + b.Property("Shape") + .HasColumnType("geometry") + .HasColumnName("SHAPE"); + + b.HasKey("Objectid"); + + b.ToTable("Maskeringsruter_8x8km", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DownloadRetryCount") + .HasColumnType("int"); + + b.Property("Downloaded") + .HasColumnType("bit"); + + b.Property("Image") + .HasColumnType("varbinary(max)"); + + b.Property("InDownloadqueue") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("License") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MediaFileTypeId") + .HasColumnType("int"); + + b.Property("ObservationId") + .HasColumnType("int") + .HasColumnName("Observation_Id"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RightsHolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Size") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.MediaFile"); + + b.HasIndex("MediaFileTypeId"); + + b.HasIndex(new[] { "Downloaded", "MediaFileTypeId", "DownloadRetryCount" }, "IX_DownLoaded"); + + b.HasIndex(new[] { "ObservationId" }, "IX_Observation_Id"); + + b.ToTable("MediaFile", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.MediaFileType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MediaTypeName") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MimeType") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.MediaFileType"); + + b.ToTable("MediaFileType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.MigrationHistory", b => + { + b.Property("MigrationId") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ContextKey") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Model") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("ProductVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("MigrationId", "ContextKey") + .HasName("PK_dbo.__MigrationHistory"); + + b.ToTable("__MigrationHistory", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BasisOfRecordId") + .HasColumnType("int"); + + b.Property("CatalogNumber") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CollectionCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CoordinatePrecisionInMeters") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DateLastModified") + .HasColumnType("datetime2"); + + b.Property("DateTimeCollected") + .HasColumnType("datetime2"); + + b.Property("DateTimeRecordImported") + .HasColumnType("datetime2"); + + b.Property("DateTimeRecordProcessed") + .HasColumnType("datetime2"); + + b.Property("DatetimeIdentified") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("East") + .HasColumnType("int"); + + b.Property("HasAnnotations") + .HasColumnType("bit"); + + b.Property("HasErrors") + .HasColumnType("bit"); + + b.Property("HashCode") + .HasColumnType("int"); + + b.Property("InstitutionCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstitutionId") + .HasMaxLength(25) + .HasColumnType("nvarchar(25)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("MatchedScientificNameId") + .HasColumnType("int"); + + b.Property("MonthCollected") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("(datepart(month,[DateTimeCollected]))", false); + + b.Property("NodeId") + .HasColumnType("int"); + + b.Property("North") + .HasColumnType("int"); + + b.Property("ObservationQualityTypeId") + .HasColumnType("int"); + + b.Property("OccurrenceId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProcessEngineId") + .HasColumnType("int"); + + b.Property("ProxyId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("TaxonGroupId") + .HasColumnType("int"); + + b.Property("TaxonId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YearCollected") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("(datepart(year,[DateTimeCollected]))", false); + + b.HasKey("Id") + .HasName("PK_dbo.Observation"); + + b.HasIndex("MatchedScientificNameId"); + + b.HasIndex("ObservationQualityTypeId"); + + b.HasIndex(new[] { "LocationId", "CategoryId" }, "IX_LocationId"); + + b.HasIndex(new[] { "BasisOfRecordId" }, "IX_Observation_BasisOfRecordId"); + + b.HasIndex(new[] { "CategoryId" }, "IX_Observation_CategoryId"); + + b.HasIndex(new[] { "CoordinatePrecisionInMeters" }, "IX_Observation_CoordinatePrecisionInMeters"); + + b.HasIndex(new[] { "DateLastModified" }, "IX_Observation_DateLastModified"); + + b.HasIndex(new[] { "DateTimeCollected" }, "IX_Observation_DateTimeCollected"); + + b.HasIndex(new[] { "InstitutionCode" }, "IX_Observation_InstitutionCode"); + + b.HasIndex(new[] { "InstitutionId" }, "IX_Observation_InstitutionId"); + + b.HasIndex(new[] { "LocationId" }, "IX_Observation_LocationId"); + + b.HasIndex(new[] { "LocationId", "HasErrors", "HasAnnotations" }, "IX_Observation_LocationId_HasErrors_HasAnnotations"); + + b.HasIndex(new[] { "MonthCollected" }, "IX_Observation_MonthCollected"); + + b.HasIndex(new[] { "TaxonGroupId" }, "IX_Observation_TaxonGroupId"); + + b.HasIndex(new[] { "TaxonGroupId", "LocationId" }, "IX_Observation_TaxonGroupId_LocationId"); + + b.HasIndex(new[] { "YearCollected" }, "IX_Observation_YearCollected"); + + b.HasIndex(new[] { "YearCollected", "LocationId" }, "IX_Observation_YearCollected_LocationId"); + + b.HasIndex(new[] { "TaxonId", "Id", "CoordinatePrecisionInMeters", "YearCollected" }, "IX_Rodliste"); + + b.HasIndex(new[] { "TaxonGroupId", "CategoryId" }, "IX_TaxonGroupId"); + + b.ToTable("Observation", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationDetail", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("AssociatedReferences") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CollectingMethod") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Collector") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DatasetId") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("DatasetName") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DateTimeCollectedStr") + .HasMaxLength(15) + .HasColumnType("nvarchar(15)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DynamicProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("EventTime") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FieldNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("GeoreferenceRemarks") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Habitat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IdentifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IndividualCount") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Locality") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MaximumElevationInMeters") + .HasColumnType("int"); + + b.Property("MeasurementMethod") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MinimumElevationInMeters") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(3500) + .HasColumnType("nvarchar(3500)"); + + b.Property("OtherCatalogNumbers") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Preparations") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RecordNumber") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RelatedResourceId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RelationshipOfResource") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Sex") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TypeStatus") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VerbatimDepth") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id") + .HasName("PK_dbo.ObservationDetails"); + + b.HasIndex(new[] { "Id" }, "IX_Id"); + + b.ToTable("ObservationDetails"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationEntityIndex", b => + { + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("EntityTypeId") + .HasColumnType("int"); + + b.Property("EntityId") + .HasColumnType("int"); + + b.HasKey("ObservationId", "EntityTypeId", "EntityId"); + + b.HasIndex("EntityTypeId", "EntityId", "ObservationId") + .HasDatabaseName("IX_ObservationEntityIndex_EntityType_EntityId_ObsId"); + + b.ToTable("ObservationEntityIndex", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AnnotationId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DateTimeModified") + .HasColumnType("datetime"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("ErrorContext") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ValidationCode") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_dbo.ObservationError"); + + b.HasIndex(new[] { "AnnotationId" }, "IX_AnnotationId"); + + b.HasIndex(new[] { "ObservationId" }, "IX_ObservationId"); + + b.ToTable("ObservationError", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LinkedObservationId") + .HasColumnType("int"); + + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("Remarks") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ObservationLink"); + + b.HasIndex(new[] { "LinkedObservationId" }, "IX_LinkedObservationId"); + + b.HasIndex(new[] { "ObservationId" }, "IX_ObservationId"); + + b.ToTable("ObservationLink", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationQualityType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id") + .HasName("PK_dbo.ObservationQualityType"); + + b.ToTable("ObservationQualityType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationTaxonHierarchy", b => + { + b.Property("ObservationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ObservationId")); + + b.Property("ClassTaxonId") + .HasColumnType("int"); + + b.Property("CohortTaxonId") + .HasColumnType("int"); + + b.Property("FamilyTaxonId") + .HasColumnType("int"); + + b.Property("FormTaxonId") + .HasColumnType("int"); + + b.Property("GenusTaxonId") + .HasColumnType("int"); + + b.Property("InfraclassTaxonId") + .HasColumnType("int"); + + b.Property("InfraorderTaxonId") + .HasColumnType("int"); + + b.Property("KingdomTaxonId") + .HasColumnType("int"); + + b.Property("NotSetTaxonId") + .HasColumnType("int"); + + b.Property("OrderTaxonId") + .HasColumnType("int"); + + b.Property("PhylumTaxonId") + .HasColumnType("int"); + + b.Property("SectionTaxonId") + .HasColumnType("int"); + + b.Property("SpeciesTaxonId") + .HasColumnType("int"); + + b.Property("SubclassTaxonId") + .HasColumnType("int"); + + b.Property("SubfamilyTaxonId") + .HasColumnType("int"); + + b.Property("SubgenusTaxonId") + .HasColumnType("int"); + + b.Property("SubkingdomTaxonId") + .HasColumnType("int"); + + b.Property("SuborderTaxonId") + .HasColumnType("int"); + + b.Property("SubphylumTaxonId") + .HasColumnType("int"); + + b.Property("SubspeciesTaxonId") + .HasColumnType("int"); + + b.Property("SubtribeTaxonId") + .HasColumnType("int"); + + b.Property("SuperclassTaxonId") + .HasColumnType("int"); + + b.Property("SuperfamilyTaxonId") + .HasColumnType("int"); + + b.Property("SuperorderTaxonId") + .HasColumnType("int"); + + b.Property("TribeTaxonId") + .HasColumnType("int"); + + b.Property("VarietyTaxonId") + .HasColumnType("int"); + + b.HasKey("ObservationId"); + + b.HasIndex("ClassTaxonId") + .HasDatabaseName("IX_OTH_Class") + .HasFilter("[ClassTaxonId] IS NOT NULL"); + + b.HasIndex("CohortTaxonId") + .HasDatabaseName("IX_OTH_Cohort") + .HasFilter("[CohortTaxonId] IS NOT NULL"); + + b.HasIndex("FamilyTaxonId") + .HasDatabaseName("IX_OTH_Family") + .HasFilter("[FamilyTaxonId] IS NOT NULL"); + + b.HasIndex("FormTaxonId") + .HasDatabaseName("IX_OTH_Form") + .HasFilter("[FormTaxonId] IS NOT NULL"); + + b.HasIndex("GenusTaxonId") + .HasDatabaseName("IX_OTH_Genus") + .HasFilter("[GenusTaxonId] IS NOT NULL"); + + b.HasIndex("InfraclassTaxonId") + .HasDatabaseName("IX_OTH_Infraclass") + .HasFilter("[InfraclassTaxonId] IS NOT NULL"); + + b.HasIndex("InfraorderTaxonId") + .HasDatabaseName("IX_OTH_Infraorder") + .HasFilter("[InfraorderTaxonId] IS NOT NULL"); + + b.HasIndex("KingdomTaxonId") + .HasDatabaseName("IX_OTH_Kingdom") + .HasFilter("[KingdomTaxonId] IS NOT NULL"); + + b.HasIndex("NotSetTaxonId") + .HasDatabaseName("IX_OTH_NotSet") + .HasFilter("[NotSetTaxonId] IS NOT NULL"); + + b.HasIndex("OrderTaxonId") + .HasDatabaseName("IX_OTH_Order") + .HasFilter("[OrderTaxonId] IS NOT NULL"); + + b.HasIndex("PhylumTaxonId") + .HasDatabaseName("IX_OTH_Phylum") + .HasFilter("[PhylumTaxonId] IS NOT NULL"); + + b.HasIndex("SectionTaxonId") + .HasDatabaseName("IX_OTH_Section") + .HasFilter("[SectionTaxonId] IS NOT NULL"); + + b.HasIndex("SpeciesTaxonId") + .HasDatabaseName("IX_OTH_Species") + .HasFilter("[SpeciesTaxonId] IS NOT NULL"); + + b.HasIndex("SubclassTaxonId") + .HasDatabaseName("IX_OTH_Subclass") + .HasFilter("[SubclassTaxonId] IS NOT NULL"); + + b.HasIndex("SubfamilyTaxonId") + .HasDatabaseName("IX_OTH_Subfamily") + .HasFilter("[SubfamilyTaxonId] IS NOT NULL"); + + b.HasIndex("SubgenusTaxonId") + .HasDatabaseName("IX_OTH_Subgenus") + .HasFilter("[SubgenusTaxonId] IS NOT NULL"); + + b.HasIndex("SubkingdomTaxonId") + .HasDatabaseName("IX_OTH_Subkingdom") + .HasFilter("[SubkingdomTaxonId] IS NOT NULL"); + + b.HasIndex("SuborderTaxonId") + .HasDatabaseName("IX_OTH_Suborder") + .HasFilter("[SuborderTaxonId] IS NOT NULL"); + + b.HasIndex("SubphylumTaxonId") + .HasDatabaseName("IX_OTH_Subphylum") + .HasFilter("[SubphylumTaxonId] IS NOT NULL"); + + b.HasIndex("SubspeciesTaxonId") + .HasDatabaseName("IX_OTH_Subspecies") + .HasFilter("[SubspeciesTaxonId] IS NOT NULL"); + + b.HasIndex("SubtribeTaxonId") + .HasDatabaseName("IX_OTH_Subtribe") + .HasFilter("[SubtribeTaxonId] IS NOT NULL"); + + b.HasIndex("SuperclassTaxonId") + .HasDatabaseName("IX_OTH_Superclass") + .HasFilter("[SuperclassTaxonId] IS NOT NULL"); + + b.HasIndex("SuperfamilyTaxonId") + .HasDatabaseName("IX_OTH_Superfamily") + .HasFilter("[SuperfamilyTaxonId] IS NOT NULL"); + + b.HasIndex("SuperorderTaxonId") + .HasDatabaseName("IX_OTH_Superorder") + .HasFilter("[SuperorderTaxonId] IS NOT NULL"); + + b.HasIndex("TribeTaxonId") + .HasDatabaseName("IX_OTH_Tribe") + .HasFilter("[TribeTaxonId] IS NOT NULL"); + + b.HasIndex("VarietyTaxonId") + .HasDatabaseName("IX_OTH_Variety") + .HasFilter("[VarietyTaxonId] IS NOT NULL"); + + b.ToTable("ObservationTaxonHierarchy", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DateCreated") + .HasColumnType("datetime"); + + b.Property("DateModified") + .HasColumnType("datetime"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ExternalId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("OrganizationTypeId") + .HasColumnType("int"); + + b.Property("ParentId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.Organization"); + + b.HasIndex(new[] { "ExternalId" }, "IX_ExternalId"); + + b.HasIndex(new[] { "OrganizationTypeId" }, "IX_OrganizationTypeId"); + + b.HasIndex(new[] { "ParentId" }, "IX_ParentId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("int"); + + b.Property("RelationTypeId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.OrganizationRelation"); + + b.HasIndex(new[] { "ObservationId" }, "IX_ObservationId"); + + b.HasIndex(new[] { "OrganizationId" }, "IX_OrganizationId"); + + b.HasIndex(new[] { "OrganizationId", "ObservationId" }, "IX_OrganizationRelation_OrgId_ObsId"); + + b.HasIndex(new[] { "RelationTypeId" }, "IX_RelationTypeId"); + + b.ToTable("OrganizationRelation", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationRelationType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.OrganizationRelationType"); + + b.ToTable("OrganizationRelationType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.OrganizationType"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ProcessRecordResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("ErrorContext") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("NotificationCode") + .HasColumnType("int"); + + b.Property("RejectedRecordId") + .HasColumnType("int") + .HasColumnName("RejectedRecord_Id"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ValidationCode") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_dbo.ProcessRecordResult"); + + b.HasIndex(new[] { "RejectedRecordId" }, "IX_RejectedRecord_Id"); + + b.HasIndex(new[] { "NotificationCode" }, "IxNotificationCode"); + + b.HasIndex(new[] { "ValidationCode" }, "IxValidationCode"); + + b.ToTable("ProcessRecordResult", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ProcessSourceDataResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedRecordsCount") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("NewImportedRecordsCount") + .HasColumnType("int"); + + b.Property("ProcessedRecordsCount") + .HasColumnType("int"); + + b.Property("RejectedRecordsCount") + .HasColumnType("int"); + + b.Property("SerializedObject") + .HasColumnType("nvarchar(max)"); + + b.Property("SingleDatabaseResult") + .HasColumnType("int"); + + b.Property("SourceDataId") + .HasColumnType("int"); + + b.Property("TimeStampFinished") + .HasColumnType("datetime2"); + + b.Property("TimeStampStarted") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("WarningRecordsCount") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_dbo.ProcessSourceDataResult"); + + b.ToTable("ProcessSourceDataResult", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ProsessEngineHistory", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PublishDate") + .HasColumnType("datetime"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.ProsessEngineHistory"); + + b.ToTable("ProsessEngineHistory", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.RecordNotificationType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id") + .HasName("PK_dbo.RecordNotificationType"); + + b.ToTable("RecordNotificationType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.RecordValidationType", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id") + .HasName("PK_dbo.RecordValidationType"); + + b.ToTable("RecordValidationType", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.RejectedRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CollectionCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DatetimeRecordProsessed") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Gid") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstitutionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RecordId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceDataId") + .HasColumnType("int"); + + b.Property("TentativId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.RejectedRecord"); + + b.HasIndex(new[] { "RecordId" }, "IX_RecordId"); + + b.HasIndex(new[] { "Gid" }, "Ix_Gid"); + + b.HasIndex(new[] { "InstitutionCode", "CollectionCode" }, "Ix_InstitutionCode"); + + b.HasIndex(new[] { "SourceDataId", "DatetimeRecordProsessed" }, "Ix_SourceDataId") + .IsDescending(false, true); + + b.HasIndex(new[] { "RecordId" }, "NonClusteredIndex-20171028-195228"); + + b.ToTable("RejectedRecord", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.SensitiveObservationDatum", b => + { + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("CoordinatePrecisionInMeters") + .HasColumnType("int"); + + b.Property("DateTimeCollected") + .HasColumnType("datetime2"); + + b.Property("East") + .HasColumnType("int"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("Locality") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("North") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(3500) + .HasColumnType("nvarchar(3500)"); + + b.HasKey("ObservationId") + .HasName("PK_dbo.SensitiveObservationData"); + + b.HasIndex(new[] { "ObservationId" }, "IX_ObservationId"); + + b.ToTable("SensitiveObservationData"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.SlowQueryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("QueryTimeMs") + .HasColumnType("bigint"); + + b.Property("RequestBody") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestPath") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ThresholdMs") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .HasDatabaseName("IX_SlowQueryLog_Endpoint"); + + b.HasIndex("OccurredAt") + .HasDatabaseName("IX_SlowQueryLog_OccurredAt"); + + b.ToTable("SlowQueryLog", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.SpatialRefSy", b => + { + b.Property("Srid") + .HasColumnType("int") + .HasColumnName("srid"); + + b.Property("AuthName") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)") + .HasColumnName("auth_name"); + + b.Property("AuthSrid") + .HasColumnType("int") + .HasColumnName("auth_srid"); + + b.Property("Proj4text") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)") + .HasColumnName("proj4text"); + + b.Property("Srtext") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)") + .HasColumnName("srtext"); + + b.HasKey("Srid") + .HasName("PK__spatial___36B11BD5A2397CD6"); + + b.ToTable("spatial_ref_sys", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Tag", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.Tag"); + + b.ToTable("Tag", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Taxon", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CumulativeObservationCount") + .HasColumnType("int"); + + b.Property("DailyCumulativeObservationCount") + .HasColumnType("int"); + + b.Property("DailyObservationCount") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("ExistsInCountry") + .HasColumnType("bit"); + + b.Property("ExternalTaxonId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("ParentTaxonId") + .HasColumnType("int"); + + b.Property("PreferredPopularName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ScientificNameIdHiarchy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TaxonGroupId") + .HasColumnType("int"); + + b.Property("TaxonIdHiarchy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TaxonRankId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ValidScientificName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ValidScientificNameAuthorship") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ValidScientificNameId") + .HasColumnType("int"); + + b.Property("WeeklyCumulativeObservationCount") + .HasColumnType("int"); + + b.Property("WeeklyObservationCount") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_dbo.Taxon"); + + b.HasIndex(new[] { "PreferredPopularName" }, "IX_PrefferedPopularname"); + + b.HasIndex(new[] { "TaxonGroupId" }, "IX_TaxonGroupId"); + + b.HasIndex(new[] { "TaxonRankId" }, "IX_TaxonRankId"); + + b.HasIndex(new[] { "TaxonRankId", "TaxonIdHiarchy" }, "IX_TaxonRank_TaxonHirachy"); + + b.HasIndex(new[] { "ValidScientificName" }, "IX_ValidScientificName"); + + b.HasIndex(new[] { "ParentTaxonId" }, "IxParentTaxonId"); + + b.HasIndex(new[] { "ParentTaxonId" }, "IxParentTaxonId_Id_ObsCount"); + + b.HasIndex(new[] { "PreferredPopularName" }, "NonClusteredIndex-20190129-153041"); + + b.ToTable("Taxon", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonGroup", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(75) + .HasColumnType("nvarchar(75)"); + + b.Property("ObservationCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonGroup"); + + b.ToTable("TaxonGroup", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonName", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Accepted") + .HasColumnType("bit"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ScientificName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ScientificNameAuthorship") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TaxonId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonName"); + + b.HasIndex(new[] { "ScientificName" }, "IX_ScientificName"); + + b.HasIndex(new[] { "TaxonId" }, "IX_Taxon_Id"); + + b.ToTable("TaxonName", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonPopularName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Preferred") + .HasColumnType("bit"); + + b.Property("SourceId") + .HasColumnType("int"); + + b.Property("TaxonId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonPopularName"); + + b.HasIndex(new[] { "TaxonId" }, "IX_Taxon_Id"); + + b.ToTable("TaxonPopularName", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ScientificName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TagGroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TaxonId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonProperty"); + + b.HasIndex(new[] { "TaxonId" }, "IX_Taxon_Id"); + + b.ToTable("TaxonProperty", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonRank", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("nvarchar(25)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonRank"); + + b.ToTable("TaxonRank", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonomyState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastEventProcessedTimeStamp") + .HasColumnType("datetime"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id") + .HasName("PK_dbo.TaxonomyState"); + + b.ToTable("TaxonomyState", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LocationArea", b => + { + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.HasKey("LocationId", "AreaId") + .HasName("PK_dbo.LocationAreas"); + + b.HasIndex(new[] { "AreaId" }, "IX_AreaId"); + + b.HasIndex(new[] { "AreaId", "LocationId" }, "IX_AreaId_LocationId"); + + b.HasIndex(new[] { "LocationId" }, "IX_LocationId"); + + b.HasIndex(new[] { "LocationId", "AreaId" }, "IX_LocationIdArea"); + + b.ToTable("LocationAreas", (string)null); + }); + + modelBuilder.Entity("ObservationBehavior", b => + { + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("BehaviorId") + .HasColumnType("int"); + + b.HasKey("ObservationId", "BehaviorId") + .HasName("PK_dbo.ObservationBehaviors"); + + b.HasIndex(new[] { "BehaviorId" }, "IX_BehaviorId"); + + b.ToTable("ObservationBehaviors", (string)null); + }); + + modelBuilder.Entity("ObservationTag", b => + { + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("TagId") + .HasColumnType("int"); + + b.HasKey("ObservationId", "TagId") + .HasName("PK_dbo.ObservationTags"); + + b.HasIndex(new[] { "ObservationId" }, "IX_ObservationId"); + + b.HasIndex(new[] { "TagId" }, "IX_TagId"); + + b.HasIndex(new[] { "TagId", "ObservationId" }, "IX_TagIdObsId"); + + b.ToTable("ObservationTags", (string)null); + }); + + modelBuilder.Entity("ObservationTaxon", b => + { + b.Property("ObservationId") + .HasColumnType("int"); + + b.Property("TaxonId") + .HasColumnType("int"); + + b.HasKey("ObservationId", "TaxonId") + .HasName("PK_dbo.ObservationTaxons"); + + b.HasIndex(new[] { "TaxonId", "ObservationId" }, "IX_TaxonId"); + + b.ToTable("ObservationTaxons", (string)null); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Area", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.AreaType", "AreaType") + .WithMany("Areas") + .HasForeignKey("AreaTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Area_dbo.AreaType_AreaTypeID"); + + b.Navigation("AreaType"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Category", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.CategoryType", "CategoryType") + .WithMany("Categories") + .HasForeignKey("CategoryTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Category_dbo.CategoryType_CategoryTypeId"); + + b.Navigation("CategoryType"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.MediaFile", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.MediaFileType", "MediaFileType") + .WithMany("MediaFiles") + .HasForeignKey("MediaFileTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.MediaFile_dbo.MediaFileType_MediaFileTypeId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "Observation") + .WithMany("MediaFiles") + .HasForeignKey("ObservationId") + .HasConstraintName("FK_dbo.MediaFile_dbo.Observation_Observation_Id"); + + b.Navigation("MediaFileType"); + + b.Navigation("Observation"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Observation", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.BasisOfRecord", "BasisOfRecord") + .WithMany("Observations") + .HasForeignKey("BasisOfRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Observation_dbo.BasisOfRecord_BasisOfRecordId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Category", "Category") + .WithMany("Observations") + .HasForeignKey("CategoryId") + .HasConstraintName("FK_dbo.Observation_dbo.Category_CategoryId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Location", "Location") + .WithMany("Observations") + .HasForeignKey("LocationId") + .HasConstraintName("FK_dbo.Observation_dbo.Location_LocationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.TaxonName", "MatchedScientificName") + .WithMany("Observations") + .HasForeignKey("MatchedScientificNameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Observation_dbo.TaxonName_MatchedScientificNameId"); + + b.HasOne("Artskart3.Core.Domain.Entities.ObservationQualityType", "ObservationQualityType") + .WithMany("Observations") + .HasForeignKey("ObservationQualityTypeId") + .HasConstraintName("FK_dbo.Observation_dbo.ObservationQualityType_ObservationQualityTypeId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Taxon", "Taxon") + .WithMany("Observations") + .HasForeignKey("TaxonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Observation_dbo.Taxon_TaxonId"); + + b.Navigation("BasisOfRecord"); + + b.Navigation("Category"); + + b.Navigation("Location"); + + b.Navigation("MatchedScientificName"); + + b.Navigation("ObservationQualityType"); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationDetail", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "IdNavigation") + .WithOne("ObservationDetail") + .HasForeignKey("Artskart3.Core.Domain.Entities.ObservationDetail", "Id") + .IsRequired() + .HasConstraintName("FK_dbo.ObservationDetails_dbo.Observation_Id"); + + b.Navigation("IdNavigation"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationError", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "Observation") + .WithMany("ObservationErrors") + .HasForeignKey("ObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationError_dbo.Observation_ObservationId"); + + b.Navigation("Observation"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationLink", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "LinkedObservation") + .WithMany("ObservationLinkLinkedObservations") + .HasForeignKey("LinkedObservationId") + .HasConstraintName("FK_dbo.ObservationLink_dbo.Observation_LinkedObservationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "Observation") + .WithMany("ObservationLinkObservations") + .HasForeignKey("ObservationId") + .IsRequired() + .HasConstraintName("FK_dbo.ObservationLink_dbo.Observation_ObservationId"); + + b.Navigation("LinkedObservation"); + + b.Navigation("Observation"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Organization", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.OrganizationType", "OrganizationType") + .WithMany("Organizations") + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Organization_dbo.OrganizationType_OrganizationTypeId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Organization", "Parent") + .WithMany("InverseParent") + .HasForeignKey("ParentId") + .HasConstraintName("FK_dbo.Organization_dbo.Organization_ParentId"); + + b.Navigation("OrganizationType"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationRelation", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "Observation") + .WithMany("OrganizationRelations") + .HasForeignKey("ObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.OrganizationRelation_dbo.Observation_ObservationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Organization", "Organization") + .WithMany("OrganizationRelations") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.OrganizationRelation_dbo.Organization_OrganizationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.OrganizationRelationType", "RelationType") + .WithMany("OrganizationRelations") + .HasForeignKey("RelationTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.OrganizationRelation_dbo.OrganizationRelationType_RelationTypeId"); + + b.Navigation("Observation"); + + b.Navigation("Organization"); + + b.Navigation("RelationType"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ProcessRecordResult", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.RejectedRecord", "RejectedRecord") + .WithMany("ProcessRecordResults") + .HasForeignKey("RejectedRecordId") + .HasConstraintName("FK_dbo.ProcessRecordResult_dbo.RejectedRecord_RejectedRecord_Id"); + + b.Navigation("RejectedRecord"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.SensitiveObservationDatum", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", "Observation") + .WithOne("SensitiveObservationDatum") + .HasForeignKey("Artskart3.Core.Domain.Entities.SensitiveObservationDatum", "ObservationId") + .IsRequired() + .HasConstraintName("FK_dbo.SensitiveObservationData_dbo.Observation_ObservationId"); + + b.Navigation("Observation"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Taxon", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.TaxonGroup", "TaxonGroup") + .WithMany("Taxons") + .HasForeignKey("TaxonGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Taxon_dbo.TaxonGroup_TaxonGroupId"); + + b.HasOne("Artskart3.Core.Domain.Entities.TaxonRank", "TaxonRank") + .WithMany("Taxons") + .HasForeignKey("TaxonRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.Taxon_dbo.TaxonRank_TaxonRankId"); + + b.Navigation("TaxonGroup"); + + b.Navigation("TaxonRank"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonName", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Taxon", "Taxon") + .WithMany("TaxonNames") + .HasForeignKey("TaxonId") + .HasConstraintName("FK_dbo.TaxonName_dbo.Taxon_Taxon_Id"); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonPopularName", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Taxon", "Taxon") + .WithMany("TaxonPopularNames") + .HasForeignKey("TaxonId") + .HasConstraintName("FK_dbo.TaxonPopularName_dbo.Taxon_Taxon_Id"); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonProperty", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Taxon", "Taxon") + .WithMany("TaxonProperties") + .HasForeignKey("TaxonId") + .HasConstraintName("FK_dbo.TaxonProperty_dbo.Taxon_Taxon_Id"); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("LocationArea", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.LocationAreas_dbo.Area_AreaId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Location", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.LocationAreas_dbo.Location_LocationId"); + }); + + modelBuilder.Entity("ObservationBehavior", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Behavior", null) + .WithMany() + .HasForeignKey("BehaviorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationBehaviors_dbo.Behavior_BehaviorId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Observation", null) + .WithMany() + .HasForeignKey("ObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationBehaviors_dbo.Observation_ObservationId"); + }); + + modelBuilder.Entity("ObservationTag", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", null) + .WithMany() + .HasForeignKey("ObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationTags_dbo.Observation_ObservationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationTags_dbo.Tag_TagId"); + }); + + modelBuilder.Entity("ObservationTaxon", b => + { + b.HasOne("Artskart3.Core.Domain.Entities.Observation", null) + .WithMany() + .HasForeignKey("ObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_dbo.ObservationTaxons_dbo.Observation_ObservationId"); + + b.HasOne("Artskart3.Core.Domain.Entities.Taxon", null) + .WithMany() + .HasForeignKey("TaxonId") + .IsRequired() + .HasConstraintName("FK_dbo.ObservationTaxons_dbo.Taxon_TaxonId"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.AreaType", b => + { + b.Navigation("Areas"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.BasisOfRecord", b => + { + b.Navigation("Observations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Category", b => + { + b.Navigation("Observations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.CategoryType", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Location", b => + { + b.Navigation("Observations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.MediaFileType", b => + { + b.Navigation("MediaFiles"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Observation", b => + { + b.Navigation("MediaFiles"); + + b.Navigation("ObservationDetail"); + + b.Navigation("ObservationErrors"); + + b.Navigation("ObservationLinkLinkedObservations"); + + b.Navigation("ObservationLinkObservations"); + + b.Navigation("OrganizationRelations"); + + b.Navigation("SensitiveObservationDatum"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationQualityType", b => + { + b.Navigation("Observations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Organization", b => + { + b.Navigation("InverseParent"); + + b.Navigation("OrganizationRelations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationRelationType", b => + { + b.Navigation("OrganizationRelations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.OrganizationType", b => + { + b.Navigation("Organizations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.RejectedRecord", b => + { + b.Navigation("ProcessRecordResults"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Taxon", b => + { + b.Navigation("Observations"); + + b.Navigation("TaxonNames"); + + b.Navigation("TaxonPopularNames"); + + b.Navigation("TaxonProperties"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonGroup", b => + { + b.Navigation("Taxons"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonName", b => + { + b.Navigation("Observations"); + }); + + modelBuilder.Entity("Artskart3.Core.Domain.Entities.TaxonRank", b => + { + b.Navigation("Taxons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.cs b/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.cs new file mode 100644 index 00000000..0c3b0753 --- /dev/null +++ b/Artskart3.Infrastructure/Migrations/20260702113806_AddObservationTaxonHierarchy.cs @@ -0,0 +1,258 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Artskart3.Infrastructure.Migrations; + +/// +public partial class AddObservationTaxonHierarchy : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ObservationTaxonHierarchy", + columns: table => new + { + ObservationId = table.Column(type: "int", nullable: false), + KingdomTaxonId = table.Column(type: "int", nullable: true), + SubkingdomTaxonId = table.Column(type: "int", nullable: true), + PhylumTaxonId = table.Column(type: "int", nullable: true), + SubphylumTaxonId = table.Column(type: "int", nullable: true), + SuperclassTaxonId = table.Column(type: "int", nullable: true), + ClassTaxonId = table.Column(type: "int", nullable: true), + SubclassTaxonId = table.Column(type: "int", nullable: true), + InfraclassTaxonId = table.Column(type: "int", nullable: true), + CohortTaxonId = table.Column(type: "int", nullable: true), + SuperorderTaxonId = table.Column(type: "int", nullable: true), + OrderTaxonId = table.Column(type: "int", nullable: true), + SuborderTaxonId = table.Column(type: "int", nullable: true), + InfraorderTaxonId = table.Column(type: "int", nullable: true), + SuperfamilyTaxonId = table.Column(type: "int", nullable: true), + FamilyTaxonId = table.Column(type: "int", nullable: true), + SubfamilyTaxonId = table.Column(type: "int", nullable: true), + TribeTaxonId = table.Column(type: "int", nullable: true), + SubtribeTaxonId = table.Column(type: "int", nullable: true), + GenusTaxonId = table.Column(type: "int", nullable: true), + SubgenusTaxonId = table.Column(type: "int", nullable: true), + SectionTaxonId = table.Column(type: "int", nullable: true), + SpeciesTaxonId = table.Column(type: "int", nullable: true), + SubspeciesTaxonId = table.Column(type: "int", nullable: true), + VarietyTaxonId = table.Column(type: "int", nullable: true), + FormTaxonId = table.Column(type: "int", nullable: true), + NotSetTaxonId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ObservationTaxonHierarchy", x => x.ObservationId); + }); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Class", + table: "ObservationTaxonHierarchy", + column: "ClassTaxonId", + filter: "[ClassTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Cohort", + table: "ObservationTaxonHierarchy", + column: "CohortTaxonId", + filter: "[CohortTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Family", + table: "ObservationTaxonHierarchy", + column: "FamilyTaxonId", + filter: "[FamilyTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Form", + table: "ObservationTaxonHierarchy", + column: "FormTaxonId", + filter: "[FormTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Genus", + table: "ObservationTaxonHierarchy", + column: "GenusTaxonId", + filter: "[GenusTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Infraclass", + table: "ObservationTaxonHierarchy", + column: "InfraclassTaxonId", + filter: "[InfraclassTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Infraorder", + table: "ObservationTaxonHierarchy", + column: "InfraorderTaxonId", + filter: "[InfraorderTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Kingdom", + table: "ObservationTaxonHierarchy", + column: "KingdomTaxonId", + filter: "[KingdomTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_NotSet", + table: "ObservationTaxonHierarchy", + column: "NotSetTaxonId", + filter: "[NotSetTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Order", + table: "ObservationTaxonHierarchy", + column: "OrderTaxonId", + filter: "[OrderTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Phylum", + table: "ObservationTaxonHierarchy", + column: "PhylumTaxonId", + filter: "[PhylumTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Section", + table: "ObservationTaxonHierarchy", + column: "SectionTaxonId", + filter: "[SectionTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Species", + table: "ObservationTaxonHierarchy", + column: "SpeciesTaxonId", + filter: "[SpeciesTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subclass", + table: "ObservationTaxonHierarchy", + column: "SubclassTaxonId", + filter: "[SubclassTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subfamily", + table: "ObservationTaxonHierarchy", + column: "SubfamilyTaxonId", + filter: "[SubfamilyTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subgenus", + table: "ObservationTaxonHierarchy", + column: "SubgenusTaxonId", + filter: "[SubgenusTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subkingdom", + table: "ObservationTaxonHierarchy", + column: "SubkingdomTaxonId", + filter: "[SubkingdomTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Suborder", + table: "ObservationTaxonHierarchy", + column: "SuborderTaxonId", + filter: "[SuborderTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subphylum", + table: "ObservationTaxonHierarchy", + column: "SubphylumTaxonId", + filter: "[SubphylumTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subspecies", + table: "ObservationTaxonHierarchy", + column: "SubspeciesTaxonId", + filter: "[SubspeciesTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Subtribe", + table: "ObservationTaxonHierarchy", + column: "SubtribeTaxonId", + filter: "[SubtribeTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Superclass", + table: "ObservationTaxonHierarchy", + column: "SuperclassTaxonId", + filter: "[SuperclassTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Superfamily", + table: "ObservationTaxonHierarchy", + column: "SuperfamilyTaxonId", + filter: "[SuperfamilyTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Superorder", + table: "ObservationTaxonHierarchy", + column: "SuperorderTaxonId", + filter: "[SuperorderTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Tribe", + table: "ObservationTaxonHierarchy", + column: "TribeTaxonId", + filter: "[TribeTaxonId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OTH_Variety", + table: "ObservationTaxonHierarchy", + column: "VarietyTaxonId", + filter: "[VarietyTaxonId] IS NOT NULL"); + + // Populer hierarkitabellen fra Taxon.TaxonIdHiarchy + migrationBuilder.Sql(@" +INSERT INTO dbo.ObservationTaxonHierarchy ( + ObservationId, KingdomTaxonId, SubkingdomTaxonId, PhylumTaxonId, SubphylumTaxonId, + SuperclassTaxonId, ClassTaxonId, SubclassTaxonId, InfraclassTaxonId, CohortTaxonId, + SuperorderTaxonId, OrderTaxonId, SuborderTaxonId, InfraorderTaxonId, SuperfamilyTaxonId, + FamilyTaxonId, SubfamilyTaxonId, TribeTaxonId, SubtribeTaxonId, GenusTaxonId, + SubgenusTaxonId, SectionTaxonId, SpeciesTaxonId, SubspeciesTaxonId, VarietyTaxonId, + FormTaxonId, NotSetTaxonId) +SELECT + o.Id, + MAX(CASE WHEN ancestor.TaxonRankId = 1 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 2 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 3 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 4 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 5 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 6 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 7 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 8 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 9 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 10 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 11 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 12 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 13 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 14 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 15 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 16 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 17 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 18 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 19 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 20 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 21 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 22 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 23 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 24 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 25 THEN ancestor.Id END), + MAX(CASE WHEN ancestor.TaxonRankId = 26 THEN ancestor.Id END) +FROM dbo.Observation o +JOIN dbo.Taxon t ON o.TaxonId = t.Id +CROSS APPLY STRING_SPLIT(t.TaxonIdHiarchy, ',') AS s +JOIN dbo.Taxon ancestor ON ancestor.Id = TRY_CAST(s.value AS INT) +WHERE o.IsDeleted = 0 +GROUP BY o.Id; +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ObservationTaxonHierarchy"); + } +} diff --git a/Artskart3.Infrastructure/Migrations/ArtskartDbContextModelSnapshot.cs b/Artskart3.Infrastructure/Migrations/ArtskartDbContextModelSnapshot.cs index b1daf331..6f946950 100644 --- a/Artskart3.Infrastructure/Migrations/ArtskartDbContextModelSnapshot.cs +++ b/Artskart3.Infrastructure/Migrations/ArtskartDbContextModelSnapshot.cs @@ -1499,6 +1499,201 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ObservationQualityType", (string)null); }); + modelBuilder.Entity("Artskart3.Core.Domain.Entities.ObservationTaxonHierarchy", b => + { + b.Property("ObservationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ObservationId")); + + b.Property("ClassTaxonId") + .HasColumnType("int"); + + b.Property("CohortTaxonId") + .HasColumnType("int"); + + b.Property("FamilyTaxonId") + .HasColumnType("int"); + + b.Property("FormTaxonId") + .HasColumnType("int"); + + b.Property("GenusTaxonId") + .HasColumnType("int"); + + b.Property("InfraclassTaxonId") + .HasColumnType("int"); + + b.Property("InfraorderTaxonId") + .HasColumnType("int"); + + b.Property("KingdomTaxonId") + .HasColumnType("int"); + + b.Property("NotSetTaxonId") + .HasColumnType("int"); + + b.Property("OrderTaxonId") + .HasColumnType("int"); + + b.Property("PhylumTaxonId") + .HasColumnType("int"); + + b.Property("SectionTaxonId") + .HasColumnType("int"); + + b.Property("SpeciesTaxonId") + .HasColumnType("int"); + + b.Property("SubclassTaxonId") + .HasColumnType("int"); + + b.Property("SubfamilyTaxonId") + .HasColumnType("int"); + + b.Property("SubgenusTaxonId") + .HasColumnType("int"); + + b.Property("SubkingdomTaxonId") + .HasColumnType("int"); + + b.Property("SuborderTaxonId") + .HasColumnType("int"); + + b.Property("SubphylumTaxonId") + .HasColumnType("int"); + + b.Property("SubspeciesTaxonId") + .HasColumnType("int"); + + b.Property("SubtribeTaxonId") + .HasColumnType("int"); + + b.Property("SuperclassTaxonId") + .HasColumnType("int"); + + b.Property("SuperfamilyTaxonId") + .HasColumnType("int"); + + b.Property("SuperorderTaxonId") + .HasColumnType("int"); + + b.Property("TribeTaxonId") + .HasColumnType("int"); + + b.Property("VarietyTaxonId") + .HasColumnType("int"); + + b.HasKey("ObservationId"); + + b.HasIndex("ClassTaxonId") + .HasDatabaseName("IX_OTH_Class") + .HasFilter("[ClassTaxonId] IS NOT NULL"); + + b.HasIndex("CohortTaxonId") + .HasDatabaseName("IX_OTH_Cohort") + .HasFilter("[CohortTaxonId] IS NOT NULL"); + + b.HasIndex("FamilyTaxonId") + .HasDatabaseName("IX_OTH_Family") + .HasFilter("[FamilyTaxonId] IS NOT NULL"); + + b.HasIndex("FormTaxonId") + .HasDatabaseName("IX_OTH_Form") + .HasFilter("[FormTaxonId] IS NOT NULL"); + + b.HasIndex("GenusTaxonId") + .HasDatabaseName("IX_OTH_Genus") + .HasFilter("[GenusTaxonId] IS NOT NULL"); + + b.HasIndex("InfraclassTaxonId") + .HasDatabaseName("IX_OTH_Infraclass") + .HasFilter("[InfraclassTaxonId] IS NOT NULL"); + + b.HasIndex("InfraorderTaxonId") + .HasDatabaseName("IX_OTH_Infraorder") + .HasFilter("[InfraorderTaxonId] IS NOT NULL"); + + b.HasIndex("KingdomTaxonId") + .HasDatabaseName("IX_OTH_Kingdom") + .HasFilter("[KingdomTaxonId] IS NOT NULL"); + + b.HasIndex("NotSetTaxonId") + .HasDatabaseName("IX_OTH_NotSet") + .HasFilter("[NotSetTaxonId] IS NOT NULL"); + + b.HasIndex("OrderTaxonId") + .HasDatabaseName("IX_OTH_Order") + .HasFilter("[OrderTaxonId] IS NOT NULL"); + + b.HasIndex("PhylumTaxonId") + .HasDatabaseName("IX_OTH_Phylum") + .HasFilter("[PhylumTaxonId] IS NOT NULL"); + + b.HasIndex("SectionTaxonId") + .HasDatabaseName("IX_OTH_Section") + .HasFilter("[SectionTaxonId] IS NOT NULL"); + + b.HasIndex("SpeciesTaxonId") + .HasDatabaseName("IX_OTH_Species") + .HasFilter("[SpeciesTaxonId] IS NOT NULL"); + + b.HasIndex("SubclassTaxonId") + .HasDatabaseName("IX_OTH_Subclass") + .HasFilter("[SubclassTaxonId] IS NOT NULL"); + + b.HasIndex("SubfamilyTaxonId") + .HasDatabaseName("IX_OTH_Subfamily") + .HasFilter("[SubfamilyTaxonId] IS NOT NULL"); + + b.HasIndex("SubgenusTaxonId") + .HasDatabaseName("IX_OTH_Subgenus") + .HasFilter("[SubgenusTaxonId] IS NOT NULL"); + + b.HasIndex("SubkingdomTaxonId") + .HasDatabaseName("IX_OTH_Subkingdom") + .HasFilter("[SubkingdomTaxonId] IS NOT NULL"); + + b.HasIndex("SuborderTaxonId") + .HasDatabaseName("IX_OTH_Suborder") + .HasFilter("[SuborderTaxonId] IS NOT NULL"); + + b.HasIndex("SubphylumTaxonId") + .HasDatabaseName("IX_OTH_Subphylum") + .HasFilter("[SubphylumTaxonId] IS NOT NULL"); + + b.HasIndex("SubspeciesTaxonId") + .HasDatabaseName("IX_OTH_Subspecies") + .HasFilter("[SubspeciesTaxonId] IS NOT NULL"); + + b.HasIndex("SubtribeTaxonId") + .HasDatabaseName("IX_OTH_Subtribe") + .HasFilter("[SubtribeTaxonId] IS NOT NULL"); + + b.HasIndex("SuperclassTaxonId") + .HasDatabaseName("IX_OTH_Superclass") + .HasFilter("[SuperclassTaxonId] IS NOT NULL"); + + b.HasIndex("SuperfamilyTaxonId") + .HasDatabaseName("IX_OTH_Superfamily") + .HasFilter("[SuperfamilyTaxonId] IS NOT NULL"); + + b.HasIndex("SuperorderTaxonId") + .HasDatabaseName("IX_OTH_Superorder") + .HasFilter("[SuperorderTaxonId] IS NOT NULL"); + + b.HasIndex("TribeTaxonId") + .HasDatabaseName("IX_OTH_Tribe") + .HasFilter("[TribeTaxonId] IS NOT NULL"); + + b.HasIndex("VarietyTaxonId") + .HasDatabaseName("IX_OTH_Variety") + .HasFilter("[VarietyTaxonId] IS NOT NULL"); + + b.ToTable("ObservationTaxonHierarchy", (string)null); + }); + modelBuilder.Entity("Artskart3.Core.Domain.Entities.Organization", b => { b.Property("Id") diff --git a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs index 204f0a43..90aebd12 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -23,13 +23,15 @@ public class SearchRepository : ISearchRepository private readonly ILogger _logger; private readonly PaginationOptions _paginationOptions; private readonly IAreaHierarchyService _areaHierarchy; + private readonly ITaxonHierarchyService _taxonHierarchy; - public SearchRepository(IArtsKartDbContext context, ILogger logger, IOptions paginationOptions, IAreaHierarchyService areaHierarchy) + public SearchRepository(IArtsKartDbContext context, ILogger logger, IOptions paginationOptions, IAreaHierarchyService areaHierarchy, ITaxonHierarchyService taxonHierarchy) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _paginationOptions = paginationOptions.Value; _areaHierarchy = areaHierarchy ?? throw new ArgumentNullException(nameof(areaHierarchy)); + _taxonHierarchy = taxonHierarchy ?? throw new ArgumentNullException(nameof(taxonHierarchy)); } /// /// Searches for taxa by name using a three-level matching strategy: @@ -273,6 +275,18 @@ private IQueryable ApplyCommonFilters(IQueryable query query = query.Where(o => taxonGroupIds.Contains(o.TaxonGroupId)); } + if (filter.TaxonIds?.Any() == true) + { + // Hierarkisk filtrering via ObservationTaxonHierarchy — inkluderer alle etterkommere + IQueryable? combinedQuery = null; + foreach (var taxonId in filter.TaxonIds) + { + var subquery = GetObservationIdsByTaxonHierarchy(taxonId); + combinedQuery = combinedQuery == null ? subquery : combinedQuery.Union(subquery); + } + query = query.Where(o => combinedQuery!.Contains(o.Id)); + } + if (filter.CategoryIds?.Any() == true) { var categoryIds = filter.CategoryIds.ToList(); @@ -347,6 +361,47 @@ private IQueryable ApplyCommonFilters(IQueryable query return query; } + /// + /// Returnerer ObservationId-er som matcher et gitt taxonId via ObservationTaxonHierarchy. + /// Slår opp taxonens rang og spør mot riktig kolonne i hierarkitabellen. + /// + private IQueryable GetObservationIdsByTaxonHierarchy(int taxonId) + { + var rankId = _taxonHierarchy.GetTaxonRankId(taxonId); + var h = _context.Set(); + + return rankId switch + { + 1 => h.Where(x => x.KingdomTaxonId == taxonId).Select(x => x.ObservationId), + 2 => h.Where(x => x.SubkingdomTaxonId == taxonId).Select(x => x.ObservationId), + 3 => h.Where(x => x.PhylumTaxonId == taxonId).Select(x => x.ObservationId), + 4 => h.Where(x => x.SubphylumTaxonId == taxonId).Select(x => x.ObservationId), + 5 => h.Where(x => x.SuperclassTaxonId == taxonId).Select(x => x.ObservationId), + 6 => h.Where(x => x.ClassTaxonId == taxonId).Select(x => x.ObservationId), + 7 => h.Where(x => x.SubclassTaxonId == taxonId).Select(x => x.ObservationId), + 8 => h.Where(x => x.InfraclassTaxonId == taxonId).Select(x => x.ObservationId), + 9 => h.Where(x => x.CohortTaxonId == taxonId).Select(x => x.ObservationId), + 10 => h.Where(x => x.SuperorderTaxonId == taxonId).Select(x => x.ObservationId), + 11 => h.Where(x => x.OrderTaxonId == taxonId).Select(x => x.ObservationId), + 12 => h.Where(x => x.SuborderTaxonId == taxonId).Select(x => x.ObservationId), + 13 => h.Where(x => x.InfraorderTaxonId == taxonId).Select(x => x.ObservationId), + 14 => h.Where(x => x.SuperfamilyTaxonId == taxonId).Select(x => x.ObservationId), + 15 => h.Where(x => x.FamilyTaxonId == taxonId).Select(x => x.ObservationId), + 16 => h.Where(x => x.SubfamilyTaxonId == taxonId).Select(x => x.ObservationId), + 17 => h.Where(x => x.TribeTaxonId == taxonId).Select(x => x.ObservationId), + 18 => h.Where(x => x.SubtribeTaxonId == taxonId).Select(x => x.ObservationId), + 19 => h.Where(x => x.GenusTaxonId == taxonId).Select(x => x.ObservationId), + 20 => h.Where(x => x.SubgenusTaxonId == taxonId).Select(x => x.ObservationId), + 21 => h.Where(x => x.SectionTaxonId == taxonId).Select(x => x.ObservationId), + 22 => h.Where(x => x.SpeciesTaxonId == taxonId).Select(x => x.ObservationId), + 23 => h.Where(x => x.SubspeciesTaxonId == taxonId).Select(x => x.ObservationId), + 24 => h.Where(x => x.VarietyTaxonId == taxonId).Select(x => x.ObservationId), + 25 => h.Where(x => x.FormTaxonId == taxonId).Select(x => x.ObservationId), + 26 => h.Where(x => x.NotSetTaxonId == taxonId).Select(x => x.ObservationId), + _ => h.Where(x => x.ObservationId == -1).Select(x => x.ObservationId) // ukjent rang, tomt resultat + }; + } + private IQueryable BuildLocationsQuery(LocationSearchFilterDto filter) { var query = _context.Set().AsNoTracking(); @@ -584,12 +639,12 @@ private async Task> AggregateMunicipalityCountsByCounty( /// /// Beregner filtrerte observasjonsantall per område via ObservationEntityIndex. + /// Når kun TaxonIds er satt, brukes en rask sti som joiner ObservationEntityIndex + /// direkte med ObservationTaxonHierarchy — uten å gå via Observation-tabellen. /// private async Task> ComputeFilteredAreaCounts( List areas, LocationSearchFilterDto filter, CancellationToken cancellationToken) { - var filteredQuery = ApplyCommonFilters(_context.Set().AsNoTracking(), filter); - var entityTypeIds = areas.Select(a => a.AreaTypeId).Distinct().ToArray(); var entityIds = areas .Select(a => _areaHierarchy.FidToEntityId(a.Fid)) @@ -598,16 +653,55 @@ private async Task> AggregateMunicipalityCountsByCounty( .Distinct() .ToArray(); - var counts = await _context.Set() - .Where(idx => entityTypeIds.Contains(idx.EntityTypeId) && entityIds.Contains(idx.EntityId)) + var areaIndexQuery = _context.Set() + .Where(idx => entityTypeIds.Contains(idx.EntityTypeId) && entityIds.Contains(idx.EntityId)); + + // Rask sti: kun TaxonIds — join direkte med hierarkitabellen, skip Observation-tabellen + if (filter.TaxonIds?.Any() == true && !HasNonTaxonObservationFilters(filter)) + { + IQueryable? hierarchyObsIds = null; + foreach (var taxonId in filter.TaxonIds) + { + var sub = GetObservationIdsByTaxonHierarchy(taxonId); + hierarchyObsIds = hierarchyObsIds == null ? sub : hierarchyObsIds.Union(sub); + } + + // TaxonGroupIds filtreres også via hierarkitabellen (allerede dekket av TaxonIds) + var counts = await areaIndexQuery + .Where(idx => hierarchyObsIds!.Contains(idx.ObservationId)) + .GroupBy(idx => new { idx.EntityTypeId, idx.EntityId }) + .Select(g => new { g.Key.EntityTypeId, g.Key.EntityId, Count = g.Count() }) + .ToListAsync(cancellationToken); + + return counts.ToDictionary(x => (x.EntityTypeId, x.EntityId), x => x.Count); + } + + // Vanlig sti: andre filtre krever Observation-tabellen + var filteredQuery = ApplyCommonFilters(_context.Set().AsNoTracking(), filter); + + var fullCounts = await areaIndexQuery .Where(idx => filteredQuery.Select(o => o.Id).Contains(idx.ObservationId)) .GroupBy(idx => new { idx.EntityTypeId, idx.EntityId }) .Select(g => new { g.Key.EntityTypeId, g.Key.EntityId, Count = g.Count() }) .ToListAsync(cancellationToken); - return counts.ToDictionary(x => (x.EntityTypeId, x.EntityId), x => x.Count); + return fullCounts.ToDictionary(x => (x.EntityTypeId, x.EntityId), x => x.Count); } + /// + /// Sjekker om filteret har observasjonsattributtfiltre utover TaxonIds/TaxonGroupIds. + /// + private static bool HasNonTaxonObservationFilters(LocationSearchFilterDto filter) => + filter.CategoryIds?.Length > 0 || + filter.BasisOfRecordIds?.Length > 0 || + filter.OrganizationIds?.Length > 0 || + filter.RestrictedAreaIds?.Length > 0 || + filter.BehaviorIds?.Length > 0 || + filter.CoordinatePrecision?.From != null || + filter.CoordinatePrecision?.To != null || + filter.Period?.From != null || + filter.Period?.To != null; + /// /// Filtrerer områdelisten basert på valgte fylker, kommuner og havområder. /// Alle er områdemarkører på samme nivå og filtreres via Fid-matching. diff --git a/Artskart3.Infrastructure/Services/TaxonHierarchyService.cs b/Artskart3.Infrastructure/Services/TaxonHierarchyService.cs new file mode 100644 index 00000000..d58ee550 --- /dev/null +++ b/Artskart3.Infrastructure/Services/TaxonHierarchyService.cs @@ -0,0 +1,197 @@ +using Artskart3.Core.Application.DTOs; +using Artskart3.Core.Application.Persistence; +using Artskart3.Core.Application.Services.Interfaces; +using Artskart3.Core.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Artskart3.Infrastructure.Services; + +/// +/// Oppslag for taksonhierarki. +/// Laster alle aktive taxoner ved oppstart og oppdaterer periodisk. +/// Brukes av SearchRepository (rangoppslag) og LookupController (trevisning). +/// +public class TaxonHierarchyService : ITaxonHierarchyService, IHostedService, IDisposable +{ + private static readonly TimeSpan ReloadInterval = TimeSpan.FromHours(1); + + private volatile Dictionary _taxonRanks = new(); + private volatile Dictionary _taxons = new(); + private volatile Dictionary> _childrenByParent = new(); + private volatile bool _initialized; + private PeriodicTimer? _timer; + private Task? _backgroundTask; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public TaxonHierarchyService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await LoadAsync(cancellationToken); + _timer = new PeriodicTimer(ReloadInterval); + _backgroundTask = ReloadLoopAsync(); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Dispose(); + if (_backgroundTask != null) + await _backgroundTask; + } + + private async Task ReloadLoopAsync() + { + while (_timer != null && await _timer.WaitForNextTickAsync()) + { + try + { + await LoadAsync(CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "Feil ved periodisk oppdatering av taksonhierarki"); + } + } + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var taxonList = await context.Set() + .Where(t => !t.IsDeleted) + .Select(t => new + { + t.Id, + t.TaxonRankId, + t.ParentTaxonId, + t.TaxonGroupId, + t.ValidScientificName, + t.PreferredPopularName, + t.CumulativeObservationCount, + t.ExistsInCountry + }) + .ToListAsync(cancellationToken); + + var taxonRanks = new Dictionary(taxonList.Count); + var taxons = new Dictionary(taxonList.Count); + var childrenByParent = new Dictionary>(); + + foreach (var t in taxonList) + { + taxonRanks[t.Id] = t.TaxonRankId; + taxons[t.Id] = new TaxonData + { + Id = t.Id, + TaxonRankId = t.TaxonRankId, + ParentTaxonId = t.ParentTaxonId, + TaxonGroupId = t.TaxonGroupId, + ValidScientificName = t.ValidScientificName, + PreferredPopularName = t.PreferredPopularName, + CumulativeObservationCount = t.CumulativeObservationCount, + ExistsInCountry = t.ExistsInCountry + }; + + if (t.ParentTaxonId == 0) continue; + + if (!childrenByParent.TryGetValue(t.ParentTaxonId, out var list)) + { + list = []; + childrenByParent[t.ParentTaxonId] = list; + } + list.Add(t.Id); + } + + _taxonRanks = taxonRanks; + _taxons = taxons; + _childrenByParent = childrenByParent; + _initialized = true; + + _logger.LogInformation( + "Lastet taksonhierarki: {TaxonCount} taxoner", + taxonList.Count); + } + + public int? GetTaxonRankId(int taxonId) + { + EnsureInitialized(); + return _taxonRanks.TryGetValue(taxonId, out var rankId) ? rankId : null; + } + + public List GetChildren(int? parentTaxonId) + { + EnsureInitialized(); + + var parentId = parentTaxonId ?? 0; + List? childIds; + + if (parentId == 0) + { + // Rotnoder: taxoner med ParentTaxonId = 0 + childIds = _taxons.Values + .Where(t => t.ParentTaxonId == 0) + .Select(t => t.Id) + .ToList(); + } + else if (!_childrenByParent.TryGetValue(parentId, out childIds)) + { + return []; + } + + var result = new List(childIds.Count); + foreach (var childId in childIds) + { + if (!_taxons.TryGetValue(childId, out var taxon)) continue; + + // Filtrer: kun taxoner med observasjoner eller som finnes i landet + if (taxon.CumulativeObservationCount is null or 0 && !taxon.ExistsInCountry) continue; + + result.Add(new TaxonTreeNodeDto + { + Id = taxon.Id, + ValidScientificName = taxon.ValidScientificName, + PreferredPopularName = taxon.PreferredPopularName, + TaxonRankId = taxon.TaxonRankId, + TaxonGroupId = taxon.TaxonGroupId, + CumulativeObservationCount = taxon.CumulativeObservationCount, + ExistsInCountry = taxon.ExistsInCountry, + HasChildren = _childrenByParent.ContainsKey(taxon.Id) + }); + } + + return result.OrderBy(t => t.ValidScientificName).ToList(); + } + + private void EnsureInitialized() + { + if (!_initialized) + throw new InvalidOperationException("TaxonHierarchyService er ikke initialisert."); + } + + public void Dispose() + { + _timer?.Dispose(); + } + + private class TaxonData + { + public int Id { get; init; } + public int TaxonRankId { get; init; } + public int ParentTaxonId { get; init; } + public int TaxonGroupId { get; init; } + public string? ValidScientificName { get; init; } + public string? PreferredPopularName { get; init; } + public int? CumulativeObservationCount { get; init; } + public bool ExistsInCountry { get; init; } + } +} diff --git a/Artskart3.Tests.Integration/Fixtures/NotificationsWebApplicationFactory.cs b/Artskart3.Tests.Integration/Fixtures/NotificationsWebApplicationFactory.cs index afec0b5a..079e6017 100644 --- a/Artskart3.Tests.Integration/Fixtures/NotificationsWebApplicationFactory.cs +++ b/Artskart3.Tests.Integration/Fixtures/NotificationsWebApplicationFactory.cs @@ -28,12 +28,22 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Fjern AreaHierarchyService (hosted service) som prøver å koble til DB ved oppstart services.RemoveAll(); services.RemoveAll(); - var hostedDescriptor = services.FirstOrDefault(d => + var areaHostedDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) && d.ImplementationFactory?.Method.ReturnType == typeof(AreaHierarchyService)); - if (hostedDescriptor != null) services.Remove(hostedDescriptor); + if (areaHostedDescriptor != null) services.Remove(areaHostedDescriptor); services.AddSingleton(); + + // Fjern TaxonHierarchyService (hosted service) som prøver å koble til DB ved oppstart + services.RemoveAll(); + services.RemoveAll(); + var taxonHostedDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) && + d.ImplementationFactory?.Method.ReturnType == typeof(TaxonHierarchyService)); + if (taxonHostedDescriptor != null) services.Remove(taxonHostedDescriptor); + + services.AddSingleton(); }); } } diff --git a/Artskart3.Tests.Integration/StubTaxonHierarchyService.cs b/Artskart3.Tests.Integration/StubTaxonHierarchyService.cs new file mode 100644 index 00000000..b0a64aa8 --- /dev/null +++ b/Artskart3.Tests.Integration/StubTaxonHierarchyService.cs @@ -0,0 +1,15 @@ +using Artskart3.Core.Application.DTOs; +using Artskart3.Core.Application.Services.Interfaces; + +namespace Artskart3.Tests.Integration; + +/// +/// Enkel stub som implementerer ITaxonHierarchyService. +/// Returnerer alltid TaxonRankId 22 (species) og tom barneliste. +/// +internal sealed class StubTaxonHierarchyService : ITaxonHierarchyService +{ + public int? GetTaxonRankId(int taxonId) => 22; + + public List GetChildren(int? parentTaxonId) => []; +} diff --git a/Artskart3.Tests.Integration/Tests/LookupEndpointTests.cs b/Artskart3.Tests.Integration/Tests/LookupEndpointTests.cs index b5a40f96..d7bfcd18 100644 --- a/Artskart3.Tests.Integration/Tests/LookupEndpointTests.cs +++ b/Artskart3.Tests.Integration/Tests/LookupEndpointTests.cs @@ -124,4 +124,30 @@ public async Task GetTaxonGroups_Returns200WithJsonArray() var doc = JsonDocument.Parse(json); doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); } + + // ----------------------------------------------------------------------- + // GET /api/Lookup/TaxonTree + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetTaxonTree_WithoutParent_Returns200WithJsonArray() + { + var response = await _client.GetAsync("/api/Lookup/TaxonTree"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + } + + [Fact] + public async Task GetTaxonTree_WithParentTaxonId_Returns200WithJsonArray() + { + var response = await _client.GetAsync("/api/Lookup/TaxonTree?parentTaxonId=1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + } } diff --git a/Artskart3.Tests.Integration/Tests/SearchRepositoryIntegrationTests.cs b/Artskart3.Tests.Integration/Tests/SearchRepositoryIntegrationTests.cs index 15534c7e..4167dcd6 100644 --- a/Artskart3.Tests.Integration/Tests/SearchRepositoryIntegrationTests.cs +++ b/Artskart3.Tests.Integration/Tests/SearchRepositoryIntegrationTests.cs @@ -59,7 +59,7 @@ public async Task InitializeAsync() .Options; _context = new ArtskartDbContext(options); - _repository = new SearchRepository(_context, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + _repository = new SearchRepository(_context, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); await SeedTestDataAsync(); } diff --git a/Artskart3.Tests.Performance/ObservationSearchBenchmarks.cs b/Artskart3.Tests.Performance/ObservationSearchBenchmarks.cs index b0a35084..e1a0228f 100644 --- a/Artskart3.Tests.Performance/ObservationSearchBenchmarks.cs +++ b/Artskart3.Tests.Performance/ObservationSearchBenchmarks.cs @@ -60,7 +60,7 @@ public void Setup() .Options; _dbContext = new ArtskartDbContext(options); - _repository = new SearchRepository(_dbContext, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + _repository = new SearchRepository(_dbContext, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); } [GlobalCleanup] diff --git a/Artskart3.Tests.Performance/SearchServiceBenchmarks.cs b/Artskart3.Tests.Performance/SearchServiceBenchmarks.cs index 661ee0b2..eae2da6d 100644 --- a/Artskart3.Tests.Performance/SearchServiceBenchmarks.cs +++ b/Artskart3.Tests.Performance/SearchServiceBenchmarks.cs @@ -49,7 +49,7 @@ public void Setup() _dbContext = new ArtskartDbContext(options); - var repository = new SearchRepository(_dbContext, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var repository = new SearchRepository(_dbContext, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); _searchService = new SearchService(repository, new MemoryCache(new MemoryCacheOptions())); } diff --git a/Artskart3.Tests.Performance/StubTaxonHierarchyService.cs b/Artskart3.Tests.Performance/StubTaxonHierarchyService.cs new file mode 100644 index 00000000..e5c1dca6 --- /dev/null +++ b/Artskart3.Tests.Performance/StubTaxonHierarchyService.cs @@ -0,0 +1,15 @@ +using Artskart3.Core.Application.DTOs; +using Artskart3.Core.Application.Services.Interfaces; + +namespace Artskart3.Tests.Performance; + +/// +/// Enkel stub som implementerer ITaxonHierarchyService. +/// Returnerer alltid TaxonRankId 22 (species) og tom barneliste. +/// +internal sealed class StubTaxonHierarchyService : ITaxonHierarchyService +{ + public int? GetTaxonRankId(int taxonId) => 22; + + public List GetChildren(int? parentTaxonId) => []; +} diff --git a/Artskart3.Tests.Unit/LookupControllerTests.cs b/Artskart3.Tests.Unit/LookupControllerTests.cs index 401fc5ac..8bbc3020 100644 --- a/Artskart3.Tests.Unit/LookupControllerTests.cs +++ b/Artskart3.Tests.Unit/LookupControllerTests.cs @@ -15,13 +15,13 @@ public class LookupControllerTests public LookupControllerTests() { _serviceMock = new Mock(); - _sut = new LookupController(_serviceMock.Object); + _sut = new LookupController(_serviceMock.Object, new StubTaxonHierarchyService()); } [Fact] public void Constructor_ThrowsArgumentNullException_WhenLookupServiceIsNull() { - var act = () => new LookupController(null!); + var act = () => new LookupController(null!, new StubTaxonHierarchyService()); act.Should().Throw().WithParameterName("lookupService"); } diff --git a/Artskart3.Tests.Unit/SearchRepositoryTests.cs b/Artskart3.Tests.Unit/SearchRepositoryTests.cs index 3ed1aa50..0a08a5c8 100644 --- a/Artskart3.Tests.Unit/SearchRepositoryTests.cs +++ b/Artskart3.Tests.Unit/SearchRepositoryTests.cs @@ -18,7 +18,7 @@ public class SearchRepositoryTests public async Task GetTaxonsAsync_WithNullName_ReturnsEmpty() { var contextMock = new Mock(); - var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); var result = await sut.GetTaxonsAsync(null!); @@ -30,7 +30,7 @@ public async Task GetTaxonsAsync_WithNullName_ReturnsEmpty() public async Task GetTaxonsAsync_WithWhitespaceName_ReturnsEmpty() { var contextMock = new Mock(); - var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); var result = await sut.GetTaxonsAsync(" "); @@ -42,7 +42,7 @@ public async Task GetTaxonsAsync_WithWhitespaceName_ReturnsEmpty() public async Task GetTaxonsAsync_WithMaxCountZero_ThrowsArgumentException() { var contextMock = new Mock(); - var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); var act = () => sut.GetTaxonsAsync("fugl", 0); @@ -54,7 +54,7 @@ await act.Should().ThrowAsync() public async Task GetTaxonsAsync_WithMaxCountTooHigh_ThrowsArgumentException() { var contextMock = new Mock(); - var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); var act = () => sut.GetTaxonsAsync("fugl", 1001); @@ -66,7 +66,7 @@ await act.Should().ThrowAsync() public async Task GetTaxonsAsync_WithNegativeMaxCount_ThrowsArgumentException() { var contextMock = new Mock(); - var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + var sut = new SearchRepository(contextMock.Object, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); var act = () => sut.GetTaxonsAsync("fugl", -1); @@ -419,7 +419,7 @@ private static ArtskartDbContext CreateInMemoryContext() } private static SearchRepository CreateRepository(ArtskartDbContext context) => - new(context, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService()); + new(context, NullLogger.Instance, Options.Create(new PaginationOptions()), new StubAreaHierarchyService(), new StubTaxonHierarchyService()); private static void SeedLocations(ArtskartDbContext context, params Location[] locations) => context.Set().AddRange(locations); diff --git a/Artskart3.Tests.Unit/StubTaxonHierarchyService.cs b/Artskart3.Tests.Unit/StubTaxonHierarchyService.cs new file mode 100644 index 00000000..7b5311b8 --- /dev/null +++ b/Artskart3.Tests.Unit/StubTaxonHierarchyService.cs @@ -0,0 +1,15 @@ +using Artskart3.Core.Application.DTOs; +using Artskart3.Core.Application.Services.Interfaces; + +namespace Artskart3.Tests.Unit; + +/// +/// Enkel stub som implementerer ITaxonHierarchyService. +/// Returnerer alltid TaxonRankId 22 (species) og tom barneliste. +/// +internal sealed class StubTaxonHierarchyService : ITaxonHierarchyService +{ + public int? GetTaxonRankId(int taxonId) => 22; + + public List GetChildren(int? parentTaxonId) => []; +} diff --git a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts index ca3bc02d..ca15efef 100644 --- a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts +++ b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts @@ -11,10 +11,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { - AreaMarkerDto, - AreaMarkerFeature, -} from '@shared/models/area/area-marker.model'; +import { AreaMarkerDto, AreaMarkerFeature } from '@shared/models/area/area-marker.model'; import { AbbreviateNumberHelper } from '@shared/helpers/number/abbreviate-number.helper'; import { ZoomConfig } from '@shared/helpers/zoom/zoom-config'; import { ApiClientService } from '../api-client.service'; @@ -31,7 +28,7 @@ const NBIC_LOCATION_STYLE = { fillColor: '#005A71', strokeColor: '#D2DDE0', strokeWidth: 2, - } + }, }; interface ParsedGeometry { @@ -44,10 +41,10 @@ interface ParsedGeometry { * @example "1 2, 3 4, 5 6, 1 2" => [[1, 2], [3, 4], [5, 6], [1, 2]] */ function parseRing(ringStr: string): number[][] | null { - const points = ringStr.split(',').map(p => p.trim()); + const points = ringStr.split(',').map((p) => p.trim()); const coordinates = points - .map(point => { - const nums = point.split(/\s+/).map(n => parseFloat(n)); + .map((point) => { + const nums = point.split(/\s+/).map((n) => parseFloat(n)); if (nums.length !== 2 || isNaN(nums[0]) || isNaN(nums[1])) { return null; } @@ -117,21 +114,37 @@ function clipRingToExtent(ring: number[][], extent: [number, number, number, num let output = ring; const edges: { inside: (p: number[]) => boolean; intersect: (a: number[], b: number[]) => number[] }[] = [ - { // Venstre (x >= minX) + { + // Venstre (x >= minX) inside: (p) => p[0] >= extent[0], - intersect: (a, b) => { const t = (extent[0] - a[0]) / (b[0] - a[0]); return [extent[0], a[1] + t * (b[1] - a[1])]; } + intersect: (a, b) => { + const t = (extent[0] - a[0]) / (b[0] - a[0]); + return [extent[0], a[1] + t * (b[1] - a[1])]; + }, }, - { // Høyre (x <= maxX) + { + // Høyre (x <= maxX) inside: (p) => p[0] <= extent[2], - intersect: (a, b) => { const t = (extent[2] - a[0]) / (b[0] - a[0]); return [extent[2], a[1] + t * (b[1] - a[1])]; } + intersect: (a, b) => { + const t = (extent[2] - a[0]) / (b[0] - a[0]); + return [extent[2], a[1] + t * (b[1] - a[1])]; + }, }, - { // Bunn (y >= minY) + { + // Bunn (y >= minY) inside: (p) => p[1] >= extent[1], - intersect: (a, b) => { const t = (extent[1] - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), extent[1]]; } + intersect: (a, b) => { + const t = (extent[1] - a[1]) / (b[1] - a[1]); + return [a[0] + t * (b[0] - a[0]), extent[1]]; + }, }, - { // Topp (y <= maxY) + { + // Topp (y <= maxY) inside: (p) => p[1] <= extent[3], - intersect: (a, b) => { const t = (extent[3] - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), extent[3]]; } + intersect: (a, b) => { + const t = (extent[3] - a[1]) / (b[1] - a[1]); + return [a[0] + t * (b[0] - a[0]), extent[3]]; + }, }, ]; @@ -162,7 +175,9 @@ function clipRingToExtent(ring: number[][], extent: [number, number, number, num */ function ringAreaAndCentroid(ring: number[][]): { area: number; cx: number; cy: number } | null { if (ring.length < 3) return null; - let area = 0, cx = 0, cy = 0; + let area = 0, + cx = 0, + cy = 0; for (let i = 0; i < ring.length; i++) { const j = (i + 1) % ring.length; const cross = ring[i][0] * ring[j][1] - ring[j][0] * ring[i][1]; @@ -183,8 +198,7 @@ function pointInRing(point: [number, number], ring: number[][]): boolean { for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i]; const [xj, yj] = ring[j]; - if ((yi > point[1]) !== (yj > point[1]) && - point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi) { + if (yi > point[1] !== yj > point[1] && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) { inside = !inside; } } @@ -235,9 +249,7 @@ function ensureInsideRing(point: [number, number], ring: number[][]): [number, n * en av de klipte polygondelene. */ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, number, number, number]): [number, number] | null { - const polygons = parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][]) - : [parsed.coordinates as number[][][]]; + const polygons = parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][]) : [parsed.coordinates as number[][][]]; const clippedParts: { ring: number[][]; area: number; cx: number; cy: number }[] = []; @@ -251,7 +263,9 @@ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, numbe if (clippedParts.length === 0) return null; // Arealvektet centroid - let totalArea = 0, weightedX = 0, weightedY = 0; + let totalArea = 0, + weightedX = 0, + weightedY = 0; for (const part of clippedParts) { totalArea += part.area; weightedX += part.cx * part.area; @@ -265,7 +279,7 @@ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, numbe } // Ellers: bruk centroid av den største synlige delen, sikret innenfor - const largest = clippedParts.reduce((a, b) => a.area > b.area ? a : b); + const largest = clippedParts.reduce((a, b) => (a.area > b.area ? a : b)); return ensureInsideRing([largest.cx, largest.cy], largest.ring); } @@ -275,6 +289,7 @@ export interface LocationSearchFilter { behaviorIds?: number[]; basisOfRecordIds?: number[]; taxonGroupIds?: number[]; + taxonIds?: number[]; countyIds?: string[]; municipalityIds?: string[]; restrictedAreaIds?: string[]; @@ -286,7 +301,7 @@ export interface LocationSearchFilter { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class AreasService { private static readonly SERVICE_NAME = 'AreasService'; @@ -314,11 +329,14 @@ export class AreasService { return this.apiClientService .postJson(`${this.areasBaseEndpoint}?zoomLevel=${apiZoomLevel}`, body, { responseType: 'text' }) .pipe( - map(responseText => { + map((responseText) => { const areas = this.apiClientService.parseJsonResponse(responseText, AreasService.SERVICE_NAME); - this.loggerService.info(`Retrieved ${Array.isArray(areas) ? areas.length : 0} areas for zoom level ${apiZoomLevel}`, AreasService.SERVICE_NAME); + this.loggerService.info( + `Retrieved ${Array.isArray(areas) ? areas.length : 0} areas for zoom level ${apiZoomLevel}`, + AreasService.SERVICE_NAME, + ); return Array.isArray(areas) ? areas : []; - }) + }), ); } @@ -337,7 +355,7 @@ export class AreasService { const features = this.mapLocationsToGeoJson(parsed); this.loggerService.info(`Retrieved ${features.length} location features`, AreasService.SERVICE_NAME); return JSON.stringify({ type: 'FeatureCollection', features }); - }) + }), ); } @@ -350,6 +368,7 @@ export class AreasService { if (filter.behaviorIds?.length) body['behaviorIds'] = filter.behaviorIds; if (filter.basisOfRecordIds?.length) body['basisOfRecordIds'] = filter.basisOfRecordIds; if (filter.taxonGroupIds?.length) body['taxonGroupIds'] = filter.taxonGroupIds; + if (filter.taxonIds?.length) body['taxonIds'] = filter.taxonIds; if (filter.countyIds?.length) body['countyIds'] = filter.countyIds; if (filter.municipalityIds?.length) body['municipalityIds'] = filter.municipalityIds; if (filter.restrictedAreaIds?.length) body['restrictedAreaIds'] = filter.restrictedAreaIds; @@ -379,9 +398,7 @@ export class AreasService { return []; } - return locations - .map(location => this.createLocationFeature(location)) - .filter((f): f is AreaMarkerFeature => f !== null); + return locations.map((location) => this.createLocationFeature(location)).filter((f): f is AreaMarkerFeature => f !== null); } private normalizeLocationResponse(response: unknown): Record[] { @@ -421,8 +438,8 @@ export class AreasService { observationCountDisplay: observationCount ? AbbreviateNumberHelper.format(observationCount) : '', isPolygon: false, ...(taxonId && { taxonId }), - ...NBIC_LOCATION_STYLE - } + ...NBIC_LOCATION_STYLE, + }, }; } catch { return null; @@ -466,26 +483,22 @@ export class AreasService { const formattedCount = count > 0 ? AbbreviateNumberHelper.format(count) : ''; // Bruk DB-centroid når hele området er synlig, ellers beregn centroid av synlig del - const fullyVisible = bbox[0] >= extent[0] && bbox[1] >= extent[1] - && bbox[2] <= extent[2] && bbox[3] <= extent[3]; + const fullyVisible = bbox[0] >= extent[0] && bbox[1] >= extent[1] && bbox[2] <= extent[2] && bbox[3] <= extent[3]; let centroid: [number, number]; if (fullyVisible) { centroid = area.centroid ? [area.centroid.x, area.centroid.y] : this.calculateCentroid( - parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][])[0][0] - : (parsed.coordinates as number[][][])[0] + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][])[0][0] : (parsed.coordinates as number[][][])[0], ); } else { - centroid = calculateClippedCentroid(parsed, extent) - ?? (area.centroid + centroid = + calculateClippedCentroid(parsed, extent) ?? + (area.centroid ? [area.centroid.x, area.centroid.y] : this.calculateCentroid( - parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][])[0][0] - : (parsed.coordinates as number[][][])[0] + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][])[0][0] : (parsed.coordinates as number[][][])[0], )); } @@ -502,8 +515,8 @@ export class AreasService { strokeColor: 'rgba(10, 109, 188, 0.6)', strokeWidth: 1.5, fillColor: 'rgba(0, 0, 0, 0)', - } - } + }, + }, }); // Centroid marker feature with circle + count label @@ -525,9 +538,9 @@ export class AreasService { label: formattedCount, font: 'bold 10px Arial', fillColor: '#FFFFFF', - } - } - } + }, + }, + }, }); } @@ -537,7 +550,8 @@ export class AreasService { private calculateCentroid(ring: number[][]): [number, number] { if (!ring || ring.length === 0) return [0, 0]; - let x = 0, y = 0; + let x = 0, + y = 0; for (const coord of ring) { x += coord[0]; y += coord[1]; @@ -546,10 +560,12 @@ export class AreasService { } private computeBbox(parsed: ParsedGeometry): [number, number, number, number] { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - const rings = parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][]).flatMap(p => p) - : (parsed.coordinates as number[][][]); + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const rings = + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][]).flatMap((p) => p) : (parsed.coordinates as number[][][]); for (const ring of rings) { for (const [x, y] of ring) { if (x < minX) minX = x; diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css new file mode 100644 index 00000000..3ef73c7f --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css @@ -0,0 +1,22 @@ +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: var(--adb-spacing-3xsm, 4px); + padding: 0 var(--adb-spacing-sm, 12px); +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: var(--adb-spacing-3xsm, 4px); + padding: var(--adb-spacing-3xsm, 4px) var(--adb-spacing-xsm, 8px); + border: 2px solid var(--adb-border-accent, #005a71); + border-radius: var(--adb-border-radius); + font-size: var(--adb-font-size-sm, 0.875rem); + color: var(--adb-text-default, inherit); + white-space: nowrap; +} + +.filter-chip-label { + font-weight: 600; +} diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html new file mode 100644 index 00000000..e7d41360 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html @@ -0,0 +1,14 @@ +@if (chips().length > 0) { +
+ @for (chip of chips(); track chip.label) { + + {{ chip.text }} + + + + + + + } +
+} diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts new file mode 100644 index 00000000..b09a7be0 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts @@ -0,0 +1,143 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { FilterChipsComponent } from './filter-chips.component'; +import { FilterStateService } from '../../services/filter-state/filter-state.service'; + +describe('FilterChipsComponent', () => { + let component: FilterChipsComponent; + let fixture: ComponentFixture; + let filterState: FilterStateService; + let translate: TranslateService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterChipsComponent, TranslateModule.forRoot()], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + + fixture = TestBed.createComponent(FilterChipsComponent); + component = fixture.componentInstance; + filterState = TestBed.inject(FilterStateService); + translate = TestBed.inject(TranslateService); + translate.setDefaultLang('no'); + translate.use('no'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show no chips when no filters are active', () => { + expect(component.chips().length).toBe(0); + }); + + it('should show a chip when taxon groups are selected', () => { + filterState.addTaxonGroup(1); + filterState.addTaxonGroup(2); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + expect(taxonChip).toBeTruthy(); + }); + + it('should show a chip when categories are selected', () => { + filterState.addCategory(10); + const chips = component.chips(); + expect(chips.length).toBeGreaterThan(0); + expect(chips.some((c) => c.text.includes('(1)'))).toBe(true); + }); + + it('should show a chip for areas (municipalities + ocean areas)', () => { + filterState.addMunicipality('0301'); + filterState.addMunicipality('0302'); + filterState.toggleOceanArea('ocean1'); + const chips = component.chips(); + const areaChip = chips.find((c) => c.text.includes('(3)')); + expect(areaChip).toBeTruthy(); + }); + + it('should show a chip when taxons (species) are selected', () => { + filterState.addTaxon(1234); + const chips = component.chips(); + expect(chips.some((c) => c.text.includes('(1)'))).toBe(true); + }); + + it('should show a chip for coordinate precision', () => { + filterState.setCoordinatePrecision(0, 500); + const chips = component.chips(); + const precChip = chips.find((c) => c.label === 'sidebar.coordinatePrecision'); + expect(precChip).toBeTruthy(); + }); + + it('should show a chip for period', () => { + filterState.setPeriod(1990, 2020); + const chips = component.chips(); + const periodChip = chips.find((c) => c.label === 'sidebar.period'); + expect(periodChip).toBeTruthy(); + }); + + it('should clear taxon groups when chip close is called', () => { + filterState.addTaxonGroup(1); + filterState.addTaxonGroup(2); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + taxonChip!.clear(); + expect(filterState.selectedTaxonGroupIds().length).toBe(0); + }); + + it('should clear categories when chip close is called', () => { + filterState.addCategory(10); + filterState.addCategory(11); + const chips = component.chips(); + const catChip = chips.find((c) => c.text.includes('(2)')); + catChip!.clear(); + expect(filterState.selectedCategoryIds().length).toBe(0); + }); + + it('should clear coordinate precision when chip close is called', () => { + filterState.setCoordinatePrecision(0, 500); + const chips = component.chips(); + const precChip = chips.find((c) => c.label === 'sidebar.coordinatePrecision'); + precChip!.clear(); + expect(filterState.coordinatePrecisionFrom()).toBeNull(); + expect(filterState.coordinatePrecisionTo()).toBeNull(); + }); + + it('should clear period when chip close is called', () => { + filterState.setPeriod(1990, 2020); + const chips = component.chips(); + const periodChip = chips.find((c) => c.label === 'sidebar.period'); + periodChip!.clear(); + expect(filterState.periodFrom()).toBeNull(); + expect(filterState.periodTo()).toBeNull(); + }); + + it('should clear taxons when chip close is called', () => { + filterState.addTaxon(1234); + filterState.addTaxon(5678); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + taxonChip!.clear(); + expect(filterState.selectedTaxonIds().length).toBe(0); + }); + + it('should update chip text when language changes', () => { + filterState.addTaxonGroup(1); + component.chips(); // trigger initial evaluation + + translate.use('en'); + fixture.detectChanges(); + + const chipsAfter = component.chips(); + // Text should change (different translation) + // With TranslateModule.forRoot() and no translations loaded, + // keys are returned as-is, but the signal should still re-evaluate + expect(chipsAfter.length).toBe(1); + // The computed should have been triggered (currentLang changed) + expect(chipsAfter[0]).toBeTruthy(); + }); +}); diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts new file mode 100644 index 00000000..d1e94ee1 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts @@ -0,0 +1,90 @@ +import { Component, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { FilterStateService } from '../../services/filter-state/filter-state.service'; + +export interface FilterChip { + label: string; + text: string; + clear: () => void; +} + +@Component({ + selector: 'app-filter-chips', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './filter-chips.component.html', + styleUrl: './filter-chips.component.css', +}) +export class FilterChipsComponent { + private readonly filterState = inject(FilterStateService); + private readonly translate = inject(TranslateService); + private readonly currentLang = signal(this.translate.currentLang || this.translate.defaultLang); + + constructor() { + this.translate.onLangChange.pipe(takeUntilDestroyed()).subscribe((event) => { + this.currentLang.set(event.lang); + }); + } + + readonly chips = computed((): FilterChip[] => { + this.currentLang(); + const chips: FilterChip[] = []; + const taxonGroups = this.filterState.selectedTaxonGroupIds(); + if (taxonGroups.length > 0) { + const label = this.translate.instant('sidebar.taxonGroups'); + chips.push({ label, text: `${label} (${taxonGroups.length})`, clear: () => this.filterState.clearTaxonGroups() }); + } + const categories = this.filterState.selectedCategoryIds(); + if (categories.length > 0) { + const label = this.translate.instant('sidebar.categories'); + chips.push({ label, text: `${label} (${categories.length})`, clear: () => this.filterState.clearCategories() }); + } + const municipalities = this.filterState.selectedMunicipalityIds(); + const oceanAreas = this.filterState.selectedOceanAreaIds(); + const areaCount = municipalities.length + oceanAreas.length; + if (areaCount > 0) { + const label = this.translate.instant('sidebar.areas'); + chips.push({ label, text: `${label} (${areaCount})`, clear: () => this.filterState.clearAreas() }); + } + const institutions = this.filterState.selectedInstitutionIds(); + if (institutions.length > 0) { + const label = this.translate.instant('sidebar.institutions'); + chips.push({ label, text: `${label} (${institutions.length})`, clear: () => this.filterState.clearInstitutions() }); + } + const behaviors = this.filterState.selectedBehaviorIds(); + if (behaviors.length > 0) { + const label = this.translate.instant('sidebar.behaviors'); + chips.push({ label, text: `${label} (${behaviors.length})`, clear: () => this.filterState.clearBehaviors() }); + } + const basisOfRecords = this.filterState.selectedBasisOfRecordIds(); + if (basisOfRecords.length > 0) { + const label = this.translate.instant('sidebar.basisOfRecords'); + chips.push({ label, text: `${label} (${basisOfRecords.length})`, clear: () => this.filterState.clearBasisOfRecords() }); + } + const taxons = this.filterState.selectedTaxonIds(); + if (taxons.length > 0) { + const label = this.translate.instant('sidebar.species'); + chips.push({ label, text: `${label} (${taxons.length})`, clear: () => this.filterState.clearTaxons() }); + } + const precFrom = this.filterState.coordinatePrecisionFrom(); + const precTo = this.filterState.coordinatePrecisionTo(); + if (precFrom != null || precTo != null) { + const label = this.translate.instant('sidebar.coordinatePrecision'); + const from = precFrom ?? 0; + const to = precTo != null ? String(precTo) : '∞'; + const text = this.translate.instant('sidebar.chipCoordinatePrecision', { from, to }); + chips.push({ label, text, clear: () => this.filterState.clearCoordinatePrecision() }); + } + const periodFrom = this.filterState.periodFrom(); + const periodTo = this.filterState.periodTo(); + if (periodFrom != null || periodTo != null) { + const label = this.translate.instant('sidebar.period'); + const from = periodFrom != null ? String(periodFrom) : '...'; + const to = periodTo != null ? String(periodTo) : '...'; + const text = this.translate.instant('sidebar.chipPeriod', { from, to }); + chips.push({ label, text, clear: () => this.filterState.clearPeriod() }); + } + return chips; + }); +} diff --git a/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts b/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts index 9cf2daf0..40946f02 100644 --- a/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts @@ -109,6 +109,7 @@ export class ListViewComponent { behaviorIds: this.filterState.selectedBehaviorIds().length ? this.filterState.selectedBehaviorIds() : undefined, basisOfRecordIds: this.filterState.selectedBasisOfRecordIds().length ? this.filterState.selectedBasisOfRecordIds() : undefined, taxonGroupIds: this.filterState.selectedTaxonGroupIds().length ? this.filterState.selectedTaxonGroupIds() : undefined, + taxonIds: this.filterState.selectedTaxonIds().length ? this.filterState.selectedTaxonIds() : undefined, countyIds: countyIds.length ? countyIds : undefined, municipalityIds: municipalityIds.length ? municipalityIds : undefined, oceanAreaIds: this.filterState.selectedOceanAreaIds().length ? this.filterState.selectedOceanAreaIds() : undefined, diff --git a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts index 078bdb1c..fafe1aa4 100644 --- a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts @@ -1,21 +1,5 @@ -import { - createMap, - MapEvents, - NbicMapComponent, - nbicMapPresets, -} from '@artsdatabanken/nbic-map-component'; -import { - AfterViewInit, - Component, - ElementRef, - Output, - EventEmitter, - ViewChild, - OnDestroy, - inject, - computed, - effect, -} from '@angular/core'; +import { createMap, MapEvents, NbicMapComponent, nbicMapPresets } from '@artsdatabanken/nbic-map-component'; +import { AfterViewInit, Component, ElementRef, Output, EventEmitter, ViewChild, OnDestroy, inject, computed, effect } from '@angular/core'; import { LoggingService } from '@shared/logging.service'; import { Subject, EMPTY } from 'rxjs'; import { catchError, debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -74,6 +58,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { behaviorIds: this.filterState.selectedBehaviorIds().length ? this.filterState.selectedBehaviorIds() : undefined, basisOfRecordIds: this.filterState.selectedBasisOfRecordIds().length ? this.filterState.selectedBasisOfRecordIds() : undefined, taxonGroupIds: this.filterState.selectedTaxonGroupIds().length ? this.filterState.selectedTaxonGroupIds() : undefined, + taxonIds: this.filterState.selectedTaxonIds().length ? this.filterState.selectedTaxonIds() : undefined, countyIds: countyIds.length ? countyIds : undefined, municipalityIds: municipalityIds.length ? municipalityIds : undefined, oceanAreaIds: this.filterState.selectedOceanAreaIds().length ? this.filterState.selectedOceanAreaIds() : undefined, @@ -218,47 +203,49 @@ export class MapComponent implements AfterViewInit, OnDestroy { } private setupAreaDataPipeline(): void { - this.fetchAreaData$.pipe( - debounceTime(300), - switchMap(({ apiZoomLevel, olZoom, extent }) => { - const isLocationPoints = apiZoomLevel === ApiZoomLevel.LocationPoints; + this.fetchAreaData$ + .pipe( + debounceTime(300), + switchMap(({ apiZoomLevel, olZoom, extent }) => { + const isLocationPoints = apiZoomLevel === ApiZoomLevel.LocationPoints; - // For områdemarkører: bruk cachet data og bygg GeoJSON med gjeldende kartutsnitt - if (!isLocationPoints) { - const cachedAreas = this.areaDataCacheByApiZoom.get(apiZoomLevel); - if (cachedAreas) { - const geojson = this.areasService.buildAreaGeoJson(cachedAreas, extent); - this.applyGeoJsonToLayer(apiZoomLevel, geojson); - return EMPTY; + // For områdemarkører: bruk cachet data og bygg GeoJSON med gjeldende kartutsnitt + if (!isLocationPoints) { + const cachedAreas = this.areaDataCacheByApiZoom.get(apiZoomLevel); + if (cachedAreas) { + const geojson = this.areasService.buildAreaGeoJson(cachedAreas, extent); + this.applyGeoJsonToLayer(apiZoomLevel, geojson); + return EMPTY; + } + + const filter = this.locationFilter(); + return this.areasService.getAreaMarkers(olZoom, filter).pipe( + tap((areas) => { + this.areaDataCacheByApiZoom.set(apiZoomLevel, areas); + const geojson = this.areasService.buildAreaGeoJson(areas, extent); + this.applyGeoJsonToLayer(apiZoomLevel, geojson); + }), + catchError((err: unknown) => { + this.logger.error(`Failed to load area markers for API zoom level ${apiZoomLevel}:`, 'MapComponent', err); + return EMPTY; + }), + ); } const filter = this.locationFilter(); - return this.areasService.getAreaMarkers(olZoom, filter).pipe( - tap(areas => { - this.areaDataCacheByApiZoom.set(apiZoomLevel, areas); - const geojson = this.areasService.buildAreaGeoJson(areas, extent); + return this.areasService.getLocationsAsGeoJsonString(extent, filter).pipe( + tap((geojson) => { this.applyGeoJsonToLayer(apiZoomLevel, geojson); }), catchError((err: unknown) => { - this.logger.error(`Failed to load area markers for API zoom level ${apiZoomLevel}:`, 'MapComponent', err); + this.logger.error(`Failed to load location points:`, 'MapComponent', err); return EMPTY; - }) + }), ); - } - - const filter = this.locationFilter(); - return this.areasService.getLocationsAsGeoJsonString(extent, filter).pipe( - tap(geojson => { - this.applyGeoJsonToLayer(apiZoomLevel, geojson); - }), - catchError((err: unknown) => { - this.logger.error(`Failed to load location points:`, 'MapComponent', err); - return EMPTY; - }) - ); - }), - takeUntil(this.destroy$), - ).subscribe(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); } private applyGeoJsonToLayer(apiZoomLevel: number, geojson: string): void { diff --git a/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts b/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts index 5dd3e3af..16f89824 100644 --- a/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts @@ -1,12 +1,4 @@ -import { - Component, - Input, - Output, - EventEmitter, - HostListener, - signal, - OnInit, -} from '@angular/core'; +import { Component, Input, Output, EventEmitter, HostListener, signal, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; @@ -18,7 +10,7 @@ import { TranslateModule } from '@ngx-translate/core'; styleUrl: './resizable-panel.component.css', }) export class ResizablePanelComponent implements OnInit { - @Input() initialWidth = 300; + @Input() initialWidth = 358; @Input() minWidth = 200; @Input() maxWidth = 500; @Input() isDraggable = true; diff --git a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css index 017c3ac7..edc5efb0 100644 --- a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css +++ b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css @@ -17,10 +17,6 @@ } } -.search-field { - display: none; -} - /* Remove default beige border from all accordions in sidebar */ adb-accordion { --adb-border-default: transparent; diff --git a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html index 29da70bf..9163a485 100644 --- a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html +++ b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html @@ -1,12 +1,12 @@