feat: add content platform operations persistence

This commit is contained in:
xiong
2026-07-26 05:58:38 +08:00
parent 3eb9f111ed
commit 9fd8708fa5
9 changed files with 15299 additions and 1 deletions

View File

@@ -41,6 +41,10 @@ public sealed class ContentAsset : AuditableTenantEntity
public JsonElement VerificationDetails { get; set; } = JsonDefaults.Object();
public string? PreviewObjectKey { get; set; }
public AssetPreviewStatus PreviewStatus { get; set; } = AssetPreviewStatus.None;
public AssetSecurityScanStatus SecurityScanStatus { get; set; } = AssetSecurityScanStatus.NotRequired;
public DateTimeOffset? SecurityScannedAt { get; set; }
public string? SecurityScanProvider { get; set; }
public JsonElement SecurityScanSummary { get; set; } = JsonDefaults.Object();
public JsonElement SecurityFlags { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
@@ -189,6 +193,16 @@ public enum AssetPreviewStatus
Failed
}
public enum AssetSecurityScanStatus
{
NotRequired,
Pending,
Scanning,
Passed,
Failed,
Skipped
}
public enum ContentImportType
{
Questions,

View File

@@ -0,0 +1,98 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Content;
public sealed class ContentAssetAccessEvent : Entity
{
public Guid TenantId { get; set; }
public Guid? AssetId { get; set; }
public Guid? UserId { get; set; }
public AssetAccessActorRole ActorRole { get; set; } = AssetAccessActorRole.Anonymous;
public AssetAccessType AccessType { get; set; } = AssetAccessType.Preview;
public string? Visibility { get; set; }
public string? AssetType { get; set; }
public string? StorageProvider { get; set; }
public AssetAccessDisposition? Disposition { get; set; }
public int? ExpiresInSeconds { get; set; }
public string? SignatureMode { get; set; }
public AssetAccessResult Result { get; set; } = AssetAccessResult.Granted;
public string? DenyCode { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ContentAssetSecurityScanEvent : Entity
{
public Guid TenantId { get; set; }
public Guid? AssetId { get; set; }
public string Provider { get; set; } = string.Empty;
public AssetSecurityScanStatus ScanStatus { get; set; } = AssetSecurityScanStatus.Pending;
public AssetSecurityRiskLevel RiskLevel { get; set; } = AssetSecurityRiskLevel.None;
public string[] IssueCodes { get; set; } = [];
public JsonElement Details { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class QuestionBankGrant : AuditableEntity
{
public Guid SourceQuestionBankId { get; set; }
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
public QuestionBankGrantScope GrantScope { get; set; } = QuestionBankGrantScope.Plans;
public string[] AllowedPlanCodes { get; set; } = [];
public Guid[] AllowedTenantIds { get; set; } = [];
public Guid[] AllowedRegionIds { get; set; } = [];
public Guid[] AllowedSubjectIds { get; set; } = [];
public QuestionBankGrantStatus Status { get; set; } = QuestionBankGrantStatus.Active;
public DateTimeOffset? StartsAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class TenantQuestionBankAdoption : AuditableTenantEntity
{
public Guid SourceQuestionBankId { get; set; }
public Guid? GrantId { get; set; }
public Guid? TargetQuestionBankId { get; set; }
public Guid? TargetEntryId { get; set; }
public Guid? TargetCollectionId { get; set; }
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
public QuestionBankAdoptionMode AdoptionMode { get; set; } = QuestionBankAdoptionMode.CopiedSnapshot;
public QuestionBankAdoptionStatus Status { get; set; } = QuestionBankAdoptionStatus.Active;
public QuestionBankAdoptionSyncStatus SyncStatus { get; set; } = QuestionBankAdoptionSyncStatus.Synced;
public JsonElement SourceSnapshot { get; set; } = JsonDefaults.Object();
public int CopiedQuestionCount { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset? LastSyncedAt { get; set; }
}
public sealed class AiRecommendationReport : AuditableTenantEntity
{
public Guid UserId { get; set; }
public Guid? RegionId { get; set; }
public AiRecommendationReportStatus Status { get; set; } = AiRecommendationReportStatus.Generated;
public string Provider { get; set; } = "local_rules";
public string? Model { get; set; }
public string PromptVersion { get; set; } = "school-recommendation-v1";
public JsonElement InputPayload { get; set; } = JsonDefaults.Object();
public JsonElement ContextPayload { get; set; } = JsonDefaults.Object();
public JsonElement ResultPayload { get; set; } = JsonDefaults.Object();
public string? ErrorMessage { get; set; }
public DateTimeOffset? GeneratedAt { get; set; }
}
public enum AssetAccessActorRole { Anonymous, Student, TenantAdmin, TenantContentEditor, System }
public enum AssetAccessType { Download, Preview, AdminDownload, AdminPreview, UploadSign, UploadConfirm }
public enum AssetAccessDisposition { Attachment, Inline }
public enum AssetAccessResult { Granted, Denied }
public enum AssetSecurityRiskLevel { None, Low, Medium, High, Critical }
public enum QuestionBankGrantScope { AllActiveTenants, Plans, Tenants, Mixed }
public enum QuestionBankGrantStatus { Active, Disabled, Expired }
public enum QuestionBankAdoptionMode { CopiedSnapshot, Reference }
public enum QuestionBankAdoptionStatus { Active, SyncPending, Suspended, Archived }
public enum QuestionBankAdoptionSyncStatus { Pending, Synced, Failed }
public enum AiRecommendationReportStatus { Draft, Generated, Failed }

View File

@@ -35,6 +35,11 @@ internal sealed class ContentAssetConfiguration : IEntityTypeConfiguration<Conte
builder.Property(entity => entity.VerificationDetails).IsJson("{}");
builder.Property(entity => entity.PreviewObjectKey).HasMaxLength(1000);
builder.Property(entity => entity.PreviewStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.SecurityScanStatus)
.HasSnakeCaseEnum()
.HasDefaultValue(AssetSecurityScanStatus.NotRequired);
builder.Property(entity => entity.SecurityScanProvider).HasMaxLength(100);
builder.Property(entity => entity.SecurityScanSummary).IsJson("{}");
builder.Property(entity => entity.SecurityFlags).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
@@ -67,6 +72,14 @@ internal sealed class ContentAssetConfiguration : IEntityTypeConfiguration<Conte
});
builder.HasIndex(entity => new { entity.TenantId, entity.PreviewStatus })
.HasFilter("preview_status <> 'none'");
builder.HasIndex(entity => new
{
entity.TenantId,
entity.SecurityScanStatus,
entity.UploadStatus,
entity.Status,
entity.UpdatedAt
});
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_content_assets_file_size", "file_size_bytes is null or file_size_bytes >= 0");

View File

@@ -0,0 +1,158 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class ContentAssetAccessEventConfiguration : IEntityTypeConfiguration<ContentAssetAccessEvent>
{
public void Configure(EntityTypeBuilder<ContentAssetAccessEvent> builder)
{
builder.ConfigureEntity("content_asset_access_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.ActorRole).HasSnakeCaseEnum();
builder.Property(entity => entity.AccessType).HasSnakeCaseEnum();
builder.Property(entity => entity.Visibility).HasMaxLength(50);
builder.Property(entity => entity.AssetType).HasMaxLength(50);
builder.Property(entity => entity.StorageProvider).HasMaxLength(50);
builder.Property(entity => entity.Disposition).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.SignatureMode).HasMaxLength(100);
builder.Property(entity => entity.Result).HasSnakeCaseEnum();
builder.Property(entity => entity.DenyCode).HasMaxLength(100);
builder.Property(entity => entity.IpAddress).HasMaxLength(100);
builder.Property(entity => entity.UserAgent).HasMaxLength(1024);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.AssetId, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt })
.HasFilter("user_id is not null");
builder.HasIndex(entity => new { entity.TenantId, entity.AccessType, entity.Result, entity.CreatedAt });
builder.ToTable(table => table.HasCheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0"));
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<ContentAsset>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.AssetId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class ContentAssetSecurityScanEventConfiguration : IEntityTypeConfiguration<ContentAssetSecurityScanEvent>
{
public void Configure(EntityTypeBuilder<ContentAssetSecurityScanEvent> builder)
{
builder.ConfigureEntity("content_asset_security_scan_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.Provider).HasMaxLength(100);
builder.Property(entity => entity.ScanStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.RiskLevel).HasSnakeCaseEnum();
builder.Property(entity => entity.IssueCodes)
.HasColumnType("text[]")
.HasDefaultValueSql("'{}'::text[]");
builder.Property(entity => entity.Details).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.AssetId, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.ScanStatus, entity.RiskLevel, entity.CreatedAt });
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<ContentAsset>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.AssetId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class QuestionBankGrantConfiguration : IEntityTypeConfiguration<QuestionBankGrant>
{
public void Configure(EntityTypeBuilder<QuestionBankGrant> builder)
{
builder.ConfigureEntity("question_bank_grants");
builder.ConfigureTimestamps();
builder.Property(entity => entity.GrantScope).HasSnakeCaseEnum();
builder.Property(entity => entity.AllowedPlanCodes)
.HasColumnType("text[]")
.HasDefaultValueSql("'{}'::text[]");
builder.Property(entity => entity.AllowedTenantIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.AllowedRegionIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.AllowedSubjectIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.SourceQuestionBankId, entity.Status, entity.StartsAt, entity.ExpiresAt });
builder.HasIndex(entity => entity.AllowedTenantIds).HasMethod("gin");
builder.HasIndex(entity => entity.AllowedPlanCodes).HasMethod("gin");
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => entity.SourceQuestionBankId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantQuestionBankAdoptionConfiguration : IEntityTypeConfiguration<TenantQuestionBankAdoption>
{
public void Configure(EntityTypeBuilder<TenantQuestionBankAdoption> builder)
{
builder.ConfigureTenantEntity("tenant_question_bank_adoptions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.AdoptionMode).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.SyncStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.SourceSnapshot).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.SourceQuestionBankId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.UpdatedAt });
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_question_bank_adoptions_copied_count", "copied_question_count >= 0"));
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => entity.SourceQuestionBankId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<QuestionBankGrant>().WithMany()
.HasForeignKey(entity => entity.GrantId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetQuestionBankId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetEntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetCollectionId })
.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);
}
}
internal sealed class AiRecommendationReportConfiguration : IEntityTypeConfiguration<AiRecommendationReport>
{
public void Configure(EntityTypeBuilder<AiRecommendationReport> builder)
{
builder.ConfigureTenantEntity("ai_recommendation_reports");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Provider).HasMaxLength(100);
builder.Property(entity => entity.Model).HasMaxLength(100);
builder.Property(entity => entity.PromptVersion).HasMaxLength(100);
builder.Property(entity => entity.InputPayload).IsJson("{}");
builder.Property(entity => entity.ContextPayload).IsJson("{}");
builder.Property(entity => entity.ResultPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.CreatedAt });
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,452 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContentPlatformOperationsPersistence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "security_scan_provider",
table: "content_assets",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "security_scan_status",
table: "content_assets",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "not_required");
migrationBuilder.AddColumn<JsonElement>(
name: "security_scan_summary",
table: "content_assets",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "security_scanned_at",
table: "content_assets",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "ai_recommendation_reports",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
provider = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
model = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
prompt_version = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
input_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
context_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
result_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
error_message = table.Column<string>(type: "text", nullable: true),
generated_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_ai_recommendation_reports", x => x.id);
table.UniqueConstraint("ak_ai_recommendation_reports_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_ai_recommendation_reports_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_ai_recommendation_reports_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_ai_recommendation_reports_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "content_asset_access_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
asset_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
actor_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
access_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
visibility = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
asset_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
storage_provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
disposition = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
expires_in_seconds = table.Column<int>(type: "integer", nullable: true),
signature_mode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
result = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
deny_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ip_address = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
user_agent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_content_asset_access_events", x => x.id);
table.UniqueConstraint("ak_content_asset_access_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0");
table.ForeignKey(
name: "fk_content_asset_access_events_content_assets_tenant_id_asset_~",
columns: x => new { x.tenant_id, x.asset_id },
principalTable: "content_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_content_asset_access_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_content_asset_access_events_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "content_asset_security_scan_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
asset_id = table.Column<Guid>(type: "uuid", nullable: true),
provider = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
scan_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
risk_level = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
issue_codes = table.Column<string[]>(type: "text[]", nullable: false, defaultValueSql: "'{}'::text[]"),
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_content_asset_security_scan_events", x => x.id);
table.UniqueConstraint("ak_content_asset_security_scan_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_content_asset_security_scan_events_content_assets_tenant_id~",
columns: x => new { x.tenant_id, x.asset_id },
principalTable: "content_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_content_asset_security_scan_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "question_bank_grants",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
source_question_bank_id = table.Column<Guid>(type: "uuid", nullable: false),
created_by = table.Column<Guid>(type: "uuid", nullable: true),
updated_by = table.Column<Guid>(type: "uuid", nullable: true),
grant_scope = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
allowed_plan_codes = table.Column<string[]>(type: "text[]", nullable: false, defaultValueSql: "'{}'::text[]"),
allowed_tenant_ids = table.Column<Guid[]>(type: "uuid[]", nullable: false, defaultValueSql: "'{}'::uuid[]"),
allowed_region_ids = table.Column<Guid[]>(type: "uuid[]", nullable: false, defaultValueSql: "'{}'::uuid[]"),
allowed_subject_ids = table.Column<Guid[]>(type: "uuid[]", nullable: false, defaultValueSql: "'{}'::uuid[]"),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
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_bank_grants", x => x.id);
table.ForeignKey(
name: "fk_question_bank_grants_question_banks_source_question_bank_id",
column: x => x.source_question_bank_id,
principalTable: "question_banks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_question_bank_grants_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_question_bank_grants_users_updated_by",
column: x => x.updated_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "tenant_question_bank_adoptions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
source_question_bank_id = table.Column<Guid>(type: "uuid", nullable: false),
grant_id = table.Column<Guid>(type: "uuid", nullable: true),
target_question_bank_id = table.Column<Guid>(type: "uuid", nullable: true),
target_entry_id = table.Column<Guid>(type: "uuid", nullable: true),
target_collection_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),
adoption_mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
sync_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
source_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
copied_question_count = table.Column<int>(type: "integer", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
last_synced_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_tenant_question_bank_adoptions", x => x.id);
table.UniqueConstraint("ak_tenant_question_bank_adoptions_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_tenant_question_bank_adoptions_copied_count", "copied_question_count >= 0");
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_content_entries_tenant_id_ta~",
columns: x => new { x.tenant_id, x.target_entry_id },
principalTable: "content_entries",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_question_bank_grants_grant_id",
column: x => x.grant_id,
principalTable: "question_bank_grants",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_question_banks_source_questi~",
column: x => x.source_question_bank_id,
principalTable: "question_banks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_question_banks_tenant_id_tar~",
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_tenant_question_bank_adoptions_question_collections_tenant_~",
columns: x => new { x.tenant_id, x.target_collection_id },
principalTable: "question_collections",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_tenant_question_bank_adoptions_users_updated_by",
column: x => x.updated_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_content_assets_tenant_id_security_scan_status_upload_status~",
table: "content_assets",
columns: new[] { "tenant_id", "security_scan_status", "upload_status", "status", "updated_at" });
migrationBuilder.CreateIndex(
name: "ix_ai_recommendation_reports_tenant_id_region_id_created_at",
table: "ai_recommendation_reports",
columns: new[] { "tenant_id", "region_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_ai_recommendation_reports_tenant_id_user_id_created_at",
table: "ai_recommendation_reports",
columns: new[] { "tenant_id", "user_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_ai_recommendation_reports_user_id",
table: "ai_recommendation_reports",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_content_asset_access_events_tenant_id_access_type_result_cr~",
table: "content_asset_access_events",
columns: new[] { "tenant_id", "access_type", "result", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_content_asset_access_events_tenant_id_asset_id_created_at",
table: "content_asset_access_events",
columns: new[] { "tenant_id", "asset_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_content_asset_access_events_tenant_id_user_id_created_at",
table: "content_asset_access_events",
columns: new[] { "tenant_id", "user_id", "created_at" },
filter: "user_id is not null");
migrationBuilder.CreateIndex(
name: "ix_content_asset_access_events_user_id",
table: "content_asset_access_events",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_content_asset_security_scan_events_tenant_id_asset_id_creat~",
table: "content_asset_security_scan_events",
columns: new[] { "tenant_id", "asset_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_content_asset_security_scan_events_tenant_id_scan_status_ri~",
table: "content_asset_security_scan_events",
columns: new[] { "tenant_id", "scan_status", "risk_level", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_question_bank_grants_allowed_plan_codes",
table: "question_bank_grants",
column: "allowed_plan_codes")
.Annotation("Npgsql:IndexMethod", "gin");
migrationBuilder.CreateIndex(
name: "ix_question_bank_grants_allowed_tenant_ids",
table: "question_bank_grants",
column: "allowed_tenant_ids")
.Annotation("Npgsql:IndexMethod", "gin");
migrationBuilder.CreateIndex(
name: "ix_question_bank_grants_created_by",
table: "question_bank_grants",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_question_bank_grants_source_question_bank_id_status_starts_~",
table: "question_bank_grants",
columns: new[] { "source_question_bank_id", "status", "starts_at", "expires_at" });
migrationBuilder.CreateIndex(
name: "ix_question_bank_grants_updated_by",
table: "question_bank_grants",
column: "updated_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_created_by",
table: "tenant_question_bank_adoptions",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_grant_id",
table: "tenant_question_bank_adoptions",
column: "grant_id");
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_source_question_bank_id",
table: "tenant_question_bank_adoptions",
column: "source_question_bank_id");
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_tenant_id_source_question_ba~",
table: "tenant_question_bank_adoptions",
columns: new[] { "tenant_id", "source_question_bank_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_tenant_id_status_updated_at",
table: "tenant_question_bank_adoptions",
columns: new[] { "tenant_id", "status", "updated_at" });
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_tenant_id_target_collection_~",
table: "tenant_question_bank_adoptions",
columns: new[] { "tenant_id", "target_collection_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_tenant_id_target_entry_id",
table: "tenant_question_bank_adoptions",
columns: new[] { "tenant_id", "target_entry_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_tenant_id_target_question_ba~",
table: "tenant_question_bank_adoptions",
columns: new[] { "tenant_id", "target_question_bank_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_adoptions_updated_by",
table: "tenant_question_bank_adoptions",
column: "updated_by");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ai_recommendation_reports");
migrationBuilder.DropTable(
name: "content_asset_access_events");
migrationBuilder.DropTable(
name: "content_asset_security_scan_events");
migrationBuilder.DropTable(
name: "tenant_question_bank_adoptions");
migrationBuilder.DropTable(
name: "question_bank_grants");
migrationBuilder.DropIndex(
name: "ix_content_assets_tenant_id_security_scan_status_upload_status~",
table: "content_assets");
migrationBuilder.DropColumn(
name: "security_scan_provider",
table: "content_assets");
migrationBuilder.DropColumn(
name: "security_scan_status",
table: "content_assets");
migrationBuilder.DropColumn(
name: "security_scan_summary",
table: "content_assets");
migrationBuilder.DropColumn(
name: "security_scanned_at",
table: "content_assets");
}
}
}

View File

@@ -2816,6 +2816,105 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_usage_records", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.AiRecommendationReport", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ContextPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("context_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<DateTimeOffset?>("GeneratedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("generated_at");
b.Property<JsonElement>("InputPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("input_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Model")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("model");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("prompt_version");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("provider");
b.Property<Guid?>("RegionId")
.HasColumnType("uuid")
.HasColumnName("region_id");
b.Property<JsonElement>("ResultPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("result_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_ai_recommendation_reports");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_ai_recommendation_reports_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_ai_recommendation_reports_user_id");
b.HasIndex("TenantId", "RegionId", "CreatedAt")
.HasDatabaseName("ix_ai_recommendation_reports_tenant_id_region_id_created_at");
b.HasIndex("TenantId", "UserId", "CreatedAt")
.HasDatabaseName("ix_ai_recommendation_reports_tenant_id_user_id_created_at");
b.ToTable("ai_recommendation_reports", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b =>
{
b.Property<Guid>("Id")
@@ -3015,6 +3114,29 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("security_flags")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("SecurityScanProvider")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("security_scan_provider");
b.Property<string>("SecurityScanStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("not_required")
.HasColumnName("security_scan_status");
b.Property<JsonElement>("SecurityScanSummary")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("security_scan_summary")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset?>("SecurityScannedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("security_scanned_at");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
@@ -3140,6 +3262,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("TenantId", "UploadStatus", "Status", "UpdatedAt")
.HasDatabaseName("ix_content_assets_tenant_id_upload_status_status_updated_at");
b.HasIndex("TenantId", "SecurityScanStatus", "UploadStatus", "Status", "UpdatedAt")
.HasDatabaseName("ix_content_assets_tenant_id_security_scan_status_upload_status~");
b.HasIndex("TenantId", "Status", "Visibility", "AssetType", "RegionId", "SubjectId", "CategoryId", "SortOrder")
.HasDatabaseName("ix_content_assets_tenant_id_status_visibility_asset_type_regio~");
@@ -3155,6 +3280,193 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AccessType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("access_type");
b.Property<string>("ActorRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("actor_role");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid")
.HasColumnName("asset_id");
b.Property<string>("AssetType")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("asset_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DenyCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("deny_code");
b.Property<string>("Disposition")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("disposition");
b.Property<int?>("ExpiresInSeconds")
.HasColumnType("integer")
.HasColumnName("expires_in_seconds");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("ip_address");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Result")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("result");
b.Property<string>("SignatureMode")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("signature_mode");
b.Property<string>("StorageProvider")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("storage_provider");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("UserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("user_agent");
b.Property<Guid?>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<string>("Visibility")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("visibility");
b.HasKey("Id")
.HasName("pk_content_asset_access_events");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_content_asset_access_events_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_content_asset_access_events_user_id");
b.HasIndex("TenantId", "AssetId", "CreatedAt")
.HasDatabaseName("ix_content_asset_access_events_tenant_id_asset_id_created_at");
b.HasIndex("TenantId", "UserId", "CreatedAt")
.HasDatabaseName("ix_content_asset_access_events_tenant_id_user_id_created_at")
.HasFilter("user_id is not null");
b.HasIndex("TenantId", "AccessType", "Result", "CreatedAt")
.HasDatabaseName("ix_content_asset_access_events_tenant_id_access_type_result_cr~");
b.ToTable("content_asset_access_events", null, t =>
{
t.HasCheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0");
});
});
modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid")
.HasColumnName("asset_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Details")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("details")
.HasDefaultValueSql("'{}'::jsonb");
b.PrimitiveCollection<string[]>("IssueCodes")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text[]")
.HasColumnName("issue_codes")
.HasDefaultValueSql("'{}'::text[]");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("provider");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("risk_level");
b.Property<string>("ScanStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("scan_status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_content_asset_security_scan_events");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_content_asset_security_scan_events_tenant_id_id");
b.HasIndex("TenantId", "AssetId", "CreatedAt")
.HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_asset_id_creat~");
b.HasIndex("TenantId", "ScanStatus", "RiskLevel", "CreatedAt")
.HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_scan_status_ri~");
b.ToTable("content_asset_security_scan_events", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b =>
{
b.Property<Guid>("Id")
@@ -4332,6 +4644,117 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Content.QuestionBankGrant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("AllowedPlanCodes")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text[]")
.HasColumnName("allowed_plan_codes")
.HasDefaultValueSql("'{}'::text[]");
b.PrimitiveCollection<Guid[]>("AllowedRegionIds")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("uuid[]")
.HasColumnName("allowed_region_ids")
.HasDefaultValueSql("'{}'::uuid[]");
b.PrimitiveCollection<Guid[]>("AllowedSubjectIds")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("uuid[]")
.HasColumnName("allowed_subject_ids")
.HasDefaultValueSql("'{}'::uuid[]");
b.PrimitiveCollection<Guid[]>("AllowedTenantIds")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("uuid[]")
.HasColumnName("allowed_tenant_ids")
.HasDefaultValueSql("'{}'::uuid[]");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("GrantScope")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("grant_scope");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid>("SourceQuestionBankId")
.HasColumnType("uuid")
.HasColumnName("source_question_bank_id");
b.Property<DateTimeOffset?>("StartsAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("starts_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_question_bank_grants");
b.HasIndex("AllowedPlanCodes")
.HasDatabaseName("ix_question_bank_grants_allowed_plan_codes");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("AllowedPlanCodes"), "gin");
b.HasIndex("AllowedTenantIds")
.HasDatabaseName("ix_question_bank_grants_allowed_tenant_ids");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("AllowedTenantIds"), "gin");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_question_bank_grants_created_by");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_question_bank_grants_updated_by");
b.HasIndex("SourceQuestionBankId", "Status", "StartsAt", "ExpiresAt")
.HasDatabaseName("ix_question_bank_grants_source_question_bank_id_status_starts_~");
b.ToTable("question_bank_grants", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b =>
{
b.Property<Guid>("Id")
@@ -4770,6 +5193,136 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("subject_shares", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankAdoption", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdoptionMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("adoption_mode");
b.Property<int>("CopiedQuestionCount")
.HasColumnType("integer")
.HasColumnName("copied_question_count");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<Guid?>("GrantId")
.HasColumnType("uuid")
.HasColumnName("grant_id");
b.Property<DateTimeOffset?>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid>("SourceQuestionBankId")
.HasColumnType("uuid")
.HasColumnName("source_question_bank_id");
b.Property<JsonElement>("SourceSnapshot")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("source_snapshot")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<string>("SyncStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("sync_status");
b.Property<Guid?>("TargetCollectionId")
.HasColumnType("uuid")
.HasColumnName("target_collection_id");
b.Property<Guid?>("TargetEntryId")
.HasColumnType("uuid")
.HasColumnName("target_entry_id");
b.Property<Guid?>("TargetQuestionBankId")
.HasColumnType("uuid")
.HasColumnName("target_question_bank_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_tenant_question_bank_adoptions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_question_bank_adoptions_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_tenant_question_bank_adoptions_created_by");
b.HasIndex("GrantId")
.HasDatabaseName("ix_tenant_question_bank_adoptions_grant_id");
b.HasIndex("SourceQuestionBankId")
.HasDatabaseName("ix_tenant_question_bank_adoptions_source_question_bank_id");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_tenant_question_bank_adoptions_updated_by");
b.HasIndex("TenantId", "SourceQuestionBankId")
.IsUnique()
.HasDatabaseName("ix_tenant_question_bank_adoptions_tenant_id_source_question_ba~");
b.HasIndex("TenantId", "TargetCollectionId")
.HasDatabaseName("ix_tenant_question_bank_adoptions_tenant_id_target_collection_~");
b.HasIndex("TenantId", "TargetEntryId")
.HasDatabaseName("ix_tenant_question_bank_adoptions_tenant_id_target_entry_id");
b.HasIndex("TenantId", "TargetQuestionBankId")
.HasDatabaseName("ix_tenant_question_bank_adoptions_tenant_id_target_question_ba~");
b.HasIndex("TenantId", "Status", "UpdatedAt")
.HasDatabaseName("ix_tenant_question_bank_adoptions_tenant_id_status_updated_at");
b.ToTable("tenant_question_bank_adoptions", null, t =>
{
t.HasCheckConstraint("ck_tenant_question_bank_adoptions_copied_count", "copied_question_count >= 0");
});
});
modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b =>
{
b.Property<Guid>("Id")
@@ -11228,6 +11781,30 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenant_usage_records_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Content.AiRecommendationReport", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_ai_recommendation_reports_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_ai_recommendation_reports_users_user_id");
b.HasOne("Tiku.Domain.Catalog.Region", null)
.WithMany()
.HasForeignKey("TenantId", "RegionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_ai_recommendation_reports_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -11294,6 +11871,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_content_assets_subjects_tenant_id_subject_id");
});
modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_content_asset_access_events_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_content_asset_access_events_users_user_id");
b.HasOne("Tiku.Domain.Content.ContentAsset", null)
.WithMany()
.HasForeignKey("TenantId", "AssetId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_content_asset_access_events_content_assets_tenant_id_asset_~");
});
modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_content_asset_security_scan_events_tenants_tenant_id");
b.HasOne("Tiku.Domain.Content.ContentAsset", null)
.WithMany()
.HasForeignKey("TenantId", "AssetId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_content_asset_security_scan_events_content_assets_tenant_id~");
});
modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -11628,6 +12245,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_practice_blueprints_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Content.QuestionBankGrant", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_question_bank_grants_users_created_by");
b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null)
.WithMany()
.HasForeignKey("SourceQuestionBankId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_question_bank_grants_question_banks_source_question_bank_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_question_bank_grants_users_updated_by");
});
modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -11785,6 +12424,62 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_subject_shares_subjects_tenant_id_target_subject_id");
});
modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankAdoption", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_question_bank_adoptions_users_created_by");
b.HasOne("Tiku.Domain.Content.QuestionBankGrant", null)
.WithMany()
.HasForeignKey("GrantId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_question_bank_adoptions_question_bank_grants_grant_id");
b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null)
.WithMany()
.HasForeignKey("SourceQuestionBankId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_question_bank_adoptions_question_banks_source_questi~");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_question_bank_adoptions_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_question_bank_adoptions_users_updated_by");
b.HasOne("Tiku.Domain.Content.QuestionCollection", null)
.WithMany()
.HasForeignKey("TenantId", "TargetCollectionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_question_bank_adoptions_question_collections_tenant_~");
b.HasOne("Tiku.Domain.Content.ContentEntry", null)
.WithMany()
.HasForeignKey("TenantId", "TargetEntryId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_question_bank_adoptions_content_entries_tenant_id_ta~");
b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null)
.WithMany()
.HasForeignKey("TenantId", "TargetQuestionBankId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_question_bank_adoptions_question_banks_tenant_id_tar~");
});
modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -57,6 +57,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<QuestionTypeGroup> QuestionTypeGroups => Set<QuestionTypeGroup>();
public DbSet<SubjectShare> SubjectShares => Set<SubjectShare>();
public DbSet<ContentAsset> ContentAssets => Set<ContentAsset>();
public DbSet<ContentAssetAccessEvent> ContentAssetAccessEvents => Set<ContentAssetAccessEvent>();
public DbSet<ContentAssetSecurityScanEvent> ContentAssetSecurityScanEvents => Set<ContentAssetSecurityScanEvent>();
public DbSet<ContentImportJob> ContentImportJobs => Set<ContentImportJob>();
public DbSet<ContentImportItem> ContentImportItems => Set<ContentImportItem>();
public DbSet<ContentImportIssue> ContentImportIssues => Set<ContentImportIssue>();
@@ -64,6 +66,9 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
public DbSet<QuestionBankGrant> QuestionBankGrants => Set<QuestionBankGrant>();
public DbSet<TenantQuestionBankAdoption> TenantQuestionBankAdoptions => Set<TenantQuestionBankAdoption>();
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();

View File

@@ -28,7 +28,7 @@ public sealed class PersistenceModelTests
.Select(entity => entity.GetTableName())
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(106, tableNames.Count);
Assert.Equal(111, tableNames.Count);
Assert.Contains("tenants", tableNames);
Assert.Contains("tenant_settings", tableNames);
Assert.Contains("content_entries", tableNames);
@@ -92,6 +92,11 @@ public sealed class PersistenceModelTests
Assert.Contains("commerce_reconciliation_items", tableNames);
Assert.Contains("commerce_reconciliation_issues", tableNames);
Assert.Contains("commerce_reconciliation_issue_events", tableNames);
Assert.Contains("content_asset_access_events", tableNames);
Assert.Contains("content_asset_security_scan_events", tableNames);
Assert.Contains("question_bank_grants", tableNames);
Assert.Contains("tenant_question_bank_adoptions", tableNames);
Assert.Contains("ai_recommendation_reports", tableNames);
Assert.Contains("referral_tracks", tableNames);
Assert.Contains("referral_codes", tableNames);
Assert.Contains("referral_leads", tableNames);
@@ -287,12 +292,21 @@ public sealed class PersistenceModelTests
[Theory]
[InlineData(typeof(ContentAsset), nameof(ContentAsset.AccessRules), "'{}'::jsonb")]
[InlineData(typeof(ContentAsset), nameof(ContentAsset.VerificationDetails), "'{}'::jsonb")]
[InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityScanSummary), "'{}'::jsonb")]
[InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityFlags), "'{}'::jsonb")]
[InlineData(typeof(ContentAssetAccessEvent), nameof(ContentAssetAccessEvent.Metadata), "'{}'::jsonb")]
[InlineData(typeof(ContentAssetSecurityScanEvent), nameof(ContentAssetSecurityScanEvent.Details), "'{}'::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")]
[InlineData(typeof(QuestionBankGrant), nameof(QuestionBankGrant.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.SourceSnapshot), "'{}'::jsonb")]
[InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.Metadata), "'{}'::jsonb")]
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.InputPayload), "'{}'::jsonb")]
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ContextPayload), "'{}'::jsonb")]
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ResultPayload), "'{}'::jsonb")]
public void Asset_import_and_video_json_properties_are_mapped_to_jsonb(
Type entityType,
string propertyName,
@@ -321,6 +335,17 @@ public sealed class PersistenceModelTests
nameof(QuestionVideo.QuestionId),
nameof(QuestionVideo.VideoId),
nameof(QuestionVideo.VideoType));
AssertHasUniqueIndex<TenantQuestionBankAdoption>(
nameof(TenantQuestionBankAdoption.TenantId),
nameof(TenantQuestionBankAdoption.SourceQuestionBankId));
var allowedPlanCodes = context.Model.FindEntityType(typeof(QuestionBankGrant))!
.FindProperty(nameof(QuestionBankGrant.AllowedPlanCodes))!;
Assert.Equal("text[]", allowedPlanCodes.GetColumnType());
var allowedTenantIds = context.Model.FindEntityType(typeof(QuestionBankGrant))!
.FindProperty(nameof(QuestionBankGrant.AllowedTenantIds))!;
Assert.Equal("uuid[]", allowedTenantIds.GetColumnType());
void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
{