forked from gongxuegit/tiku-backend.net
feat: add content assets and import persistence
This commit is contained in:
243
Tiku.Domain/Content/AssetEntities.cs
Normal file
243
Tiku.Domain/Content/AssetEntities.cs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
|
||||||
|
namespace Tiku.Domain.Content;
|
||||||
|
|
||||||
|
public sealed class ContentAsset : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid? RegionId { get; set; }
|
||||||
|
public Guid? SubjectId { get; set; }
|
||||||
|
public Guid? CategoryId { get; set; }
|
||||||
|
public Guid? ContentNodeId { get; set; }
|
||||||
|
public Guid? CreatedBy { get; set; }
|
||||||
|
public Guid? UpdatedBy { get; set; }
|
||||||
|
public Guid? VerifiedBy { get; set; }
|
||||||
|
public string? LegacyId { get; set; }
|
||||||
|
public string? AssetKey { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public string? CdnUrl { get; set; }
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
public ContentAssetType AssetType { get; set; } = ContentAssetType.Document;
|
||||||
|
public AssetStorageProvider StorageProvider { get; set; } = AssetStorageProvider.ExternalUrl;
|
||||||
|
public string? Bucket { get; set; }
|
||||||
|
public string? ObjectKey { get; set; }
|
||||||
|
public string? MimeType { get; set; }
|
||||||
|
public long? FileSizeBytes { get; set; }
|
||||||
|
public string? ChecksumSha256 { get; set; }
|
||||||
|
public ContentVisibility Visibility { get; set; } = ContentVisibility.Members;
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
public ContentStatus Status { get; set; } = ContentStatus.Active;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||||
|
public string Source { get; set; } = "manual";
|
||||||
|
public int DownloadCount { get; set; }
|
||||||
|
public AssetUploadStatus UploadStatus { get; set; } = AssetUploadStatus.NotRequired;
|
||||||
|
public DateTimeOffset? VerifiedAt { get; set; }
|
||||||
|
public long? VerifiedSizeBytes { get; set; }
|
||||||
|
public string? VerifiedChecksumSha256 { get; set; }
|
||||||
|
public JsonElement VerificationDetails { get; set; } = JsonDefaults.Object();
|
||||||
|
public string? PreviewObjectKey { get; set; }
|
||||||
|
public AssetPreviewStatus PreviewStatus { get; set; } = AssetPreviewStatus.None;
|
||||||
|
public JsonElement SecurityFlags { get; set; } = JsonDefaults.Object();
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ContentImportJob : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid? CreatedBy { get; set; }
|
||||||
|
public Guid? TargetRegionId { get; set; }
|
||||||
|
public Guid? TargetSubjectId { get; set; }
|
||||||
|
public Guid? TargetCategoryId { get; set; }
|
||||||
|
public Guid? TargetContentNodeId { get; set; }
|
||||||
|
public Guid? TargetQuestionBankId { get; set; }
|
||||||
|
public ContentImportType ImportType { get; set; } = ContentImportType.Questions;
|
||||||
|
public ImportSourceFormat SourceFormat { get; set; } = ImportSourceFormat.Json;
|
||||||
|
public ContentImportStatus Status { get; set; } = ContentImportStatus.Preview;
|
||||||
|
public string? SourceName { get; set; }
|
||||||
|
public string? SourceHash { get; set; }
|
||||||
|
public bool DryRun { get; set; } = true;
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int ValidCount { get; set; }
|
||||||
|
public int ErrorCount { get; set; }
|
||||||
|
public int WarningCount { get; set; }
|
||||||
|
public int InsertedCount { get; set; }
|
||||||
|
public int UpdatedCount { get; set; }
|
||||||
|
public int SkippedCount { get; set; }
|
||||||
|
public JsonElement Summary { get; set; } = JsonDefaults.Object();
|
||||||
|
public JsonElement RawPayload { get; set; } = JsonDefaults.Array();
|
||||||
|
public JsonElement NormalizedPayload { get; set; } = JsonDefaults.Array();
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTimeOffset? StartedAt { get; set; }
|
||||||
|
public DateTimeOffset? FinishedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ContentImportItem : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid JobId { get; set; }
|
||||||
|
public int RowNo { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
public ContentImportItemStatus Status { get; set; } = ContentImportItemStatus.Valid;
|
||||||
|
public string? TargetType { get; set; }
|
||||||
|
public Guid? TargetId { get; set; }
|
||||||
|
public JsonElement SourcePayload { get; set; } = JsonDefaults.Object();
|
||||||
|
public JsonElement NormalizedPayload { get; set; } = JsonDefaults.Object();
|
||||||
|
public string? ContentHash { get; set; }
|
||||||
|
public int IssuesCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ContentImportIssue : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid JobId { get; set; }
|
||||||
|
public Guid? ItemId { get; set; }
|
||||||
|
public int? RowNo { get; set; }
|
||||||
|
public ImportIssueSeverity Severity { get; set; } = ImportIssueSeverity.Error;
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string? FieldPath { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public JsonElement Details { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ImageAsset : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public string? LegacyId { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
public string? CdnUrl { get; set; }
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppAsset : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public string? LegacyId { get; set; }
|
||||||
|
public string AssetKey { get; set; } = string.Empty;
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VideoExplanation : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid? SubjectId { get; set; }
|
||||||
|
public string? LegacyId { get; set; }
|
||||||
|
public string? LegacySubjectId { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? VideoUrl { get; set; }
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
public int? DurationSeconds { get; set; }
|
||||||
|
public JsonElement KnowledgeTags { get; set; } = JsonDefaults.Array();
|
||||||
|
public bool IsGeneral { get; set; }
|
||||||
|
public int? Difficulty { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string? SourceHash { get; set; }
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class QuestionVideo : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public Guid? QuestionId { get; set; }
|
||||||
|
public Guid? VideoId { get; set; }
|
||||||
|
public string? LegacyId { get; set; }
|
||||||
|
public string? LegacyQuestionId { get; set; }
|
||||||
|
public string? LegacyVideoId { get; set; }
|
||||||
|
public QuestionVideoType VideoType { get; set; } = QuestionVideoType.Specific;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContentAssetType
|
||||||
|
{
|
||||||
|
Pdf,
|
||||||
|
Video,
|
||||||
|
Image,
|
||||||
|
Audio,
|
||||||
|
Document,
|
||||||
|
Package,
|
||||||
|
Link,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AssetStorageProvider
|
||||||
|
{
|
||||||
|
ExternalUrl,
|
||||||
|
SupabaseStorage,
|
||||||
|
AliyunOss,
|
||||||
|
TencentCos,
|
||||||
|
QiniuKodo,
|
||||||
|
LocalDev
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AssetUploadStatus
|
||||||
|
{
|
||||||
|
NotRequired,
|
||||||
|
Pending,
|
||||||
|
Verified,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AssetPreviewStatus
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Pending,
|
||||||
|
Ready,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContentImportType
|
||||||
|
{
|
||||||
|
Questions,
|
||||||
|
Vocabulary,
|
||||||
|
Handbook,
|
||||||
|
Scoreline,
|
||||||
|
Assets,
|
||||||
|
Videos
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ImportSourceFormat
|
||||||
|
{
|
||||||
|
Json,
|
||||||
|
Excel,
|
||||||
|
Csv,
|
||||||
|
Pocketbase,
|
||||||
|
Api
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContentImportStatus
|
||||||
|
{
|
||||||
|
Preview,
|
||||||
|
Pending,
|
||||||
|
Importing,
|
||||||
|
Completed,
|
||||||
|
CompletedWithErrors,
|
||||||
|
Failed,
|
||||||
|
Rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContentImportItemStatus
|
||||||
|
{
|
||||||
|
Valid,
|
||||||
|
Invalid,
|
||||||
|
Inserted,
|
||||||
|
Updated,
|
||||||
|
Skipped,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ImportIssueSeverity
|
||||||
|
{
|
||||||
|
Error,
|
||||||
|
Warning
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum QuestionVideoType
|
||||||
|
{
|
||||||
|
Specific,
|
||||||
|
General,
|
||||||
|
Related
|
||||||
|
}
|
||||||
@@ -130,8 +130,8 @@ public sealed class QuestionTypeGroup : AuditableTenantEntity
|
|||||||
|
|
||||||
public sealed class SubjectShare : AuditableTenantEntity
|
public sealed class SubjectShare : AuditableTenantEntity
|
||||||
{
|
{
|
||||||
public Guid? SourceSubjectId { get; set; }
|
public Guid SourceSubjectId { get; set; }
|
||||||
public Guid? TargetSubjectId { get; set; }
|
public Guid TargetSubjectId { get; set; }
|
||||||
public string? LegacyId { get; set; }
|
public string? LegacyId { get; set; }
|
||||||
public string? LegacySourceSubjectId { get; set; }
|
public string? LegacySourceSubjectId { get; set; }
|
||||||
public string? LegacyTargetSubjectId { get; set; }
|
public string? LegacyTargetSubjectId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
internal sealed class ContentAssetConfiguration : IEntityTypeConfiguration<ContentAsset>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ContentAsset> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("content_assets");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.AssetKey).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.Title).HasMaxLength(300);
|
||||||
|
builder.Property(entity => entity.Category).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.FileName).HasMaxLength(500);
|
||||||
|
builder.Property(entity => entity.CdnUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(entity => entity.AssetType).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.StorageProvider).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.Bucket).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.ObjectKey).HasMaxLength(1000);
|
||||||
|
builder.Property(entity => entity.MimeType).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.ChecksumSha256).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.Visibility).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.PreviewUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.AccessRules).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.Source).HasMaxLength(50);
|
||||||
|
builder.Property(entity => entity.UploadStatus).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.VerifiedChecksumSha256).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.VerificationDetails).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.PreviewObjectKey).HasMaxLength(1000);
|
||||||
|
builder.Property(entity => entity.PreviewStatus).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.SecurityFlags).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.Status,
|
||||||
|
entity.Visibility,
|
||||||
|
entity.AssetType,
|
||||||
|
entity.RegionId,
|
||||||
|
entity.SubjectId,
|
||||||
|
entity.CategoryId,
|
||||||
|
entity.SortOrder
|
||||||
|
});
|
||||||
|
builder.HasIndex(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.StorageProvider,
|
||||||
|
entity.Bucket,
|
||||||
|
entity.ObjectKey
|
||||||
|
});
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.ChecksumSha256 })
|
||||||
|
.HasFilter("checksum_sha256 is not null");
|
||||||
|
builder.HasIndex(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.UploadStatus,
|
||||||
|
entity.Status,
|
||||||
|
entity.UpdatedAt
|
||||||
|
});
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.PreviewStatus })
|
||||||
|
.HasFilter("preview_status <> 'none'");
|
||||||
|
builder.ToTable(table =>
|
||||||
|
{
|
||||||
|
table.HasCheckConstraint("ck_content_assets_file_size", "file_size_bytes is null or file_size_bytes >= 0");
|
||||||
|
table.HasCheckConstraint("ck_content_assets_download_count", "download_count >= 0");
|
||||||
|
table.HasCheckConstraint("ck_content_assets_verified_size", "verified_size_bytes is null or verified_size_bytes >= 0");
|
||||||
|
table.HasCheckConstraint(
|
||||||
|
"ck_content_assets_verified_checksum",
|
||||||
|
"verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'");
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.HasOne<Region>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Subject>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Category>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.CategoryId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ContentNode>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ContentNodeId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.CreatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
builder.HasOne<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UpdatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
builder.HasOne<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.VerifiedBy)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ContentImportJobConfiguration : IEntityTypeConfiguration<ContentImportJob>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ContentImportJob> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("content_import_jobs");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.ImportType).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.SourceFormat).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.SourceName).HasMaxLength(500);
|
||||||
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
|
builder.Property(entity => entity.Summary).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.RawPayload).IsJson("[]");
|
||||||
|
builder.Property(entity => entity.NormalizedPayload).IsJson("[]");
|
||||||
|
builder.HasIndex(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.ImportType,
|
||||||
|
entity.Status,
|
||||||
|
entity.CreatedAt
|
||||||
|
});
|
||||||
|
builder.ToTable(table =>
|
||||||
|
{
|
||||||
|
table.HasCheckConstraint("ck_content_import_jobs_counts", "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.HasOne<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.CreatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
builder.HasOne<Region>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.TargetRegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Subject>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.TargetSubjectId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Category>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.TargetCategoryId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ContentNode>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.TargetContentNodeId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<QuestionBank>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.TargetQuestionBankId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ContentImportItemConfiguration : IEntityTypeConfiguration<ContentImportItem>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ContentImportItem> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("content_import_items");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.ExternalId).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.TargetType).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.SourcePayload).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.NormalizedPayload).IsJson("{}");
|
||||||
|
builder.Property(entity => entity.ContentHash).HasMaxLength(128);
|
||||||
|
builder.HasIndex(entity => new { entity.JobId, entity.RowNo }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.Status, entity.RowNo });
|
||||||
|
builder.ToTable(table =>
|
||||||
|
{
|
||||||
|
table.HasCheckConstraint("ck_content_import_items_row_no", "row_no >= 0");
|
||||||
|
table.HasCheckConstraint("ck_content_import_items_issues_count", "issues_count >= 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.HasOne<ContentImportJob>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.JobId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ContentImportIssueConfiguration : IEntityTypeConfiguration<ContentImportIssue>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ContentImportIssue> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("content_import_issues");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.Severity).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.FieldPath).HasMaxLength(500);
|
||||||
|
builder.Property(entity => entity.Message).HasMaxLength(2000);
|
||||||
|
builder.Property(entity => entity.Details).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.Severity, entity.RowNo });
|
||||||
|
|
||||||
|
builder.HasOne<ContentImportJob>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.JobId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne<ContentImportItem>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ItemId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ImageAssetConfiguration : IEntityTypeConfiguration<ImageAsset>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ImageAsset> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("images");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.Title).HasMaxLength(300);
|
||||||
|
builder.Property(entity => entity.Category).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.FileName).HasMaxLength(500);
|
||||||
|
builder.Property(entity => entity.CdnUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.Category, entity.IsPublic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AppAssetConfiguration : IEntityTypeConfiguration<AppAsset>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AppAsset> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("app_assets");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.AssetKey).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.FileName).HasMaxLength(500);
|
||||||
|
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||||
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.AssetKey }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class VideoExplanationConfiguration : IEntityTypeConfiguration<VideoExplanation>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<VideoExplanation> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("video_explanations");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.LegacySubjectId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.Title).HasMaxLength(300);
|
||||||
|
builder.Property(entity => entity.VideoUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(entity => entity.ThumbnailUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(entity => entity.KnowledgeTags).IsJson("[]");
|
||||||
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.SourceHash })
|
||||||
|
.HasFilter("source_hash is not null");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.SubjectId, entity.IsActive, entity.SortOrder });
|
||||||
|
|
||||||
|
builder.HasOne<Subject>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class QuestionVideoConfiguration : IEntityTypeConfiguration<QuestionVideo>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<QuestionVideo> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("question_videos");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.LegacyQuestionId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.LegacyVideoId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.VideoType).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
|
builder.HasIndex(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.QuestionId,
|
||||||
|
entity.VideoId,
|
||||||
|
entity.VideoType
|
||||||
|
}).IsUnique();
|
||||||
|
|
||||||
|
builder.HasOne<Question>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne<VideoExplanation>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.VideoId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|||||||
using Tiku.Domain.Catalog;
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
@@ -14,7 +15,6 @@ internal sealed class VocabularyUnitConfiguration : IEntityTypeConfiguration<Voc
|
|||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.Name).HasMaxLength(300);
|
builder.Property(entity => entity.Name).HasMaxLength(300);
|
||||||
builder.Property(entity => entity.Description).HasMaxLength(2000);
|
|
||||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
@@ -51,10 +51,7 @@ internal sealed class VocabularyWordConfiguration : IEntityTypeConfiguration<Voc
|
|||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.Word).HasMaxLength(300);
|
builder.Property(entity => entity.Word).HasMaxLength(300);
|
||||||
builder.Property(entity => entity.Phonetic).HasMaxLength(200);
|
builder.Property(entity => entity.Phonetic).HasMaxLength(300);
|
||||||
builder.Property(entity => entity.Meaning).HasMaxLength(2000);
|
|
||||||
builder.Property(entity => entity.Example).HasMaxLength(4000);
|
|
||||||
builder.Property(entity => entity.ExampleTranslation).HasMaxLength(4000);
|
|
||||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
builder.Property(entity => entity.Tags).IsJson("[]");
|
builder.Property(entity => entity.Tags).IsJson("[]");
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
@@ -98,17 +95,12 @@ internal sealed class UserWordProgressConfiguration : IEntityTypeConfiguration<U
|
|||||||
{
|
{
|
||||||
builder.ConfigureTenantEntity("user_word_progress");
|
builder.ConfigureTenantEntity("user_word_progress");
|
||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.LegacyWordId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.EaseFactor).HasPrecision(4, 2).HasDefaultValue(2.50m);
|
builder.Property(entity => entity.EaseFactor).HasPrecision(4, 2).HasDefaultValue(2.50m);
|
||||||
builder.Property(entity => entity.LastResult).HasNullableSnakeCaseEnum();
|
builder.Property(entity => entity.LastResult).HasNullableSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.DueLevel).HasSnakeCaseEnum();
|
builder.Property(entity => entity.DueLevel).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.WordId }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.WordId }).IsUnique();
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Status });
|
|
||||||
builder.HasIndex(entity => new
|
builder.HasIndex(entity => new
|
||||||
{
|
{
|
||||||
entity.TenantId,
|
entity.TenantId,
|
||||||
@@ -141,11 +133,7 @@ internal sealed class UserWordFavoriteConfiguration : IEntityTypeConfiguration<U
|
|||||||
{
|
{
|
||||||
builder.ConfigureTenantEntity("user_word_favorites");
|
builder.ConfigureTenantEntity("user_word_favorites");
|
||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.Note).HasMaxLength(1000);
|
||||||
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.LegacyWordId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.Note).HasMaxLength(2000);
|
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.WordId }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.WordId }).IsUnique();
|
||||||
|
|
||||||
builder.HasOne<User>().WithMany()
|
builder.HasOne<User>().WithMany()
|
||||||
@@ -169,7 +157,6 @@ internal sealed class HandbookSubjectConfiguration : IEntityTypeConfiguration<Ha
|
|||||||
builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
|
builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.Icon).HasMaxLength(2048);
|
builder.Property(entity => entity.Icon).HasMaxLength(2048);
|
||||||
builder.Property(entity => entity.Color).HasMaxLength(50);
|
builder.Property(entity => entity.Color).HasMaxLength(50);
|
||||||
builder.Property(entity => entity.Description).HasMaxLength(2000);
|
|
||||||
builder.Property(entity => entity.MajorLegacyIds).IsJson("[]");
|
builder.Property(entity => entity.MajorLegacyIds).IsJson("[]");
|
||||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
@@ -215,7 +202,6 @@ internal sealed class HandbookChapterConfiguration : IEntityTypeConfiguration<Ha
|
|||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.Name).HasMaxLength(300);
|
builder.Property(entity => entity.Name).HasMaxLength(300);
|
||||||
builder.Property(entity => entity.Description).HasMaxLength(2000);
|
|
||||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
@@ -251,8 +237,7 @@ internal sealed class HandbookEntryConfiguration : IEntityTypeConfiguration<Hand
|
|||||||
builder.ConfigureTenantEntity("handbook_entries");
|
builder.ConfigureTenantEntity("handbook_entries");
|
||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.Title).HasMaxLength(300);
|
builder.Property(entity => entity.Title).HasMaxLength(500);
|
||||||
builder.Property(entity => entity.Summary).HasMaxLength(2000);
|
|
||||||
builder.Property(entity => entity.Tags).IsJson("[]");
|
builder.Property(entity => entity.Tags).IsJson("[]");
|
||||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
@@ -289,7 +274,7 @@ internal sealed class QuestionTypeGroupConfiguration : IEntityTypeConfiguration<
|
|||||||
builder.ConfigureTenantEntity("question_type_groups");
|
builder.ConfigureTenantEntity("question_type_groups");
|
||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.DisplayName).HasMaxLength(300);
|
builder.Property(entity => entity.DisplayName).HasMaxLength(200);
|
||||||
builder.Property(entity => entity.Types).IsJson("[]");
|
builder.Property(entity => entity.Types).IsJson("[]");
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||||
builder.HasIndex(entity => new
|
builder.HasIndex(entity => new
|
||||||
@@ -311,18 +296,19 @@ internal sealed class SubjectShareConfiguration : IEntityTypeConfiguration<Subje
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<SubjectShare> builder)
|
public void Configure(EntityTypeBuilder<SubjectShare> builder)
|
||||||
{
|
{
|
||||||
builder.ConfigureTenantEntity("subject_shares");
|
builder.ToTable("subject_shares");
|
||||||
builder.ConfigureTimestamps();
|
builder.HasKey(entity => new
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.LegacySourceSubjectId).HasMaxLength(64);
|
|
||||||
builder.Property(entity => entity.LegacyTargetSubjectId).HasMaxLength(64);
|
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
|
||||||
builder.HasIndex(entity => new
|
|
||||||
{
|
{
|
||||||
entity.TenantId,
|
entity.TenantId,
|
||||||
entity.SourceSubjectId,
|
entity.SourceSubjectId,
|
||||||
entity.TargetSubjectId
|
entity.TargetSubjectId
|
||||||
}).IsUnique();
|
});
|
||||||
|
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.TargetSubjectId });
|
||||||
|
|
||||||
|
builder.HasOne<Tenant>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
builder.HasOne<Subject>().WithMany()
|
builder.HasOne<Subject>().WithMany()
|
||||||
.HasForeignKey(entity => new { entity.TenantId, entity.SourceSubjectId })
|
.HasForeignKey(entity => new { entity.TenantId, entity.SourceSubjectId })
|
||||||
|
|||||||
5673
Tiku.Infrastructure/Persistence/Migrations/20260725211732_AddContentAssetsAndImportPersistence.Designer.cs
generated
Normal file
5673
Tiku.Infrastructure/Persistence/Migrations/20260725211732_AddContentAssetsAndImportPersistence.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,630 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContentAssetsAndImportPersistence : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "app_assets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
asset_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
file_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_app_assets", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_app_assets_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_app_assets_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "content_assets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
subject_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
category_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
content_node_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
updated_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
verified_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
asset_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||||
|
category = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
file_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
cdn_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
asset_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
storage_provider = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
bucket = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
object_key = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
mime_type = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
file_size_bytes = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
checksum_sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
visibility = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
preview_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
access_rules = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
source = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
download_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
upload_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
verified_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
verified_size_bytes = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
verified_checksum_sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
verification_details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
preview_object_key = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
preview_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
security_flags = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_content_assets", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_content_assets_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.CheckConstraint("ck_content_assets_download_count", "download_count >= 0");
|
||||||
|
table.CheckConstraint("ck_content_assets_file_size", "file_size_bytes is null or file_size_bytes >= 0");
|
||||||
|
table.CheckConstraint("ck_content_assets_verified_checksum", "verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'");
|
||||||
|
table.CheckConstraint("ck_content_assets_verified_size", "verified_size_bytes is null or verified_size_bytes >= 0");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_categories_tenant_id_category_id",
|
||||||
|
columns: x => new { x.tenant_id, x.category_id },
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_content_nodes_tenant_id_content_node_id",
|
||||||
|
columns: x => new { x.tenant_id, x.content_node_id },
|
||||||
|
principalTable: "content_nodes",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_regions_tenant_id_region_id",
|
||||||
|
columns: x => new { x.tenant_id, x.region_id },
|
||||||
|
principalTable: "regions",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_subjects_tenant_id_subject_id",
|
||||||
|
columns: x => new { x.tenant_id, x.subject_id },
|
||||||
|
principalTable: "subjects",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_users_created_by",
|
||||||
|
column: x => x.created_by,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_users_updated_by",
|
||||||
|
column: x => x.updated_by,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_assets_users_verified_by",
|
||||||
|
column: x => x.verified_by,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "content_import_jobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
target_region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
target_subject_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
target_category_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
target_content_node_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
target_question_bank_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
import_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
source_format = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
source_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
source_hash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
dry_run = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
total_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
valid_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
error_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
warning_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
inserted_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
updated_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
skipped_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
summary = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
raw_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
|
||||||
|
normalized_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
|
||||||
|
error_message = table.Column<string>(type: "text", nullable: true),
|
||||||
|
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
finished_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_content_import_jobs", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_content_import_jobs_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.CheckConstraint("ck_content_import_jobs_counts", "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_categories_tenant_id_target_category_id",
|
||||||
|
columns: x => new { x.tenant_id, x.target_category_id },
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_content_nodes_tenant_id_target_content_~",
|
||||||
|
columns: x => new { x.tenant_id, x.target_content_node_id },
|
||||||
|
principalTable: "content_nodes",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_question_banks_tenant_id_target_questio~",
|
||||||
|
columns: x => new { x.tenant_id, x.target_question_bank_id },
|
||||||
|
principalTable: "question_banks",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_regions_tenant_id_target_region_id",
|
||||||
|
columns: x => new { x.tenant_id, x.target_region_id },
|
||||||
|
principalTable: "regions",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_subjects_tenant_id_target_subject_id",
|
||||||
|
columns: x => new { x.tenant_id, x.target_subject_id },
|
||||||
|
principalTable: "subjects",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_jobs_users_created_by",
|
||||||
|
column: x => x.created_by,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "images",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||||
|
category = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
file_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
cdn_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_images", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_images_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_images_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "video_explanations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
subject_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
legacy_subject_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||||
|
description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
video_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
thumbnail_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
duration_seconds = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
knowledge_tags = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
|
||||||
|
is_general = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
difficulty = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
source_hash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_video_explanations", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_video_explanations_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_video_explanations_subjects_tenant_id_subject_id",
|
||||||
|
columns: x => new { x.tenant_id, x.subject_id },
|
||||||
|
principalTable: "subjects",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_video_explanations_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "content_import_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
job_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
row_no = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
external_id = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
target_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
target_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
source_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
normalized_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
content_hash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
issues_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_content_import_items", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_content_import_items_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.CheckConstraint("ck_content_import_items_issues_count", "issues_count >= 0");
|
||||||
|
table.CheckConstraint("ck_content_import_items_row_no", "row_no >= 0");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_items_content_import_jobs_tenant_id_job_id",
|
||||||
|
columns: x => new { x.tenant_id, x.job_id },
|
||||||
|
principalTable: "content_import_jobs",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_items_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "question_videos",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
question_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
video_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
legacy_question_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
legacy_video_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
video_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_question_videos", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_question_videos_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_question_videos_questions_tenant_id_question_id",
|
||||||
|
columns: x => new { x.tenant_id, x.question_id },
|
||||||
|
principalTable: "questions",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_question_videos_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_question_videos_video_explanations_tenant_id_video_id",
|
||||||
|
columns: x => new { x.tenant_id, x.video_id },
|
||||||
|
principalTable: "video_explanations",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "content_import_issues",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
job_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
item_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
row_no = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
severity = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
field_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
message = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
|
||||||
|
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_content_import_issues", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_content_import_issues_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_issues_content_import_items_tenant_id_item_id",
|
||||||
|
columns: x => new { x.tenant_id, x.item_id },
|
||||||
|
principalTable: "content_import_items",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_issues_content_import_jobs_tenant_id_job_id",
|
||||||
|
columns: x => new { x.tenant_id, x.job_id },
|
||||||
|
principalTable: "content_import_jobs",
|
||||||
|
principalColumns: new[] { "tenant_id", "id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_content_import_issues_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_app_assets_tenant_id_asset_key",
|
||||||
|
table: "app_assets",
|
||||||
|
columns: new[] { "tenant_id", "asset_key" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_app_assets_tenant_id_legacy_id",
|
||||||
|
table: "app_assets",
|
||||||
|
columns: new[] { "tenant_id", "legacy_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_created_by",
|
||||||
|
table: "content_assets",
|
||||||
|
column: "created_by");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_category_id",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "category_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_checksum_sha256",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "checksum_sha256" },
|
||||||
|
filter: "checksum_sha256 is not null");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_content_node_id",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "content_node_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_legacy_id",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "legacy_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_preview_status",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "preview_status" },
|
||||||
|
filter: "preview_status <> 'none'");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_region_id",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "region_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_status_visibility_asset_type_regio~",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "status", "visibility", "asset_type", "region_id", "subject_id", "category_id", "sort_order" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_storage_provider_bucket_object_key",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "storage_provider", "bucket", "object_key" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_subject_id",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "subject_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_tenant_id_upload_status_status_updated_at",
|
||||||
|
table: "content_assets",
|
||||||
|
columns: new[] { "tenant_id", "upload_status", "status", "updated_at" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_updated_by",
|
||||||
|
table: "content_assets",
|
||||||
|
column: "updated_by");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_assets_verified_by",
|
||||||
|
table: "content_assets",
|
||||||
|
column: "verified_by");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_issues_tenant_id_item_id",
|
||||||
|
table: "content_import_issues",
|
||||||
|
columns: new[] { "tenant_id", "item_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_issues_tenant_id_job_id_severity_row_no",
|
||||||
|
table: "content_import_issues",
|
||||||
|
columns: new[] { "tenant_id", "job_id", "severity", "row_no" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_items_job_id_row_no",
|
||||||
|
table: "content_import_items",
|
||||||
|
columns: new[] { "job_id", "row_no" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_items_tenant_id_job_id_status_row_no",
|
||||||
|
table: "content_import_items",
|
||||||
|
columns: new[] { "tenant_id", "job_id", "status", "row_no" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_created_by",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
column: "created_by");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_import_type_status_created_at",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "import_type", "status", "created_at" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_target_category_id",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "target_category_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_target_content_node_id",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "target_content_node_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_target_question_bank_id",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "target_question_bank_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_target_region_id",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "target_region_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_content_import_jobs_tenant_id_target_subject_id",
|
||||||
|
table: "content_import_jobs",
|
||||||
|
columns: new[] { "tenant_id", "target_subject_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_images_tenant_id_category_is_public",
|
||||||
|
table: "images",
|
||||||
|
columns: new[] { "tenant_id", "category", "is_public" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_images_tenant_id_legacy_id",
|
||||||
|
table: "images",
|
||||||
|
columns: new[] { "tenant_id", "legacy_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_question_videos_tenant_id_legacy_id",
|
||||||
|
table: "question_videos",
|
||||||
|
columns: new[] { "tenant_id", "legacy_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_question_videos_tenant_id_question_id_video_id_video_type",
|
||||||
|
table: "question_videos",
|
||||||
|
columns: new[] { "tenant_id", "question_id", "video_id", "video_type" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_question_videos_tenant_id_video_id",
|
||||||
|
table: "question_videos",
|
||||||
|
columns: new[] { "tenant_id", "video_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_video_explanations_tenant_id_legacy_id",
|
||||||
|
table: "video_explanations",
|
||||||
|
columns: new[] { "tenant_id", "legacy_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_video_explanations_tenant_id_source_hash",
|
||||||
|
table: "video_explanations",
|
||||||
|
columns: new[] { "tenant_id", "source_hash" },
|
||||||
|
filter: "source_hash is not null");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_video_explanations_tenant_id_subject_id_is_active_sort_order",
|
||||||
|
table: "video_explanations",
|
||||||
|
columns: new[] { "tenant_id", "subject_id", "is_active", "sort_order" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "app_assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "content_assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "content_import_issues");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "images");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "question_videos");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "content_import_items");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "video_explanations");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "content_import_jobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,14 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
|||||||
public DbSet<HandbookEntry> HandbookEntries => Set<HandbookEntry>();
|
public DbSet<HandbookEntry> HandbookEntries => Set<HandbookEntry>();
|
||||||
public DbSet<QuestionTypeGroup> QuestionTypeGroups => Set<QuestionTypeGroup>();
|
public DbSet<QuestionTypeGroup> QuestionTypeGroups => Set<QuestionTypeGroup>();
|
||||||
public DbSet<SubjectShare> SubjectShares => Set<SubjectShare>();
|
public DbSet<SubjectShare> SubjectShares => Set<SubjectShare>();
|
||||||
|
public DbSet<ContentAsset> ContentAssets => Set<ContentAsset>();
|
||||||
|
public DbSet<ContentImportJob> ContentImportJobs => Set<ContentImportJob>();
|
||||||
|
public DbSet<ContentImportItem> ContentImportItems => Set<ContentImportItem>();
|
||||||
|
public DbSet<ContentImportIssue> ContentImportIssues => Set<ContentImportIssue>();
|
||||||
|
public DbSet<ImageAsset> Images => Set<ImageAsset>();
|
||||||
|
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
|
||||||
|
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
|
||||||
|
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
|
||||||
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||||
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class PersistenceModelTests
|
|||||||
.Select(entity => entity.GetTableName())
|
.Select(entity => entity.GetTableName())
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
Assert.Equal(37, tableNames.Count);
|
Assert.Equal(45, tableNames.Count);
|
||||||
Assert.Contains("tenants", tableNames);
|
Assert.Contains("tenants", tableNames);
|
||||||
Assert.Contains("tenant_settings", tableNames);
|
Assert.Contains("tenant_settings", tableNames);
|
||||||
Assert.Contains("content_entries", tableNames);
|
Assert.Contains("content_entries", tableNames);
|
||||||
@@ -39,6 +39,14 @@ public sealed class PersistenceModelTests
|
|||||||
Assert.Contains("handbook_entries", tableNames);
|
Assert.Contains("handbook_entries", tableNames);
|
||||||
Assert.Contains("question_type_groups", tableNames);
|
Assert.Contains("question_type_groups", tableNames);
|
||||||
Assert.Contains("subject_shares", tableNames);
|
Assert.Contains("subject_shares", tableNames);
|
||||||
|
Assert.Contains("content_assets", tableNames);
|
||||||
|
Assert.Contains("content_import_jobs", tableNames);
|
||||||
|
Assert.Contains("content_import_items", tableNames);
|
||||||
|
Assert.Contains("content_import_issues", tableNames);
|
||||||
|
Assert.Contains("images", tableNames);
|
||||||
|
Assert.Contains("app_assets", tableNames);
|
||||||
|
Assert.Contains("video_explanations", tableNames);
|
||||||
|
Assert.Contains("question_videos", tableNames);
|
||||||
Assert.Contains("questions", tableNames);
|
Assert.Contains("questions", tableNames);
|
||||||
Assert.Contains("question_versions", tableNames);
|
Assert.Contains("question_versions", tableNames);
|
||||||
Assert.Contains("answer_records", tableNames);
|
Assert.Contains("answer_records", tableNames);
|
||||||
@@ -209,4 +217,83 @@ public sealed class PersistenceModelTests
|
|||||||
.SequenceEqual(propertyNames));
|
.SequenceEqual(propertyNames));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(ContentAsset), nameof(ContentAsset.AccessRules), "'{}'::jsonb")]
|
||||||
|
[InlineData(typeof(ContentAsset), nameof(ContentAsset.VerificationDetails), "'{}'::jsonb")]
|
||||||
|
[InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityFlags), "'{}'::jsonb")]
|
||||||
|
[InlineData(typeof(ContentImportJob), nameof(ContentImportJob.RawPayload), "'[]'::jsonb")]
|
||||||
|
[InlineData(typeof(ContentImportItem), nameof(ContentImportItem.SourcePayload), "'{}'::jsonb")]
|
||||||
|
[InlineData(typeof(ContentImportIssue), nameof(ContentImportIssue.Details), "'{}'::jsonb")]
|
||||||
|
[InlineData(typeof(VideoExplanation), nameof(VideoExplanation.KnowledgeTags), "'[]'::jsonb")]
|
||||||
|
[InlineData(typeof(QuestionVideo), nameof(QuestionVideo.Metadata), "'{}'::jsonb")]
|
||||||
|
public void Asset_import_and_video_json_properties_are_mapped_to_jsonb(
|
||||||
|
Type entityType,
|
||||||
|
string propertyName,
|
||||||
|
string expectedDefaultValueSql)
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
var property = context.Model.FindEntityType(entityType)!
|
||||||
|
.FindProperty(propertyName)!;
|
||||||
|
|
||||||
|
Assert.Equal("jsonb", property.GetColumnType());
|
||||||
|
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
|
||||||
|
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_items_and_question_videos_have_expected_unique_indexes()
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
AssertHasUniqueIndex<ContentImportItem>(
|
||||||
|
nameof(ContentImportItem.JobId),
|
||||||
|
nameof(ContentImportItem.RowNo));
|
||||||
|
AssertHasUniqueIndex<QuestionVideo>(
|
||||||
|
nameof(QuestionVideo.TenantId),
|
||||||
|
nameof(QuestionVideo.QuestionId),
|
||||||
|
nameof(QuestionVideo.VideoId),
|
||||||
|
nameof(QuestionVideo.VideoType));
|
||||||
|
|
||||||
|
void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
|
||||||
|
{
|
||||||
|
var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
indexes,
|
||||||
|
index => index.IsUnique
|
||||||
|
&& index.Properties
|
||||||
|
.Select(property => property.Name)
|
||||||
|
.SequenceEqual(propertyNames));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Asset_import_and_video_relations_include_tenant_in_foreign_keys()
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
var entityTypes = new[]
|
||||||
|
{
|
||||||
|
typeof(ContentAsset),
|
||||||
|
typeof(ContentImportJob),
|
||||||
|
typeof(ContentImportItem),
|
||||||
|
typeof(ContentImportIssue),
|
||||||
|
typeof(VideoExplanation),
|
||||||
|
typeof(QuestionVideo)
|
||||||
|
};
|
||||||
|
|
||||||
|
var compositeForeignKeys = entityTypes
|
||||||
|
.SelectMany(entityType => context.Model.FindEntityType(entityType)!
|
||||||
|
.GetForeignKeys())
|
||||||
|
.Where(foreignKey => foreignKey.Properties.Count > 1)
|
||||||
|
.Select(foreignKey => foreignKey.Properties
|
||||||
|
.Select(property => property.Name)
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
Assert.All(
|
||||||
|
compositeForeignKeys,
|
||||||
|
properties => Assert.Contains("TenantId", properties));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user