feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -182,7 +182,7 @@ internal sealed class ContentImportItemConfiguration : IEntityTypeConfiguration<
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.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.Status, entity.RowNo });
builder.ToTable(table =>
{

View File

@@ -134,7 +134,7 @@ internal sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
builder.Property(entity => entity.RawPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.OrderId, entity.UpdatedAt });
builder.HasIndex(entity => new { entity.Provider, entity.ProviderTradeNo })
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.ProviderTradeNo })
.IsUnique()
.HasFilter("provider_trade_no is not null");
builder.ToTable(table =>
@@ -160,7 +160,7 @@ internal sealed class PaymentEventConfiguration : IEntityTypeConfiguration<Payme
builder.Property(entity => entity.EventId).HasMaxLength(200);
builder.Property(entity => entity.Payload).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.Provider, entity.EventId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.EventId }).IsUnique();
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Payment>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PaymentId })
@@ -438,7 +438,7 @@ internal sealed class CommerceReconciliationItemConfiguration : IEntityTypeConfi
builder.Property(entity => entity.IssueCode).HasMaxLength(100);
builder.Property(entity => entity.Details).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.BatchId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.MatchStatus, entity.RowNo });
builder.HasIndex(entity => new { entity.TenantId, entity.OrderNo, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.RefundNo, entity.ProviderRefundNo, entity.CreatedAt });

View File

@@ -180,7 +180,7 @@ internal sealed class QuestionCollectionItemConfiguration :
{
entity.TenantId,
entity.CollectionId,
entity.QuestionId
entity.QuestionReferenceId
}).IsUnique();
builder.HasIndex(entity => new
{
@@ -194,10 +194,14 @@ internal sealed class QuestionCollectionItemConfiguration :
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -65,72 +65,48 @@ internal sealed class ContentAssetSecurityScanEventConfiguration : IEntityTypeCo
}
}
internal sealed class QuestionBankGrantConfiguration : IEntityTypeConfiguration<QuestionBankGrant>
internal sealed class TenantQuestionBankPreferenceConfiguration : IEntityTypeConfiguration<TenantQuestionBankPreference>
{
public void Configure(EntityTypeBuilder<QuestionBankGrant> builder)
public void Configure(EntityTypeBuilder<TenantQuestionBankPreference> builder)
{
builder.ConfigureEntity("question_bank_grants");
builder.ConfigureTenantEntity("tenant_question_bank_preferences");
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.Alias).HasMaxLength(300);
builder.Property(entity => entity.NavigationLocation).HasMaxLength(100);
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.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionBankOwnerTenantId,
entity.QuestionBankId
}).IsUnique();
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => entity.SourceQuestionBankId)
.OnDelete(DeleteBehavior.Cascade);
.HasForeignKey(entity => new { entity.QuestionBankOwnerTenantId, entity.QuestionBankId })
.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 TenantQuestionBankAdoptionConfiguration : IEntityTypeConfiguration<TenantQuestionBankAdoption>
internal sealed class TenantQuestionReferenceConfiguration : IEntityTypeConfiguration<TenantQuestionReference>
{
public void Configure(EntityTypeBuilder<TenantQuestionBankAdoption> builder)
public void Configure(EntityTypeBuilder<TenantQuestionReference> builder)
{
builder.ConfigureTenantEntity("tenant_question_bank_adoptions");
builder.ConfigureTenantEntity("tenant_question_references");
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 })
builder.Property(entity => entity.Source).HasSnakeCaseEnum();
builder.HasAlternateKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
});
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.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);
}
}

View File

@@ -17,7 +17,6 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
builder.Property(entity => entity.Mode).HasMaxLength(50);
builder.Property(entity => entity.TargetType).HasMaxLength(50);
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.QuestionIds).IsJson("[]");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.AccessMode)
.HasSnakeCaseEnum()
@@ -55,6 +54,45 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
}
}
internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfiguration<PracticeSessionQuestion>
{
public void Configure(EntityTypeBuilder<PracticeSessionQuestion> builder)
{
builder.ConfigureTenantEntity("practice_session_questions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Id });
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Position }).IsUnique();
builder.Property(entity => entity.Score).HasPrecision(8, 2);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
TenantId = entity.QuestionOwnerTenantId,
entity.QuestionId,
Id = entity.QuestionVersionId
})
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
{
public void Configure(EntityTypeBuilder<AnswerRecord> builder)
@@ -67,13 +105,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
});
builder.HasIndex(entity => new
{
entity.TenantId,
@@ -81,31 +112,9 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
entity.PracticeSessionId
});
builder.ToTable(table => table.HasCheckConstraint(
"ck_answer_records_version_requires_question",
"question_version_id is null or question_id is not null"));
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new
{
@@ -120,6 +129,20 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSessionQuestion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.PracticeSessionId,
Id = entity.SessionQuestionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.PracticeSessionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -128,7 +151,7 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
{
builder.ToTable("favorite_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
builder.Property(entity => entity.Source).HasMaxLength(50);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
@@ -138,10 +161,14 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -150,7 +177,7 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
public void Configure(EntityTypeBuilder<WrongQuestion> builder)
{
builder.ToTable("wrong_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
@@ -160,10 +187,14 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -59,7 +59,7 @@ internal sealed class TenantInvoiceConfiguration : IEntityTypeConfiguration<Tena
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Currency).HasMaxLength(10);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.InvoiceNo).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.DueDate });
builder.HasIndex(entity => new { entity.TenantId, entity.BillingPeriodStart, entity.BillingPeriodEnd })
.IsUnique()
@@ -110,7 +110,7 @@ internal sealed class TenantInvoicePaymentConfiguration : IEntityTypeConfigurati
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ProviderTradeNo).HasMaxLength(200);
builder.Property(entity => entity.RawPayload).IsJson("{}");
builder.HasIndex(entity => entity.PaymentNo).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.PaymentNo }).IsUnique();
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status });
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"));
builder.HasOne<TenantInvoice>().WithMany()
@@ -229,7 +229,7 @@ internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTyp
builder.Property(entity => entity.RequestPayload).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.ScheduledAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.ChannelId, entity.ReminderId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.ReminderId }).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.NextAttemptAt, entity.ScheduledAt, entity.CreatedAt });
builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt });

View File

@@ -14,7 +14,6 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<Quest
builder.ConfigureTenantEntity("question_banks");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.SourceScope).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
@@ -94,7 +93,7 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<Qu
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TaxonomyNodeConfiguration : IEntityTypeConfiguration<TaxonomyNode>
{
public void Configure(EntityTypeBuilder<TaxonomyNode> builder)
{
builder.ConfigureTenantEntity("taxonomy_nodes");
builder.ConfigureTimestamps();
builder.Property(entity => entity.NodeType).HasSnakeCaseEnum();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Path).HasColumnType("ltree");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasOne<TaxonomyNode>().WithMany()
.HasForeignKey(entity => new { TenantId = entity.ParentOwnerTenantId, Id = entity.ParentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint(
"ck_taxonomy_nodes_parent_pair",
"(parent_owner_tenant_id is null) = (parent_id is null)"));
}
}
internal sealed class QuestionTaxonomyAssignmentConfiguration : IEntityTypeConfiguration<QuestionTaxonomyAssignment>
{
public void Configure(EntityTypeBuilder<QuestionTaxonomyAssignment> builder)
{
builder.ConfigureEntity("question_taxonomy_assignments");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.TaxonomyOwnerTenantId,
entity.TaxonomyNodeId
}).IsUnique();
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TaxonomyNode>().WithMany()
.HasForeignKey(entity => new { TenantId = entity.TaxonomyOwnerTenantId, Id = entity.TaxonomyNodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -23,6 +23,9 @@ internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
builder.HasIndex(entity => entity.Slug).IsUnique();
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Mode)
.IsUnique()
.HasFilter("mode = 'platform_owned'");
builder.HasOne<User>()
.WithMany()
@@ -67,7 +70,10 @@ internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration<Tenan
builder.Property(entity => entity.DomainType).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.VerificationToken).HasMaxLength(256);
builder.HasIndex(entity => entity.Host).IsUnique();
builder.Property(entity => entity.LastFailureReason).HasMaxLength(2000);
builder.HasIndex(entity => entity.Host)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.IsPrimary })
.IsUnique()
.HasFilter("is_primary");
@@ -114,3 +120,29 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<Ten
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
{
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
{
builder.ConfigureTenantEntity("tenant_frontend_configs");
builder.ConfigureTimestamps();
builder.HasIndex(entity => entity.TenantId).IsUnique();
builder.Property(entity => entity.ConfigVersion).IsConcurrencyToken();
builder.Property(entity => entity.PublishedBranding).IsJson("{}");
builder.Property(entity => entity.PublishedTheme).IsJson("{}");
builder.Property(entity => entity.PublishedFeatures).IsJson("{}");
builder.Property(entity => entity.PublishedNavigation).IsJson("[]");
builder.Property(entity => entity.PublishedHomeModules).IsJson("[]");
builder.Property(entity => entity.DraftBranding).IsJson("{}");
builder.Property(entity => entity.DraftTheme).IsJson("{}");
builder.Property(entity => entity.DraftFeatures).IsJson("{}");
builder.Property(entity => entity.DraftNavigation).IsJson("[]");
builder.Property(entity => entity.DraftHomeModules).IsJson("[]");
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_frontend_configs_schema_version", "schema_version > 0");
table.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0");
});
}
}

View File

@@ -111,7 +111,9 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.TokenHash).IsUnique();
builder.HasIndex(entity => entity.TokenHash)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt })
.HasFilter("revoked_at is null");

View File

@@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProductActiveAndOperationsCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_products_tenant_id_region_id_type_sort_order",
table: "products");
migrationBuilder.AddColumn<bool>(
name: "is_active",
table: "products",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.CreateIndex(
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
table: "products",
columns: new[] { "tenant_id", "region_id", "type", "is_active", "sort_order" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
table: "products");
migrationBuilder.DropColumn(
name: "is_active",
table: "products");
migrationBuilder.CreateIndex(
name: "ix_products_tenant_id_region_id_type_sort_order",
table: "products",
columns: new[] { "tenant_id", "region_id", "type", "sort_order" });
}
}
}

View File

@@ -1,157 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddScorelineDynamicRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "scoreline_fields",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
field_key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
field_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
field_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
unit = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
is_filter = table.Column<bool>(type: "boolean", nullable: false),
is_required = table.Column<bool>(type: "boolean", nullable: false),
is_visible = table.Column<bool>(type: "boolean", nullable: false),
is_trend = table.Column<bool>(type: "boolean", nullable: false),
options = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
placeholder = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
sort_order = 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_scoreline_fields", x => x.id);
table.UniqueConstraint("ak_scoreline_fields_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_fields_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_scoreline_fields_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "scoreline_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
school_id = table.Column<Guid>(type: "uuid", nullable: true),
major_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
year = table.Column<int>(type: "integer", nullable: false),
school_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
major_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
field_values = 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_scoreline_records", x => x.id);
table.UniqueConstraint("ak_scoreline_records_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_records_majors_tenant_id_major_id",
columns: x => new { x.tenant_id, x.major_id },
principalTable: "majors",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_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_scoreline_records_schools_tenant_id_school_id",
columns: x => new { x.tenant_id, x.school_id },
principalTable: "schools",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_legacy_id",
table: "scoreline_fields",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_field_key",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "field_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "is_filter", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_legacy_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_major_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "major_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~",
table: "scoreline_records",
columns: new[] { "tenant_id", "region_id", "school_id", "major_id", "year" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_school_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "school_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_year",
table: "scoreline_records",
columns: new[] { "tenant_id", "year" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "scoreline_fields");
migrationBuilder.DropTable(
name: "scoreline_records");
}
}
}

View File

@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStudentProfileAvatarPreset : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "avatar_preset",
table: "student_profiles",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "male");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "avatar_preset",
table: "student_profiles");
}
}
}

View File

@@ -1,69 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPaymentSdkAndTenantSecretFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tenant_secrets",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
purpose = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
secret_key = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
secret_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
rotated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
expires_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_secrets", x => x.id);
table.UniqueConstraint("ak_tenant_secrets_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_secrets_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
table: "tenant_secrets",
columns: new[] { "tenant_id", "purpose", "provider", "secret_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_secrets_tenant_id_purpose_provider_status",
table: "tenant_secrets",
columns: new[] { "tenant_id", "purpose", "provider", "status" });
migrationBuilder.CreateIndex(
name: "ix_tenant_secrets_tenant_id_secret_ref",
table: "tenant_secrets",
columns: new[] { "tenant_id", "secret_ref" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tenant_secrets");
}
}
}

View File

@@ -1,265 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPointsPersistenceFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "point_activity_tasks",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
task_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
task_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
points = table.Column<int>(type: "integer", nullable: false),
max_claims_per_user = table.Column<int>(type: "integer", nullable: false),
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
rules = 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_point_activity_tasks", x => x.id);
table.UniqueConstraint("ak_point_activity_tasks_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_point_activity_tasks_max_claims", "max_claims_per_user > 0");
table.CheckConstraint("ck_point_activity_tasks_points", "points > 0");
table.ForeignKey(
name: "fk_point_activity_tasks_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "point_exchange_items",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
item_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
points_cost = table.Column<int>(type: "integer", nullable: false),
stock = table.Column<int>(type: "integer", nullable: true),
days = table.Column<int>(type: "integer", nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
fulfillment_payload = 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_point_exchange_items", x => x.id);
table.UniqueConstraint("ak_point_exchange_items_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_point_exchange_items_days", "days is null or days >= 0");
table.CheckConstraint("ck_point_exchange_items_points_cost", "points_cost > 0");
table.CheckConstraint("ck_point_exchange_items_stock", "stock is null or stock >= 0");
table.ForeignKey(
name: "fk_point_exchange_items_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_point_exchange_items_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "point_activity_claims",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
task_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
task_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
points = table.Column<int>(type: "integer", nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
source_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
source_id = table.Column<Guid>(type: "uuid", nullable: true),
claimed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
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_point_activity_claims", x => x.id);
table.UniqueConstraint("ak_point_activity_claims_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_point_activity_claims_points", "points > 0");
table.ForeignKey(
name: "fk_point_activity_claims_point_activity_tasks_tenant_id_task_id",
columns: x => new { x.tenant_id, x.task_id },
principalTable: "point_activity_tasks",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_point_activity_claims_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_point_activity_claims_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "point_exchange_orders",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
item_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
order_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
item_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
points_cost = table.Column<int>(type: "integer", nullable: false),
ordered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
cancelled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
fulfillment_snapshot = 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_point_exchange_orders", x => x.id);
table.UniqueConstraint("ak_point_exchange_orders_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_point_exchange_orders_points_cost", "points_cost > 0");
table.ForeignKey(
name: "fk_point_exchange_orders_point_exchange_items_tenant_id_item_id",
columns: x => new { x.tenant_id, x.item_id },
principalTable: "point_exchange_items",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_point_exchange_orders_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_point_exchange_orders_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_point_activity_claims_tenant_id_task_id",
table: "point_activity_claims",
columns: new[] { "tenant_id", "task_id" });
migrationBuilder.CreateIndex(
name: "ix_point_activity_claims_tenant_id_user_id_created_at",
table: "point_activity_claims",
columns: new[] { "tenant_id", "user_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_point_activity_claims_tenant_id_user_id_task_id_source_type~",
table: "point_activity_claims",
columns: new[] { "tenant_id", "user_id", "task_id", "source_type", "source_id" },
unique: true,
filter: "source_type is not null and source_id is not null");
migrationBuilder.CreateIndex(
name: "ix_point_activity_claims_user_id",
table: "point_activity_claims",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_point_activity_tasks_tenant_id_status_sort_order",
table: "point_activity_tasks",
columns: new[] { "tenant_id", "status", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_point_activity_tasks_tenant_id_task_key",
table: "point_activity_tasks",
columns: new[] { "tenant_id", "task_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_point_exchange_items_tenant_id_item_key",
table: "point_exchange_items",
columns: new[] { "tenant_id", "item_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_point_exchange_items_tenant_id_region_id_status_sort_order",
table: "point_exchange_items",
columns: new[] { "tenant_id", "region_id", "status", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_point_exchange_orders_tenant_id_item_id",
table: "point_exchange_orders",
columns: new[] { "tenant_id", "item_id" });
migrationBuilder.CreateIndex(
name: "ix_point_exchange_orders_tenant_id_order_no",
table: "point_exchange_orders",
columns: new[] { "tenant_id", "order_no" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_point_exchange_orders_tenant_id_user_id_created_at",
table: "point_exchange_orders",
columns: new[] { "tenant_id", "user_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_point_exchange_orders_user_id",
table: "point_exchange_orders",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "point_activity_claims");
migrationBuilder.DropTable(
name: "point_exchange_orders");
migrationBuilder.DropTable(
name: "point_activity_tasks");
migrationBuilder.DropTable(
name: "point_exchange_items");
}
}
}

View File

@@ -1,157 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCommissionSettlementProofs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "commission_settlement_export_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),
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
exported_by = table.Column<Guid>(type: "uuid", nullable: true),
export_format = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
filename = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
row_count = table.Column<int>(type: "integer", nullable: false),
content_sha256 = table.Column<string>(type: "character varying(128)", maxLength: 128, 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_commission_settlement_export_events", x => x.id);
table.CheckConstraint("ck_commission_export_events_rows", "row_count >= 0");
table.ForeignKey(
name: "fk_commission_settlement_export_events_commission_settlements_~",
columns: x => new { x.tenant_id, x.settlement_id },
principalTable: "commission_settlements",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_export_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_export_events_users_exported_by",
column: x => x.exported_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "commission_settlement_proofs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
asset_id = table.Column<Guid>(type: "uuid", nullable: true),
submitted_by = table.Column<Guid>(type: "uuid", nullable: true),
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
proof_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
description = table.Column<string>(type: "text", nullable: true),
external_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
amount_cents = table.Column<int>(type: "integer", nullable: true),
payment_method = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
payment_account = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
paid_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
review_note = table.Column<string>(type: "text", 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_commission_settlement_proofs", x => x.id);
table.UniqueConstraint("ak_commission_settlement_proofs_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_commission_settlement_proofs_amount", "amount_cents is null or amount_cents >= 0");
table.ForeignKey(
name: "fk_commission_settlement_proofs_commission_settlements_tenant_~",
columns: x => new { x.tenant_id, x.settlement_id },
principalTable: "commission_settlements",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_proofs_content_assets_tenant_id_asset~",
columns: x => new { x.tenant_id, x.asset_id },
principalTable: "content_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commission_settlement_proofs_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_proofs_users_reviewed_by",
column: x => x.reviewed_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commission_settlement_proofs_users_submitted_by",
column: x => x.submitted_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_export_events_exported_by",
table: "commission_settlement_export_events",
column: "exported_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_export_events_tenant_id_settlement_id~",
table: "commission_settlement_export_events",
columns: new[] { "tenant_id", "settlement_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_reviewed_by",
table: "commission_settlement_proofs",
column: "reviewed_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_submitted_by",
table: "commission_settlement_proofs",
column: "submitted_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_tenant_id_asset_id",
table: "commission_settlement_proofs",
columns: new[] { "tenant_id", "asset_id" });
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_tenant_id_settlement_id_status~",
table: "commission_settlement_proofs",
columns: new[] { "tenant_id", "settlement_id", "status", "created_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "commission_settlement_export_events");
migrationBuilder.DropTable(
name: "commission_settlement_proofs");
}
}
}

View File

@@ -1,84 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class EncryptTenantSecretPayloads : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "secret_payload",
table: "tenant_secrets");
migrationBuilder.AddColumn<byte[]>(
name: "encrypted_payload",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<string>(
name: "encryption_key_id",
table: "tenant_secrets",
type: "character varying(100)",
maxLength: 100,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<byte[]>(
name: "encryption_nonce",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<byte[]>(
name: "encryption_tag",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets",
sql: "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encrypted_payload",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_key_id",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_nonce",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_tag",
table: "tenant_secrets");
migrationBuilder.AddColumn<JsonElement>(
name: "secret_payload",
table: "tenant_secrets",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
}
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Tiku.Application.Security;
namespace Tiku.Infrastructure.Persistence;
public sealed class TenantIsolationSaveChangesInterceptor(ITenantContext tenantContext) : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
Enforce(eventData.Context);
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Enforce(eventData.Context);
return ValueTask.FromResult(result);
}
private void Enforce(DbContext? dbContext)
{
if (dbContext is null)
{
return;
}
foreach (var entry in dbContext.ChangeTracker.Entries()
.Where(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted))
{
var tenantProperty = entry.Metadata.FindProperty("TenantId");
if (tenantProperty?.ClrType != typeof(Guid))
{
continue;
}
var property = entry.Property("TenantId");
var currentTenantId = (Guid)(property.CurrentValue ?? Guid.Empty);
var originalTenantId = (Guid)(property.OriginalValue ?? Guid.Empty);
if (entry.State == EntityState.Added)
{
if (!tenantContext.TenantId.HasValue && !tenantContext.IsSystem)
{
throw new TenantIsolationException(
entry.Metadata.ClrType,
"Cannot add tenant-owned data without a resolved tenant.");
}
if (currentTenantId == Guid.Empty && tenantContext.TenantId.HasValue)
{
property.CurrentValue = tenantContext.TenantId.Value;
currentTenantId = tenantContext.TenantId.Value;
}
if (!tenantContext.IsSystem && currentTenantId != tenantContext.TenantId)
{
throw new TenantIsolationException(
entry.Metadata.ClrType,
"Cannot add data for another tenant.");
}
continue;
}
if (property.IsModified || currentTenantId != originalTenantId)
{
throw new TenantIsolationException(
entry.Metadata.ClrType,
"Tenant ownership cannot be changed.");
}
if (!tenantContext.IsSystem &&
(!tenantContext.TenantId.HasValue || originalTenantId != tenantContext.TenantId.Value))
{
throw new TenantIsolationException(
entry.Metadata.ClrType,
"Cannot modify or delete data owned by another tenant.");
}
}
}
}
public sealed class TenantIsolationException(Type entityType, string message)
: InvalidOperationException($"Tenant isolation rejected {entityType.Name}: {message}");

View File

@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
@@ -14,8 +16,26 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence;
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
public sealed class TikuDbContext(
DbContextOptions<TikuDbContext> options,
ITenantContext tenantContext) : DbContext(options)
{
public TikuDbContext(DbContextOptions<TikuDbContext> options)
: this(options, CreateToolingTenantContext())
{
}
public Guid? CurrentTenantId => tenantContext.TenantId;
public Guid CurrentTenantIdOrEmpty => tenantContext.TenantId ?? Guid.Empty;
public bool IsTenantResolved => tenantContext.IsResolved;
public bool IsSystemScope => tenantContext.IsSystem;
private static ITenantContext CreateToolingTenantContext()
{
var context = new TenantContext();
context.InitializeSystem(null, "Direct DbContext construction for model tooling");
return context;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
@@ -23,6 +43,7 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
public DbSet<TenantAuthProvider> TenantAuthProviders => Set<TenantAuthProvider>();
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
@@ -42,6 +63,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<Major> Majors => Set<Major>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<TaxonomyNode> TaxonomyNodes => Set<TaxonomyNode>();
public DbSet<QuestionTaxonomyAssignment> QuestionTaxonomyAssignments => Set<QuestionTaxonomyAssignment>();
public DbSet<ScorelineField> ScorelineFields => Set<ScorelineField>();
public DbSet<ScorelineRecord> ScorelineRecords => Set<ScorelineRecord>();
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
@@ -71,10 +94,11 @@ 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<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
@@ -155,9 +179,97 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
modelBuilder.HasPostgresExtension("citext");
modelBuilder.HasPostgresExtension("ltree");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
ApplyTenantQueryFilters(modelBuilder);
ValidateTenantModel(modelBuilder);
modelBuilder.UseSnakeCaseIdentifiers();
}
private void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
{
var applyMethod = typeof(TikuDbContext)
.GetMethod(nameof(ApplyTenantQueryFilter), BindingFlags.Instance | BindingFlags.NonPublic)!;
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var tenantProperty = entityType.FindProperty("TenantId");
if (tenantProperty?.ClrType != typeof(Guid) || entityType.BaseType is not null)
{
continue;
}
applyMethod.MakeGenericMethod(entityType.ClrType).Invoke(this, [modelBuilder]);
}
}
private void ApplyTenantQueryFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity =>
IsSystemScope ||
(IsTenantResolved &&
EF.Property<Guid>(entity, "TenantId") == CurrentTenantIdOrEmpty));
}
private static void ValidateTenantModel(ModelBuilder modelBuilder)
{
var invalidUniqueIndexes = new List<string>();
var invalidTenantForeignKeys = new List<string>();
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var tenantProperty = entityType.FindProperty("TenantId");
if (tenantProperty?.ClrType != typeof(Guid))
{
continue;
}
if (!typeof(ITenantOwned).IsAssignableFrom(entityType.ClrType))
{
throw new InvalidOperationException(
$"Tenant entity '{entityType.ClrType.Name}' must implement {nameof(ITenantOwned)}.");
}
if (!entityType.GetDeclaredQueryFilters().Any())
{
throw new InvalidOperationException(
$"Tenant entity '{entityType.ClrType.Name}' does not have a tenant query filter.");
}
foreach (var index in entityType.GetDeclaredIndexes().Where(index => index.IsUnique))
{
if (index.Properties.All(property => property.Name != "TenantId") &&
index.FindAnnotation("Tiku:GlobalUnique")?.Value is not true)
{
invalidUniqueIndexes.Add(
$"{entityType.ClrType.Name}({string.Join(",", index.Properties.Select(property => property.Name))})");
}
}
foreach (var foreignKey in entityType.GetDeclaredForeignKeys().Where(foreignKey =>
typeof(ITenantOwned).IsAssignableFrom(foreignKey.PrincipalEntityType.ClrType)))
{
if (foreignKey.PrincipalKey.Properties.All(property => property.Name != "TenantId"))
{
invalidTenantForeignKeys.Add(
$"{entityType.ClrType.Name}->{foreignKey.PrincipalEntityType.ClrType.Name}");
}
}
}
if (invalidUniqueIndexes.Count > 0)
{
throw new InvalidOperationException(
$"Unique indexes on tenant entities must include TenantId: {string.Join("; ", invalidUniqueIndexes)}");
}
if (invalidTenantForeignKeys.Count > 0)
{
throw new InvalidOperationException(
$"Foreign keys between tenant entities must use a tenant-qualified principal key: {string.Join("; ", invalidTenantForeignKeys)}");
}
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
UpdateTimestamps();